phlo-mcp 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: phlo-mcp
3
+ Version: 0.2.0
4
+ Summary: MCP server for Phlo observability and lakehouse operations
5
+ Author-email: Phlo Team <team@phlo.dev>
6
+ License: MIT
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/plain
9
+ Requires-Dist: phlo>=0.1.0
10
+ Requires-Dist: phlo-api>=0.1.0
11
+ Requires-Dist: httpx>=0.28.1
12
+ Requires-Dist: mcp[cli]>=1.20.0
13
+ Requires-Dist: opentelemetry-sdk>=1.38.0
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest>=7.0; extra == "dev"
16
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
17
+
18
+ MCP server for Phlo observability and lakehouse operations.
@@ -0,0 +1,185 @@
1
+ # phlo-mcp
2
+
3
+ MCP server for Phlo observability and lakehouse operations.
4
+
5
+ ## Overview
6
+
7
+ `phlo-mcp` exposes curated read-only MCP tools over Phlo's observability and
8
+ operator surfaces. It sits on top of `phlo-api` and gives MCP clients a stable,
9
+ agent-friendly surface for lakehouse inspection.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ uv pip install -e packages/phlo-mcp
15
+ ```
16
+
17
+ ## Exposed tools
18
+
19
+ - `get_platform_health`
20
+ - `get_service_status`
21
+ - `get_recent_alerts`
22
+ - `get_dashboard_links`
23
+ - `get_logs_query_link`
24
+ - `get_metrics_query_link`
25
+ - `get_materialization_history`
26
+ - `get_run_logs`
27
+ - `get_run_trace_spans`
28
+ - `get_trace_spans`
29
+ - `render_trace_spans_tree`
30
+ - `inspect_materialization`
31
+ - `get_asset_materialization_trace`
32
+ - `render_materialization_trace_tree`
33
+ - `render_run_trace_tree`
34
+
35
+ Optional guarded operational tools:
36
+
37
+ - `materialize_asset`
38
+ - `retry_failed_run`
39
+ - `get_dagster_run_status`
40
+
41
+ Resources:
42
+
43
+ - `phlo://runtime/config`
44
+ - `phlo://runtime/services`
45
+ - `phlo://runtime/services/{service_name}`
46
+ - `phlo://runtime/plugins`
47
+ - `phlo://runtime/assets`
48
+ - `phlo://runtime/assets/{asset_key_path}`
49
+ - `phlo://runtime/schemas/{asset_key_path}`
50
+ - `phlo://runtime/contracts`
51
+ - `phlo://runtime/contracts/{table_name}`
52
+ - `phlo://runtime/dashboards`
53
+ - `phlo://docs/packages/{package_name}`
54
+
55
+ Run-level and materialization tools rely on the backing `phlo-api` having access
56
+ to Dagster asset history, Loki log queries, and ClickStack OTEL trace storage.
57
+ Trace tools can be filtered by run id, asset key, job name, service name, span
58
+ name, status code, and start/end time.
59
+ Resource URIs are read-only and deterministic so MCP clients can attach Phlo
60
+ runtime context without invoking parameterized tools.
61
+
62
+ When real spans are available, rendered trees include:
63
+ - span kind
64
+ - status code
65
+ - duration
66
+ - selected Phlo attributes like stage, asset key, job name, and operation
67
+
68
+ ## Usage
69
+
70
+ Start a backing API first:
71
+
72
+ ```bash
73
+ uv run --package phlo-api uvicorn phlo_api.main:app --host 127.0.0.1 --port 4000
74
+ ```
75
+
76
+ Run the MCP server over stdio:
77
+
78
+ ```bash
79
+ phlo-mcp --api-base-url http://127.0.0.1:4000
80
+ ```
81
+
82
+ For protected `phlo-api` instances, provide a bearer token:
83
+
84
+ ```bash
85
+ phlo-mcp \
86
+ --api-base-url http://127.0.0.1:4000 \
87
+ --api-token "$PHLO_API_TOKEN"
88
+ ```
89
+
90
+ Guarded operational tools are disabled by default. To expose asset
91
+ materialization and run retry tools, set `PHLO_MCP_ENABLE_WRITE_TOOLS=true` or
92
+ pass `--enable-write-tools` with an API token. Write tools return structured
93
+ `audit_context` metadata and default to `dry_run=true` where supported. Current
94
+ `phlo-api` operation routes support dry-run validation and run status; live
95
+ Dagster launch and retry are intentionally not implemented yet.
96
+
97
+ Claude Code example:
98
+
99
+ ```json
100
+ {
101
+ "mcpServers": {
102
+ "phlo": {
103
+ "command": "uv",
104
+ "args": ["run", "--package", "phlo-mcp", "phlo-mcp", "--api-base-url", "http://127.0.0.1:4000"]
105
+ }
106
+ }
107
+ }
108
+ ```
109
+
110
+ Example prompts once connected:
111
+
112
+ - "Get the latest materializations for `silver/orders`."
113
+ - "Fetch logs for run `abc-123`."
114
+ - "Inspect the latest materialization for `silver/orders`."
115
+ - "Get the latest materialization trace for `silver/orders`."
116
+ - "Render the materialization trace tree for `silver/orders`."
117
+ - "Get OTEL spans for run `abc-123`."
118
+ - "Get failed Dagster spans for asset `silver/orders` in the last hour."
119
+ - "Render the run trace tree for `abc-123`."
120
+ - "Render a trace tree for job `daily_orders`."
121
+
122
+ Run it over streamable HTTP:
123
+
124
+ ```bash
125
+ phlo-mcp \
126
+ --transport streamable-http \
127
+ --host 127.0.0.1 \
128
+ --port 8000 \
129
+ --path /mcp \
130
+ --api-base-url http://127.0.0.1:4000
131
+ ```
132
+
133
+ Optional local span capture:
134
+
135
+ ```bash
136
+ phlo-mcp --trace-file .phlo/phlo-mcp-trace.jsonl
137
+ ```
138
+
139
+ ## Live stack smoke
140
+
141
+ Run the MCP smoke against a live `phlo-api` service and its configured capability backends:
142
+
143
+ ```bash
144
+ uv run python packages/phlo-mcp/tests/smoke_stack.py --start-stack
145
+ ```
146
+
147
+ The smoke checks live `phlo-api`, orchestration connectivity, trace filtering
148
+ through the observability capability, MCP tool registration, MCP resource
149
+ registration, representative MCP resource reads, and a generated
150
+ `mcp_smoke_asset` capability fixture.
151
+ With `--start-stack`, the fixture is written to `.phlo/mcp-smoke-project`,
152
+ which is ignored by git.
153
+
154
+ When the configured observability backend requires credentials, pass them to
155
+ the backing `phlo-api` process with that backend's environment variables.
156
+
157
+ To verify guarded write-tool registration without calling mutation endpoints:
158
+
159
+ ```bash
160
+ uv run python packages/phlo-mcp/tests/smoke_stack.py \
161
+ --enable-write-tools \
162
+ --api-token "$PHLO_MCP_SMOKE_API_TOKEN"
163
+ ```
164
+
165
+ To call guarded write tools in dry-run mode, add `--exercise-write-tools` and
166
+ `--start-stack`, or provide `--asset-key` when testing an existing project.
167
+ This requires the backing `phlo-api` write endpoints to be available.
168
+
169
+ ```bash
170
+ uv run python packages/phlo-mcp/tests/smoke_stack.py \
171
+ --start-stack \
172
+ --enable-write-tools \
173
+ --api-token "$PHLO_MCP_SMOKE_API_TOKEN" \
174
+ --exercise-write-tools
175
+ ```
176
+
177
+ Use real data assertions when you have a known run or asset:
178
+
179
+ ```bash
180
+ uv run python packages/phlo-mcp/tests/smoke_stack.py \
181
+ --run-id abc-123 \
182
+ --require-run-spans \
183
+ --asset-key silver/orders \
184
+ --require-materialization
185
+ ```
@@ -0,0 +1,52 @@
1
+ [build-system]
2
+ build-backend = "setuptools.build_meta"
3
+ requires = [
4
+ "setuptools>=45",
5
+ "wheel",
6
+ ]
7
+
8
+ [project]
9
+ dependencies = [
10
+ "phlo>=0.1.0",
11
+ "phlo-api>=0.1.0",
12
+ "httpx>=0.28.1",
13
+ "mcp[cli]>=1.20.0",
14
+ "opentelemetry-sdk>=1.38.0",
15
+ ]
16
+ description = "MCP server for Phlo observability and lakehouse operations"
17
+ name = "phlo-mcp"
18
+ requires-python = ">=3.11"
19
+ version = "0.2.0"
20
+
21
+ [[project.authors]]
22
+ email = "team@phlo.dev"
23
+ name = "Phlo Team"
24
+
25
+ [project.license]
26
+ text = "MIT"
27
+
28
+ [project.optional-dependencies]
29
+ dev = [
30
+ "pytest>=7.0",
31
+ "ruff>=0.1.0",
32
+ ]
33
+
34
+ [project.readme]
35
+ content-type = "text/plain"
36
+ text = "MCP server for Phlo observability and lakehouse operations."
37
+
38
+ [project.scripts]
39
+ phlo-mcp = "phlo_mcp.cli:main"
40
+
41
+ [tool.ruff]
42
+ line-length = 100
43
+ target-version = "py311"
44
+
45
+ [tool.setuptools]
46
+ include-package-data = true
47
+
48
+ [tool.setuptools.package-dir]
49
+ "" = "src"
50
+
51
+ [tool.setuptools.packages.find]
52
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """Phlo MCP package."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.2.0"
@@ -0,0 +1,189 @@
1
+ """HTTP client helpers for phlo-mcp."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+ from opentelemetry import trace
9
+
10
+ from phlo_mcp.config import McpConfig
11
+
12
+
13
+ class PhloApiClient:
14
+ """Small typed wrapper around phlo-api observability routes."""
15
+
16
+ def __init__(self, config: McpConfig, *, tracer_name: str = "phlo.mcp") -> None:
17
+ self._config = config
18
+ self._tracer = trace.get_tracer(tracer_name)
19
+
20
+ @property
21
+ def api_base_url(self) -> str:
22
+ return self._config.api_base_url
23
+
24
+ @property
25
+ def headers(self) -> dict[str, str]:
26
+ if self._config.api_token:
27
+ return {"Authorization": f"Bearer {self._config.api_token}"}
28
+ return {}
29
+
30
+ def get_platform_health(self) -> dict[str, Any]:
31
+ return self._get_json("/api/observability/health")
32
+
33
+ def get_config(self) -> dict[str, Any] | list[dict[str, Any]]:
34
+ return self._get_json("/api/config")
35
+
36
+ def get_plugins(self) -> dict[str, Any] | list[dict[str, Any]]:
37
+ return self._get_json("/api/plugins")
38
+
39
+ def get_services(self) -> dict[str, Any] | list[dict[str, Any]]:
40
+ return self._get_json("/api/services")
41
+
42
+ def get_service_info(self, service_name: str) -> dict[str, Any] | list[dict[str, Any]]:
43
+ return self._get_json(f"/api/services/{service_name}")
44
+
45
+ def get_assets(self) -> dict[str, Any] | list[dict[str, Any]]:
46
+ return self._get_json("/api/dagster/assets")
47
+
48
+ def get_asset_details(self, asset_key_path: str) -> dict[str, Any] | list[dict[str, Any]]:
49
+ return self._get_json(f"/api/dagster/assets/{asset_key_path}")
50
+
51
+ def get_contracts(self) -> dict[str, Any] | list[dict[str, Any]]:
52
+ return self._get_json("/api/contracts")
53
+
54
+ def get_contract(self, table_name: str) -> dict[str, Any] | list[dict[str, Any]]:
55
+ return self._get_json(f"/api/contracts/{table_name}")
56
+
57
+ def get_service_status(self) -> list[dict[str, Any]] | dict[str, Any]:
58
+ return self._get_json("/api/observability/services")
59
+
60
+ def get_recent_alerts(self, limit: int = 5) -> list[dict[str, Any]] | dict[str, Any]:
61
+ return self._get_json("/api/observability/alerts", params={"limit": limit})
62
+
63
+ def get_dashboard_links(self) -> list[dict[str, Any]] | dict[str, Any]:
64
+ return self._get_json("/api/observability/dashboards")
65
+
66
+ def get_run_logs(
67
+ self,
68
+ run_id: str,
69
+ *,
70
+ level: str | None = None,
71
+ limit: int = 200,
72
+ ) -> dict[str, Any] | list[dict[str, Any]]:
73
+ params: dict[str, Any] = {"limit": limit}
74
+ if level:
75
+ params["level"] = level
76
+ return self._get_json(f"/api/loki/runs/{run_id}", params=params)
77
+
78
+ def get_materialization_history(
79
+ self,
80
+ asset_key_path: str,
81
+ *,
82
+ limit: int = 10,
83
+ ) -> dict[str, Any] | list[dict[str, Any]]:
84
+ return self._get_json(
85
+ f"/api/dagster/assets/{asset_key_path}/history", params={"limit": limit}
86
+ )
87
+
88
+ def get_run_trace_spans(
89
+ self,
90
+ run_id: str,
91
+ *,
92
+ limit: int = 500,
93
+ ) -> dict[str, Any] | list[dict[str, Any]]:
94
+ return self._get_json(f"/api/observability/traces/runs/{run_id}", params={"limit": limit})
95
+
96
+ def get_logs_query_link(self, service: str | None = None) -> dict[str, Any]:
97
+ params = {"service": service} if service else None
98
+ return self._get_json("/api/observability/links/logs", params=params)
99
+
100
+ def get_trace_spans(
101
+ self,
102
+ *,
103
+ run_id: str | None = None,
104
+ asset_key: str | None = None,
105
+ job_name: str | None = None,
106
+ service_name: str | None = None,
107
+ span_name: str | None = None,
108
+ status_code: str | None = None,
109
+ start_time: str | None = None,
110
+ end_time: str | None = None,
111
+ limit: int = 500,
112
+ ) -> dict[str, Any] | list[dict[str, Any]]:
113
+ params: dict[str, Any] = {"limit": limit}
114
+ for key, value in {
115
+ "run_id": run_id,
116
+ "asset_key": asset_key,
117
+ "job_name": job_name,
118
+ "service_name": service_name,
119
+ "span_name": span_name,
120
+ "status_code": status_code,
121
+ "start_time": start_time,
122
+ "end_time": end_time,
123
+ }.items():
124
+ if value:
125
+ params[key] = value
126
+ return self._get_json("/api/observability/traces", params=params)
127
+
128
+ def get_metrics_query_link(self, metric: str | None = None) -> dict[str, Any]:
129
+ params = {"metric": metric} if metric else None
130
+ return self._get_json("/api/observability/links/metrics", params=params)
131
+
132
+ def materialize_asset(
133
+ self,
134
+ asset_key_path: str,
135
+ *,
136
+ dry_run: bool = True,
137
+ partition_key: str | None = None,
138
+ ) -> dict[str, Any] | list[dict[str, Any]]:
139
+ payload: dict[str, Any] = {"dry_run": dry_run}
140
+ if partition_key:
141
+ payload["partition_key"] = partition_key
142
+ return self._post_json(f"/api/dagster/assets/{asset_key_path}/materialize", json=payload)
143
+
144
+ def retry_run(
145
+ self,
146
+ run_id: str,
147
+ *,
148
+ dry_run: bool = True,
149
+ ) -> dict[str, Any] | list[dict[str, Any]]:
150
+ return self._post_json(f"/api/dagster/runs/{run_id}/retry", json={"dry_run": dry_run})
151
+
152
+ def get_run_status(self, run_id: str) -> dict[str, Any] | list[dict[str, Any]]:
153
+ return self._get_json(f"/api/dagster/runs/{run_id}/status")
154
+
155
+ def _get_json(
156
+ self,
157
+ path: str,
158
+ *,
159
+ params: dict[str, Any] | None = None,
160
+ ) -> dict[str, Any] | list[dict[str, Any]]:
161
+ url = f"{self.api_base_url}{path}"
162
+ with self._tracer.start_as_current_span(
163
+ "http.client GET",
164
+ attributes={
165
+ "http.request.method": "GET",
166
+ "url.full": url,
167
+ },
168
+ ):
169
+ response = httpx.get(url, params=params, headers=self.headers, timeout=10.0)
170
+ response.raise_for_status()
171
+ return response.json()
172
+
173
+ def _post_json(
174
+ self,
175
+ path: str,
176
+ *,
177
+ json: dict[str, Any],
178
+ ) -> dict[str, Any] | list[dict[str, Any]]:
179
+ url = f"{self.api_base_url}{path}"
180
+ with self._tracer.start_as_current_span(
181
+ "http.client POST",
182
+ attributes={
183
+ "http.request.method": "POST",
184
+ "url.full": url,
185
+ },
186
+ ):
187
+ response = httpx.post(url, json=json, headers=self.headers, timeout=30.0)
188
+ response.raise_for_status()
189
+ return response.json()
@@ -0,0 +1,55 @@
1
+ """CLI entrypoint for phlo-mcp."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from phlo_mcp.config import McpConfig, config_from_env
8
+ from phlo_mcp.server import create_server
9
+
10
+
11
+ def build_parser() -> argparse.ArgumentParser:
12
+ parser = argparse.ArgumentParser(description="Run the Phlo MCP server")
13
+ parser.add_argument(
14
+ "--transport",
15
+ choices=("stdio", "streamable-http"),
16
+ help="MCP transport to use (defaults to PHLO_MCP_TRANSPORT or stdio)",
17
+ )
18
+ parser.add_argument("--api-base-url", help="Base URL for the backing phlo-api instance")
19
+ parser.add_argument("--api-token", help="Bearer token for authenticated phlo-api requests")
20
+ parser.add_argument(
21
+ "--enable-write-tools",
22
+ action="store_true",
23
+ help="Register guarded operational tools (requires authenticated phlo-api)",
24
+ )
25
+ parser.add_argument("--trace-file", help="Optional JSONL file to write local span events")
26
+ parser.add_argument("--host", help="Bind host for streamable-http transport")
27
+ parser.add_argument("--port", type=int, help="Bind port for streamable-http transport")
28
+ parser.add_argument("--path", help="HTTP path for streamable-http transport (default: /mcp)")
29
+ return parser
30
+
31
+
32
+ def parse_args() -> McpConfig:
33
+ parser = build_parser()
34
+ args = parser.parse_args()
35
+ env_config = config_from_env()
36
+ return McpConfig(
37
+ api_base_url=(args.api_base_url or env_config.api_base_url).rstrip("/"),
38
+ api_token=args.api_token if args.api_token is not None else env_config.api_token,
39
+ enable_write_tools=args.enable_write_tools or env_config.enable_write_tools,
40
+ trace_file=args.trace_file if args.trace_file is not None else env_config.trace_file,
41
+ transport=args.transport or env_config.transport,
42
+ host=args.host or env_config.host,
43
+ port=args.port if args.port is not None else env_config.port,
44
+ streamable_http_path=args.path or env_config.streamable_http_path,
45
+ )
46
+
47
+
48
+ def main() -> None:
49
+ config = parse_args()
50
+ server = create_server(config)
51
+ server.run(transport=config.transport)
52
+
53
+
54
+ if __name__ == "__main__":
55
+ main()
@@ -0,0 +1,41 @@
1
+ """Configuration helpers for phlo-mcp."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+
8
+ _DEFAULT_API_BASE_URL = "http://127.0.0.1:4000"
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class McpConfig:
13
+ """Runtime configuration for the Phlo MCP server."""
14
+
15
+ api_base_url: str = _DEFAULT_API_BASE_URL
16
+ api_token: str | None = None
17
+ enable_write_tools: bool = False
18
+ trace_file: str | None = None
19
+ transport: str = "stdio"
20
+ host: str = "127.0.0.1"
21
+ port: int = 8000
22
+ streamable_http_path: str = "/mcp"
23
+
24
+
25
+ def _truthy_env(name: str) -> bool:
26
+ return os.environ.get(name, "").lower() in {"1", "true", "yes", "on"}
27
+
28
+
29
+ def config_from_env() -> McpConfig:
30
+ """Load MCP configuration from environment variables."""
31
+ port = int(os.environ.get("PHLO_MCP_PORT", "8000"))
32
+ return McpConfig(
33
+ api_base_url=os.environ.get("PHLO_MCP_API_BASE_URL", _DEFAULT_API_BASE_URL).rstrip("/"),
34
+ api_token=os.environ.get("PHLO_MCP_API_TOKEN") or None,
35
+ enable_write_tools=_truthy_env("PHLO_MCP_ENABLE_WRITE_TOOLS"),
36
+ trace_file=os.environ.get("PHLO_MCP_TRACE_FILE") or None,
37
+ transport=os.environ.get("PHLO_MCP_TRANSPORT", "stdio"),
38
+ host=os.environ.get("PHLO_MCP_HOST", "127.0.0.1"),
39
+ port=port,
40
+ streamable_http_path=os.environ.get("PHLO_MCP_HTTP_PATH", "/mcp"),
41
+ )