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
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,14 @@ For Claude Desktop, add this to your config (`~/Library/Application Support/Clau
| `dryrun_pipeline` | Test a pipeline with sample data without writing to database |
| `delete_pipeline` | Delete a specific version of a pipeline |

### Dashboard Management

| Tool | Description |
|------|-------------|
| `list_dashboards` | List all Perses dashboard definitions |
| `create_dashboard` | Create or update a Perses dashboard definition |
| `delete_dashboard` | Delete a dashboard definition |

### Resources & Prompts

- **Resources**: Browse tables via `greptime://<table>/data` URIs
Expand All @@ -76,7 +84,7 @@ GREPTIMEDB_DATABASE=public # Database name
GREPTIMEDB_TIMEZONE=UTC # Session timezone

# Optional
GREPTIMEDB_HTTP_PORT=4000 # HTTP API port for pipeline management
GREPTIMEDB_HTTP_PORT=4000 # HTTP API port for pipeline/dashboard management
GREPTIMEDB_HTTP_PROTOCOL=http # HTTP protocol (http/https)
GREPTIMEDB_POOL_SIZE=5 # Connection pool size
GREPTIMEDB_MASK_ENABLED=true # Enable sensitive data masking
Expand Down
14 changes: 10 additions & 4 deletions docs/llm-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ You have access to a GreptimeDB MCP server for querying and managing time-series
- `dryrun_pipeline`: Test pipeline with sample data without writing
- `delete_pipeline`: Remove a pipeline version

**Note**: All HTTP API calls (pipeline tools) require authentication. The MCP server handles auth automatically using configured credentials. When providing curl examples to users, always include `-u <username>:<password>`.
### Dashboard Management
- `list_dashboards`: View all Perses dashboard definitions
- `create_dashboard`: Create/update a Perses dashboard with JSON definition
- `delete_dashboard`: Remove a dashboard definition

**Note**: All HTTP API calls (pipeline and dashboard tools) require authentication. The MCP server handles auth automatically using configured credentials. When providing curl examples to users, always include `-u <username>:<password>`.

## Available Prompts
Use these prompts for specialized tasks:
Expand All @@ -35,9 +40,10 @@ Use these prompts for specialized tasks:

## Workflow Tips
1. For log pipeline creation: Get log sample → use `pipeline_creator` prompt → generate YAML → `create_pipeline` → `dryrun_pipeline` to verify
2. For data analysis: `describe_table` first → understand schema → `execute_sql` or `execute_tql`
3. For time-series: Prefer `query_range` for aggregations, `execute_tql` for PromQL patterns
4. Always check `health_check` if queries fail unexpectedly
2. For dashboard creation: Prepare Perses JSON definition → `create_dashboard` → verify with `list_dashboards`
3. For data analysis: `describe_table` first → understand schema → `execute_sql` or `execute_tql`
4. For time-series: Prefer `query_range` for aggregations, `execute_tql` for PromQL patterns
5. Always check `health_check` if queries fail unexpectedly
```

## Using Prompts in Claude Desktop
Expand Down
6 changes: 3 additions & 3 deletions server.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.GreptimeTeam/greptimedb-mcp-server",
"description": "Query and analyze GreptimeDB metrics, logs and traces via SQL, TQL and RANGE queries.",
"description": "Query and analyze GreptimeDB metrics, logs and traces via SQL, TQL and RANGE queries. Manage pipelines and Perses dashboards.",
"repository": {
"url": "https://github.com/GreptimeTeam/greptimedb-mcp-server",
"source": "github"
Expand Down Expand Up @@ -97,12 +97,12 @@
},
{
"name": "GREPTIMEDB_HTTP_PORT",
"description": "HTTP API port for pipeline management (default: 4000)",
"description": "HTTP API port for pipeline/dashboard management (default: 4000)",
"isRequired": false
},
{
"name": "GREPTIMEDB_HTTP_PROTOCOL",
"description": "HTTP protocol for pipeline API: http or https (default: http)",
"description": "HTTP protocol for pipeline/dashboard API: http or https (default: http)",
"isRequired": false
},
{
Expand Down
109 changes: 109 additions & 0 deletions src/greptimedb_mcp_server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,115 @@ async def delete_pipeline(
return f"Error deleting pipeline: {str(e)}"


DASHBOARD_NAME_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_\-]*$")


def _validate_dashboard_name(name: str) -> str:
"""Validate dashboard name format."""
if not name:
raise ValueError("Dashboard name is required")
if not DASHBOARD_NAME_PATTERN.match(name):
raise ValueError(
"Invalid dashboard name: must start with letter or underscore, "
"contain only alphanumeric characters, underscores, and hyphens"
)
return name


@mcp.tool()
async def list_dashboards() -> str:
"""List all Perses dashboard definitions stored in GreptimeDB."""
state = get_state()
url = f"{state.http_base_url}/v1/dashboards"
auth = state.get_http_auth()

try:
async with state.http_session.get(url, auth=auth) as response:
response_text = await response.text()

if response.status == 200:
try:
result = json.loads(response_text)
return json.dumps(result, indent=2, ensure_ascii=False)
except json.JSONDecodeError:
return response_text
else:
error_detail = response_text if response_text else "No details"
return (
f"Error listing dashboards (HTTP {response.status}): {error_detail}"
)

except aiohttp.ClientError as e:
logger.error(f"HTTP error listing dashboards: {e}")
return f"Error listing dashboards: {str(e)}"


@mcp.tool()
async def create_dashboard(
name: Annotated[str, "Name of the dashboard"],
definition: Annotated[str, "Perses dashboard definition in JSON format"],
) -> str:
"""Create or update a Perses dashboard definition in GreptimeDB."""
state = get_state()
name = _validate_dashboard_name(name)

url = f"{state.http_base_url}/v1/dashboards/{quote(name)}"
auth = state.get_http_auth()

try:
json_definition = json.loads(definition)
except json.JSONDecodeError as e:
return f"Error: Invalid JSON definition: {str(e)}"

try:
async with state.http_session.post(
url,
json=json_definition,
auth=auth,
) as response:
response_text = await response.text()

if response.status == 200:
return f"Dashboard '{name}' saved successfully."
else:
error_detail = response_text if response_text else "No details"
return (
f"Error creating dashboard (HTTP {response.status}): {error_detail}"
)

except aiohttp.ClientError as e:
logger.error(f"HTTP error creating dashboard '{name}': {e}")
return f"Error creating dashboard: {str(e)}"


@mcp.tool()
async def delete_dashboard(
name: Annotated[str, "Name of the dashboard to delete"],
) -> str:
"""Delete a Perses dashboard definition from GreptimeDB."""
state = get_state()
name = _validate_dashboard_name(name)

url = f"{state.http_base_url}/v1/dashboards/{quote(name)}"
auth = state.get_http_auth()

try:
async with state.http_session.delete(url, auth=auth) as response:
response_text = await response.text()

if response.status == 200:
return f"Dashboard '{name}' deleted successfully."
else:
error_detail = response_text if response_text else "No details"
return (
f"Error deleting dashboard (HTTP {response.status}): {error_detail}"
)

except aiohttp.ClientError as e:
logger.error(f"HTTP error deleting dashboard '{name}': {e}")
return f"Error deleting dashboard: {str(e)}"


def _register_prompts():
"""Register prompts from templates."""
templates = templates_loader()
Expand Down
60 changes: 60 additions & 0 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
dryrun_pipeline,
delete_pipeline,
_validate_pipeline_name,
_validate_dashboard_name,
list_dashboards,
create_dashboard,
delete_dashboard,
)
from greptimedb_mcp_server.utils import templates_loader

Expand Down Expand Up @@ -67,6 +71,7 @@ def setup_state():
http_base_url=f"http://{config.host}:{config.http_port}",
mask_enabled=config.mask_enabled,
mask_patterns=[],
http_session=None,
)

yield
Expand Down Expand Up @@ -635,3 +640,58 @@ async def test_delete_pipeline_missing_version():
"""Test delete_pipeline with missing version"""
result = await delete_pipeline(name="test_pipeline", version="")
assert "Error: version is required" in result


# Dashboard tools tests


def test_validate_dashboard_name_valid():
"""Test valid dashboard names"""
assert _validate_dashboard_name("test_dashboard") == "test_dashboard"
assert _validate_dashboard_name("Dashboard1") == "Dashboard1"
assert _validate_dashboard_name("_private") == "_private"
assert _validate_dashboard_name("a") == "a"
assert _validate_dashboard_name("test-dashboard") == "test-dashboard"
assert _validate_dashboard_name("dash_board-123") == "dash_board-123"


def test_validate_dashboard_name_invalid():
"""Test invalid dashboard names"""
with pytest.raises(ValueError) as excinfo:
_validate_dashboard_name("")
assert "Dashboard name is required" in str(excinfo.value)

with pytest.raises(ValueError) as excinfo:
_validate_dashboard_name("123invalid")
assert "Invalid dashboard name" in str(excinfo.value)

with pytest.raises(ValueError) as excinfo:
_validate_dashboard_name("test.dashboard")
assert "Invalid dashboard name" in str(excinfo.value)

with pytest.raises(ValueError) as excinfo:
_validate_dashboard_name("test dashboard")
assert "Invalid dashboard name" in str(excinfo.value)


@pytest.mark.asyncio
async def test_create_dashboard_invalid_name():
"""Test create_dashboard with invalid name"""
with pytest.raises(ValueError) as excinfo:
await create_dashboard(name="123.invalid", definition='{"kind": "Dashboard"}')
assert "Invalid dashboard name" in str(excinfo.value)


@pytest.mark.asyncio
async def test_create_dashboard_invalid_json():
"""Test create_dashboard with invalid JSON"""
result = await create_dashboard(name="test_dashboard", definition="not valid json")
assert "Error: Invalid JSON definition" in result


@pytest.mark.asyncio
async def test_delete_dashboard_invalid_name():
"""Test delete_dashboard with invalid name"""
with pytest.raises(ValueError) as excinfo:
await delete_dashboard(name="123.invalid")
assert "Invalid dashboard name" in str(excinfo.value)
Loading