Skip to content

Commit c0430de

Browse files
authored
feat: adds allow write mode for dev (#41)
* feat: adds allow write mode for dev Signed-off-by: Dennis Zhuang <killme2008@gmail.com> * fix: redact database password in startup config log Address PR #41 review: db_config contained the plaintext password and was logged at INFO level, leaking credentials to anyone with log access. Mask the password field before logging. * chore: bump version to 0.5.0 --------- Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
1 parent f8e4dbd commit c0430de

7 files changed

Lines changed: 145 additions & 10 deletions

File tree

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ GREPTIMEDB_POOL_SIZE=5 # Connection pool size
9090
GREPTIMEDB_MASK_ENABLED=true # Enable sensitive data masking
9191
GREPTIMEDB_MASK_PATTERNS= # Additional patterns (comma-separated)
9292
GREPTIMEDB_AUDIT_ENABLED=true # Enable audit logging
93+
GREPTIMEDB_ALLOW_WRITE=false # Allow write/DDL via execute_sql (DANGEROUS, local/test only)
9394

9495
# Transport (for HTTP server mode)
9596
GREPTIMEDB_TRANSPORT=stdio # stdio, sse, or streamable-http
@@ -111,6 +112,7 @@ greptimedb-mcp-server \
111112
--timezone UTC \
112113
--pool-size 5 \
113114
--mask-enabled true \
115+
--allow-write false \
114116
--transport stdio
115117
```
116118

@@ -165,6 +167,27 @@ All queries go through a security gate that:
165167
- **Blocks**: Encoded bypass attempts (hex, UNHEX, CHAR)
166168
- **Allows**: SELECT, SHOW, DESCRIBE, TQL, EXPLAIN, UNION
167169

170+
### Write Mode (Disabled by Default)
171+
172+
The server is **read-only by default**. For local development or testing, you can
173+
allow write/destructive SQL (DDL/DML such as `CREATE`, `DROP`, `ALTER`, `INSERT`,
174+
`UPDATE`, `DELETE`) through the `execute_sql` tool by enabling write mode:
175+
176+
```bash
177+
# Environment variable
178+
GREPTIMEDB_ALLOW_WRITE=true greptimedb-mcp-server
179+
180+
# Or CLI argument
181+
greptimedb-mcp-server --allow-write true
182+
```
183+
184+
When enabled, the security gate is **bypassed** for `execute_sql`, and the server
185+
logs a warning on startup.
186+
187+
> ⚠️ **Danger**: This lets an AI assistant run destructive statements against your
188+
> database. Never enable it against production data. Combine with a read-only
189+
> database user if you only need read access.
190+
168191
### Data Masking
169192

170193
Sensitive columns are automatically masked (`******`) based on column name patterns:

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "greptimedb-mcp-server"
7-
version = "0.4.9"
7+
version = "0.5.0"
88
description = "MCP server for GreptimeDB with SQL/TQL/PromQL support, sensitive data masking, and prompt templates for observability data analysis."
99
readme = "README.md"
1010
license = {text = "MIT"}

server.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@
66
"url": "https://github.com/GreptimeTeam/greptimedb-mcp-server",
77
"source": "github"
88
},
9-
"version": "0.4.9",
9+
"version": "0.5.0",
1010
"packages": [
1111
{
1212
"registryType": "pypi",
1313
"identifier": "greptimedb-mcp-server",
14-
"version": "0.4.9",
14+
"version": "0.5.0",
1515
"transport": {
1616
"type": "stdio"
1717
},
@@ -119,6 +119,11 @@
119119
"name": "GREPTIMEDB_AUDIT_ENABLED",
120120
"description": "Enable audit logging for tool invocations (default: true)",
121121
"isRequired": false
122+
},
123+
{
124+
"name": "GREPTIMEDB_ALLOW_WRITE",
125+
"description": "Allow write/destructive SQL (DDL/DML) via execute_sql by skipping the security gate. DANGEROUS: only use on local or test instances (default: false)",
126+
"isRequired": false
122127
}
123128
]
124129
}

src/greptimedb_mcp_server/config.py

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,13 @@ class Config:
8484
Enable audit logging for all tool calls
8585
"""
8686

87+
allow_write: bool
88+
"""
89+
Allow write/destructive SQL (DDL/DML such as CREATE, DROP, ALTER, INSERT,
90+
UPDATE, DELETE) through the `execute_sql` tool by skipping the security
91+
gate. Disabled by default; intended only for local development or testing.
92+
"""
93+
8794
allowed_hosts: list[str]
8895
"""
8996
Allowed hosts for DNS rebinding protection (for sse/streamable-http).
@@ -169,7 +176,7 @@ def from_env_arguments() -> "Config":
169176

170177
parser.add_argument(
171178
"--mask-enabled",
172-
type=lambda x: x.lower() not in ("false", "0", "no"),
179+
type=_parse_bool,
173180
help="Enable data masking for sensitive columns (default: true)",
174181
default=os.getenv("GREPTIMEDB_MASK_ENABLED", "true"),
175182
)
@@ -205,11 +212,22 @@ def from_env_arguments() -> "Config":
205212

206213
parser.add_argument(
207214
"--audit-enabled",
208-
type=lambda x: x.lower() not in ("false", "0", "no"),
215+
type=_parse_bool,
209216
help="Enable audit logging for all tool calls (default: true)",
210217
default=os.getenv("GREPTIMEDB_AUDIT_ENABLED", "true"),
211218
)
212219

220+
parser.add_argument(
221+
"--allow-write",
222+
type=_parse_bool,
223+
help=(
224+
"Allow write/destructive SQL (DDL/DML) via execute_sql by "
225+
"skipping the security gate. DANGEROUS: only use on local or "
226+
"test instances (default: false)"
227+
),
228+
default=os.getenv("GREPTIMEDB_ALLOW_WRITE", "false"),
229+
)
230+
213231
parser.add_argument(
214232
"--allowed-hosts",
215233
type=str,
@@ -250,6 +268,7 @@ def from_env_arguments() -> "Config":
250268
listen_host=args.listen_host,
251269
listen_port=args.listen_port,
252270
audit_enabled=args.audit_enabled,
271+
allow_write=args.allow_write,
253272
allowed_hosts=_parse_comma_separated(args.allowed_hosts),
254273
allowed_origins=_parse_comma_separated(args.allowed_origins),
255274
)
@@ -261,3 +280,15 @@ def _parse_comma_separated(value: str) -> list[str]:
261280
if not value:
262281
return []
263282
return [item.strip() for item in value.split(",") if item.strip()]
283+
284+
285+
def _parse_bool(value: str) -> bool:
286+
"""Parse a strict boolean value for environment and CLI configuration."""
287+
normalized = value.strip().lower()
288+
if normalized in {"true", "1", "yes", "on"}:
289+
return True
290+
if normalized in {"false", "0", "no", "off"}:
291+
return False
292+
raise argparse.ArgumentTypeError(
293+
"expected a boolean value: true/false, 1/0, yes/no, or on/off"
294+
)

src/greptimedb_mcp_server/server.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ class AppState:
6363
http_base_url: str
6464
mask_enabled: bool = True
6565
mask_patterns: list[str] = field(default_factory=list)
66+
allow_write: bool = False
6667
pool: MySQLConnectionPool | None = field(default=None)
6768
http_session: aiohttp.ClientSession | None = field(default=None)
6869

@@ -430,11 +431,18 @@ async def lifespan(mcp: FastMCP):
430431
http_base_url=http_base_url,
431432
mask_enabled=config.mask_enabled,
432433
mask_patterns=mask_patterns,
434+
allow_write=config.allow_write,
433435
http_session=aiohttp.ClientSession(),
434436
)
435437

436-
logger.info(f"GreptimeDB Config: {db_config}")
438+
safe_db_config = {**db_config, "password": "***" if config.password else ""}
439+
logger.info(f"GreptimeDB Config: {safe_db_config}")
437440
logger.info(f"Data masking: {'enabled' if config.mask_enabled else 'disabled'}")
441+
if config.allow_write:
442+
logger.warning(
443+
"Write mode ENABLED: execute_sql allows destructive SQL (DDL/DML). "
444+
"Do NOT use against production data."
445+
)
438446
logger.info("Starting GreptimeDB MCP server...")
439447

440448
try:
@@ -549,13 +557,19 @@ async def execute_sql(
549557
] = "csv",
550558
limit: Annotated[int, "Maximum number of rows to return (default: 1000)"] = 1000,
551559
) -> str:
552-
"""Execute SQL query against GreptimeDB. Please use MySQL dialect."""
560+
"""Execute SQL query against GreptimeDB. Please use MySQL dialect.
561+
562+
Read-only by default. When the server runs with write mode enabled
563+
(--allow-write / GREPTIMEDB_ALLOW_WRITE), destructive SQL (DDL/DML) is
564+
also permitted.
565+
"""
553566
state = get_state()
554567
limit = _validate_sql_params(query, format, limit)
555568

556-
is_dangerous, reason = security_gate(query=query)
557-
if is_dangerous:
558-
return f"Error: Dangerous operation blocked: {reason}"
569+
if not state.allow_write:
570+
is_dangerous, reason = security_gate(query=query)
571+
if is_dangerous:
572+
return f"Error: Dangerous operation blocked: {reason}"
559573

560574
start_time = time.time()
561575

tests/test_config.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import os
22
from unittest.mock import patch
3+
import pytest
4+
35
from greptimedb_mcp_server.config import Config, _parse_comma_separated
46

57

@@ -23,6 +25,7 @@ def test_config_default_values():
2325
assert config.transport == "stdio"
2426
assert config.listen_host == "0.0.0.0"
2527
assert config.listen_port == 8080
28+
assert config.allow_write is False
2629
assert config.allowed_hosts == []
2730
assert config.allowed_origins == []
2831

@@ -44,6 +47,7 @@ def test_config_env_variables():
4447
"GREPTIMEDB_TRANSPORT": "streamable-http",
4548
"GREPTIMEDB_LISTEN_HOST": "127.0.0.1",
4649
"GREPTIMEDB_LISTEN_PORT": "3000",
50+
"GREPTIMEDB_ALLOW_WRITE": "true",
4751
"GREPTIMEDB_ALLOWED_HOSTS": "localhost:*,127.0.0.1:*",
4852
"GREPTIMEDB_ALLOWED_ORIGINS": "http://localhost:*,https://example.com",
4953
}
@@ -64,6 +68,7 @@ def test_config_env_variables():
6468
assert config.transport == "streamable-http"
6569
assert config.listen_host == "127.0.0.1"
6670
assert config.listen_port == 3000
71+
assert config.allow_write is True
6772
assert config.allowed_hosts == ["localhost:*", "127.0.0.1:*"]
6873
assert config.allowed_origins == [
6974
"http://localhost:*",
@@ -101,6 +106,8 @@ def test_config_cli_arguments():
101106
"192.168.1.1",
102107
"--listen-port",
103108
"9090",
109+
"--allow-write",
110+
"true",
104111
"--allowed-hosts",
105112
"my-service.namespace:*",
106113
"--allowed-origins",
@@ -123,6 +130,7 @@ def test_config_cli_arguments():
123130
assert config.transport == "sse"
124131
assert config.listen_host == "192.168.1.1"
125132
assert config.listen_port == 9090
133+
assert config.allow_write is True
126134
assert config.allowed_hosts == ["my-service.namespace:*"]
127135
assert config.allowed_origins == ["http://my-app.example.com"]
128136

@@ -144,6 +152,7 @@ def test_config_precedence():
144152
"GREPTIMEDB_TRANSPORT": "stdio",
145153
"GREPTIMEDB_LISTEN_HOST": "env-listen-host",
146154
"GREPTIMEDB_LISTEN_PORT": "1111",
155+
"GREPTIMEDB_ALLOW_WRITE": "false",
147156
}
148157

149158
cli_args = [
@@ -172,6 +181,8 @@ def test_config_precedence():
172181
"cli-listen-host",
173182
"--listen-port",
174183
"2222",
184+
"--allow-write",
185+
"true",
175186
]
176187

177188
with patch.dict(os.environ, env_vars):
@@ -190,6 +201,42 @@ def test_config_precedence():
190201
assert config.transport == "streamable-http"
191202
assert config.listen_host == "cli-listen-host"
192203
assert config.listen_port == 2222
204+
assert config.allow_write is True
205+
206+
207+
@pytest.mark.parametrize(
208+
("env_value", "expected"),
209+
[
210+
("true", True),
211+
("1", True),
212+
("yes", True),
213+
("on", True),
214+
("false", False),
215+
("0", False),
216+
("no", False),
217+
("off", False),
218+
],
219+
)
220+
def test_config_allow_write_boolean_values(env_value, expected):
221+
with patch.dict(os.environ, {"GREPTIMEDB_ALLOW_WRITE": env_value}, clear=True):
222+
with patch("sys.argv", ["script_name"]):
223+
config = Config.from_env_arguments()
224+
225+
assert config.allow_write is expected
226+
227+
228+
def test_config_allow_write_rejects_invalid_env_value():
229+
with patch.dict(os.environ, {"GREPTIMEDB_ALLOW_WRITE": "maybe"}, clear=True):
230+
with patch("sys.argv", ["script_name"]):
231+
with pytest.raises(SystemExit):
232+
Config.from_env_arguments()
233+
234+
235+
def test_config_allow_write_rejects_invalid_cli_value():
236+
with patch.dict(os.environ, {}, clear=True):
237+
with patch("sys.argv", ["script_name", "--allow-write", "maybe"]):
238+
with pytest.raises(SystemExit):
239+
Config.from_env_arguments()
193240

194241

195242
class TestParseCommaSeparated:

tests/test_server.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ def setup_state():
4444
listen_host="0.0.0.0",
4545
listen_port=8080,
4646
audit_enabled=False,
47+
allow_write=False,
4748
allowed_hosts=[],
4849
allowed_origins=[],
4950
)
@@ -71,6 +72,7 @@ def setup_state():
7172
http_base_url=f"http://{config.host}:{config.http_port}",
7273
mask_enabled=config.mask_enabled,
7374
mask_patterns=[],
75+
allow_write=config.allow_write,
7476
http_session=None,
7577
)
7678

@@ -98,6 +100,19 @@ async def test_execute_sql_dangerous_blocked():
98100
assert "Forbidden `DROP` operation" in result
99101

100102

103+
@pytest.mark.asyncio
104+
async def test_execute_sql_write_allowed_when_enabled():
105+
"""Write mode lets destructive SQL bypass the security gate."""
106+
server._state.allow_write = True
107+
try:
108+
result = await execute_sql(query="DROP TABLE users")
109+
finally:
110+
server._state.allow_write = False
111+
112+
assert "Dangerous operation blocked" not in result
113+
assert "executed successfully" in result
114+
115+
101116
@pytest.mark.asyncio
102117
async def test_execute_sql_show_tables():
103118
"""Test SHOW TABLES query execution"""

0 commit comments

Comments
 (0)