Skip to content

Commit 74aff15

Browse files
authored
feat: v0.4 dev (#24)
* feat: impl HTTP transports for mcp server Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * fix: tql start, end format Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * fix: sql injection in tql params Signed-off-by: Dennis Zhuang <killme2008@gmail.com> --------- Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
1 parent bce74b4 commit 74aff15

10 files changed

Lines changed: 583 additions & 12 deletions

File tree

README.md

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,32 @@ Claude will:
115115
4. Test it with `dryrun_pipeline` tool
116116

117117
## Security
118-
All queries pass through a security gate that:
118+
119+
### Database User Configuration (Recommended)
120+
121+
For production deployments, create a **read-only database user** for the MCP server. This provides defense-in-depth security at the database level.
122+
123+
Configure a read-only user in GreptimeDB using [static user provider](https://docs.greptime.com/user-guide/deployments-administration/authentication/static/#permission-modes):
124+
125+
```
126+
# User format: username:permission_mode=password
127+
mcp_readonly:readonly=your_secure_password
128+
```
129+
130+
Permission modes:
131+
- `readonly` (or `ro`) - Can only query data (recommended for MCP server)
132+
- `writeonly` (or `wo`) - Can only write data
133+
- `readwrite` (or `rw`) - Full access (default)
134+
135+
Then configure the MCP server to use this user:
136+
```bash
137+
GREPTIMEDB_USER=mcp_readonly
138+
GREPTIMEDB_PASSWORD=your_secure_password
139+
```
140+
141+
### Application-Level Security Gate
142+
143+
All queries also pass through a security gate that:
119144
- Blocks DDL/DML operations: DROP, DELETE, TRUNCATE, UPDATE, INSERT, ALTER, CREATE, GRANT, REVOKE
120145
- Blocks dynamic SQL execution: EXEC, EXECUTE, CALL
121146
- Blocks data modification: REPLACE INTO
@@ -178,6 +203,11 @@ GREPTIMEDB_TIMEZONE=UTC
178203
GREPTIMEDB_POOL_SIZE=5 # Optional: Connection pool size (defaults to 5)
179204
GREPTIMEDB_MASK_ENABLED=true # Optional: Enable data masking (defaults to true)
180205
GREPTIMEDB_MASK_PATTERNS= # Optional: Additional sensitive column patterns (comma-separated)
206+
207+
# MCP Server Transport Options
208+
GREPTIMEDB_TRANSPORT=stdio # Optional: Transport mode (stdio, sse, streamable-http, defaults to stdio)
209+
GREPTIMEDB_LISTEN_HOST=0.0.0.0 # Optional: HTTP server bind host (defaults to 0.0.0.0)
210+
GREPTIMEDB_LISTEN_PORT=8080 # Optional: HTTP server bind port (defaults to 8080)
181211
```
182212

183213
Or via command-line args:
@@ -192,7 +222,33 @@ Or via command-line args:
192222
* `--timezone` the session time zone, empty by default (using server default time zone),
193223
* `--pool-size` the connection pool size, `5` by default,
194224
* `--mask-enabled` enable data masking for sensitive columns, `true` by default,
195-
* `--mask-patterns` additional sensitive column patterns (comma-separated), empty by default.
225+
* `--mask-patterns` additional sensitive column patterns (comma-separated), empty by default,
226+
* `--transport` MCP transport mode (`stdio`, `sse`, `streamable-http`), `stdio` by default,
227+
* `--listen-host` HTTP server bind host (for sse/streamable-http), `0.0.0.0` by default,
228+
* `--listen-port` HTTP server bind port (for sse/streamable-http), `8080` by default.
229+
230+
## HTTP Server Mode
231+
232+
For containerized or Kubernetes deployments, you can run the MCP server in HTTP mode instead of stdio:
233+
234+
```bash
235+
# Streamable HTTP mode (recommended for production)
236+
greptimedb-mcp-server --transport streamable-http --listen-port 8080
237+
238+
# SSE mode (legacy, for older clients)
239+
greptimedb-mcp-server --transport sse --listen-host 0.0.0.0 --listen-port 3000
240+
241+
# Via environment variables (for Docker/K8s)
242+
GREPTIMEDB_TRANSPORT=streamable-http \
243+
GREPTIMEDB_LISTEN_HOST=0.0.0.0 \
244+
GREPTIMEDB_LISTEN_PORT=8080 \
245+
greptimedb-mcp-server
246+
```
247+
248+
**Transport modes:**
249+
- `stdio` (default): Standard input/output, for local CLI integration (e.g., Claude Desktop)
250+
- `streamable-http`: HTTP-based transport with SSE streaming, recommended for remote/production deployments
251+
- `sse`: Server-Sent Events transport (legacy, being deprecated in MCP spec)
196252

197253
# Usage
198254

src/greptimedb_mcp_server/config.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,21 @@ class Config:
6464
Additional sensitive column patterns (comma-separated)
6565
"""
6666

67+
transport: str
68+
"""
69+
MCP transport mode: stdio, sse, or streamable-http
70+
"""
71+
72+
listen_host: str
73+
"""
74+
MCP HTTP server bind host (for sse/streamable-http transports)
75+
"""
76+
77+
listen_port: int
78+
"""
79+
MCP HTTP server bind port (for sse/streamable-http transports)
80+
"""
81+
6782
@staticmethod
6883
def from_env_arguments() -> "Config":
6984
"""
@@ -149,6 +164,28 @@ def from_env_arguments() -> "Config":
149164
default=os.getenv("GREPTIMEDB_MASK_PATTERNS", ""),
150165
)
151166

167+
parser.add_argument(
168+
"--transport",
169+
type=str,
170+
choices=["stdio", "sse", "streamable-http"],
171+
help="MCP transport mode (default: stdio)",
172+
default=os.getenv("GREPTIMEDB_TRANSPORT", "stdio"),
173+
)
174+
175+
parser.add_argument(
176+
"--listen-host",
177+
type=str,
178+
help="MCP HTTP server bind host (default: 0.0.0.0)",
179+
default=os.getenv("GREPTIMEDB_LISTEN_HOST", "0.0.0.0"),
180+
)
181+
182+
parser.add_argument(
183+
"--listen-port",
184+
type=int,
185+
help="MCP HTTP server bind port (default: 8080)",
186+
default=int(os.getenv("GREPTIMEDB_LISTEN_PORT", "8080")),
187+
)
188+
152189
args = parser.parse_args()
153190
return Config(
154191
host=args.host,
@@ -162,4 +199,7 @@ def from_env_arguments() -> "Config":
162199
http_protocol=args.http_protocol,
163200
mask_enabled=args.mask_enabled,
164201
mask_patterns=args.mask_patterns,
202+
transport=args.transport,
203+
listen_host=args.listen_host,
204+
listen_port=args.listen_port,
165205
)

src/greptimedb_mcp_server/server.py

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
validate_query_component,
1111
validate_duration,
1212
validate_fill,
13+
validate_time_expression,
14+
format_tql_time_param,
1315
)
1416

1517
import asyncio
@@ -77,10 +79,26 @@ def get_http_auth(self) -> aiohttp.BasicAuth | None:
7779
return None
7880

7981

82+
# Global config (set by main() before run())
83+
_config: Config | None = None
84+
8085
# Global state (initialized in lifespan)
8186
_state: AppState | None = None
8287

8388

89+
def get_config() -> Config:
90+
"""Get the parsed configuration.
91+
92+
Falls back to parsing from env/args if not pre-initialized by main().
93+
This preserves compatibility with alternative entry points like
94+
`mcp dev greptimedb_mcp_server.server:mcp` or programmatic imports.
95+
"""
96+
global _config
97+
if _config is None:
98+
_config = Config.from_env_arguments()
99+
return _config
100+
101+
84102
def get_state() -> AppState:
85103
"""Get the application state."""
86104
if _state is None:
@@ -93,7 +111,7 @@ async def lifespan(mcp: FastMCP):
93111
"""Initialize application state on startup."""
94112
global _state
95113

96-
config = Config.from_env_arguments()
114+
config = get_config()
97115
db_config = {
98116
"host": config.host,
99117
"port": config.port,
@@ -337,9 +355,14 @@ async def execute_tql(
337355
"Example: rate(http_requests_total[5m])",
338356
],
339357
start: Annotated[
340-
str, "Start time (RFC3339, Unix timestamp, or relative like 'now-1h')"
358+
str,
359+
"Start time: SQL expression (e.g., \"now() - interval '5' minute\"), "
360+
"RFC3339 (e.g., '2024-01-01T00:00:00Z'), or Unix timestamp",
361+
],
362+
end: Annotated[
363+
str,
364+
"End time: SQL expression (e.g., 'now()'), " "RFC3339, or Unix timestamp",
341365
],
342-
end: Annotated[str, "End time (RFC3339, Unix timestamp, or relative like 'now')"],
343366
step: Annotated[str, "Query resolution step, e.g., '1m', '5m', '1h'"],
344367
lookback: Annotated[str | None, "Lookback delta for range queries"] = None,
345368
format: Annotated[
@@ -354,8 +377,8 @@ async def execute_tql(
354377
if format not in VALID_FORMATS:
355378
raise ValueError(f"Invalid format: {format}. Must be one of: {VALID_FORMATS}")
356379

357-
validate_tql_param(start, "start")
358-
validate_tql_param(end, "end")
380+
validate_time_expression(start, "start")
381+
validate_time_expression(end, "end")
359382
validate_tql_param(step, "step")
360383
if lookback:
361384
validate_tql_param(lookback, "lookback")
@@ -364,10 +387,12 @@ async def execute_tql(
364387
if is_dangerous:
365388
return f"Error: Dangerous operation blocked: {reason}"
366389

390+
start_param = format_tql_time_param(start)
391+
end_param = format_tql_time_param(end)
367392
if lookback:
368-
tql = f"TQL EVAL ('{start}', '{end}', '{step}', '{lookback}') {query}"
393+
tql = f"TQL EVAL ({start_param}, {end_param}, '{step}', '{lookback}') {query}"
369394
else:
370-
tql = f"TQL EVAL ('{start}', '{end}', '{step}') {query}"
395+
tql = f"TQL EVAL ({start_param}, {end_param}, '{step}') {query}"
371396

372397
start_time = time.time()
373398

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

835860
def main():
836861
"""Main entry point."""
837-
mcp.run()
862+
global _config
863+
_config = Config.from_env_arguments()
864+
865+
# Only configure HTTP server settings for non-stdio transports
866+
# to avoid overriding user's programmatic configuration
867+
if _config.transport != "stdio":
868+
mcp.settings.host = _config.listen_host
869+
mcp.settings.port = _config.listen_port
870+
logger.info(
871+
f"Starting MCP server with transport: {_config.transport} "
872+
f"on {_config.listen_host}:{_config.listen_port}"
873+
)
874+
else:
875+
logger.info("Starting MCP server with transport: stdio")
876+
877+
mcp.run(transport=_config.transport)
838878

839879

840880
if __name__ == "__main__":

src/greptimedb_mcp_server/templates/promql_analysis/config.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ arguments:
55
description: "The metric name to query (required)."
66
required: true
77
- name: "start_time"
8-
description: "Query start time (e.g., 'now-1h' or ISO timestamp)."
8+
description: "Start time: SQL expression (now() - interval '5' minute), RFC3339, or Unix timestamp."
99
required: true
1010
- name: "end_time"
11-
description: "Query end time (e.g., 'now' or ISO timestamp)."
11+
description: "End time: SQL expression (now()), RFC3339, or Unix timestamp."
1212
required: true
1313
metadata:
1414
tags:

src/greptimedb_mcp_server/templates/promql_analysis/template.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ histogram_quantile(0.99, rate({{ metric }}_bucket[5m])) > 0.5
5757
## Notes
5858

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

src/greptimedb_mcp_server/utils.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,3 +135,32 @@ def validate_fill(value: str) -> str:
135135
if not FILL_PATTERN.match(value):
136136
raise ValueError("Invalid fill: must be NULL, PREV, LINEAR, or a number")
137137
return value
138+
139+
140+
def is_sql_time_expression(value: str) -> bool:
141+
"""Check if value is a SQL time expression (contains function call)."""
142+
return "(" in value
143+
144+
145+
def format_tql_time_param(value: str) -> str:
146+
"""Format time parameter for TQL: quote literals, leave SQL expressions as-is."""
147+
if is_sql_time_expression(value):
148+
return value
149+
# Escape single quotes in literal values to avoid breaking the TQL statement
150+
safe_value = value.replace("'", "''")
151+
return f"'{safe_value}'"
152+
153+
154+
def validate_time_expression(value: str, name: str) -> str:
155+
"""Validate time expression for TQL start/end parameters."""
156+
if not value:
157+
raise ValueError(f"{name} is required")
158+
if ";" in value or "--" in value:
159+
raise ValueError(f"Invalid characters in {name}")
160+
# Guard against malformed or injected strings with unbalanced quotes
161+
if value.count("'") % 2 != 0:
162+
raise ValueError(f"Unbalanced quotes in {name}")
163+
is_dangerous, reason = security_gate(value)
164+
if is_dangerous:
165+
raise ValueError(f"Dangerous pattern in {name}: {reason}")
166+
return value

tests/test_config.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ def test_config_default_values():
2020
assert config.http_protocol == "http"
2121
assert config.mask_enabled is True
2222
assert config.mask_patterns == ""
23+
assert config.transport == "stdio"
24+
assert config.listen_host == "0.0.0.0"
25+
assert config.listen_port == 8080
2326

2427

2528
def test_config_env_variables():
@@ -36,6 +39,9 @@ def test_config_env_variables():
3639
"GREPTIMEDB_HTTP_PROTOCOL": "https",
3740
"GREPTIMEDB_MASK_ENABLED": "false",
3841
"GREPTIMEDB_MASK_PATTERNS": "phone,address",
42+
"GREPTIMEDB_TRANSPORT": "streamable-http",
43+
"GREPTIMEDB_LISTEN_HOST": "127.0.0.1",
44+
"GREPTIMEDB_LISTEN_PORT": "3000",
3945
}
4046

4147
with patch.dict(os.environ, env_vars):
@@ -51,6 +57,9 @@ def test_config_env_variables():
5157
assert config.http_protocol == "https"
5258
assert config.mask_enabled is False
5359
assert config.mask_patterns == "phone,address"
60+
assert config.transport == "streamable-http"
61+
assert config.listen_host == "127.0.0.1"
62+
assert config.listen_port == 3000
5463

5564

5665
def test_config_cli_arguments():
@@ -77,6 +86,12 @@ def test_config_cli_arguments():
7786
"false",
7887
"--mask-patterns",
7988
"custom1,custom2",
89+
"--transport",
90+
"sse",
91+
"--listen-host",
92+
"192.168.1.1",
93+
"--listen-port",
94+
"9090",
8095
]
8196

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

96114

97115
def test_config_precedence():
@@ -108,6 +126,9 @@ def test_config_precedence():
108126
"GREPTIMEDB_HTTP_PROTOCOL": "http",
109127
"GREPTIMEDB_MASK_ENABLED": "true",
110128
"GREPTIMEDB_MASK_PATTERNS": "env_pattern",
129+
"GREPTIMEDB_TRANSPORT": "stdio",
130+
"GREPTIMEDB_LISTEN_HOST": "env-listen-host",
131+
"GREPTIMEDB_LISTEN_PORT": "1111",
111132
}
112133

113134
cli_args = [
@@ -130,6 +151,12 @@ def test_config_precedence():
130151
"false",
131152
"--mask-patterns",
132153
"cli_pattern",
154+
"--transport",
155+
"streamable-http",
156+
"--listen-host",
157+
"cli-listen-host",
158+
"--listen-port",
159+
"2222",
133160
]
134161

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

0 commit comments

Comments
 (0)