oddyssey-mcp 0.1.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,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: oddyssey-mcp
3
+ Version: 0.1.0
4
+ Summary: Oddyssey MCP server: pilots the local observability stack for CLI coding agents
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: mcp==2.0.0
8
+ Requires-Dist: httpx==0.28.1
@@ -0,0 +1 @@
1
+ """oddyssey MCP server package."""
@@ -0,0 +1,45 @@
1
+ """The oddyssey MCP server: pilots the local observability stack over stdio."""
2
+
3
+ from __future__ import annotations
4
+
5
+ # mcp 2.0 renamed the high-level server: `mcp.server.fastmcp.FastMCP` is gone,
6
+ # the equivalent is `mcp.server.MCPServer` (canonically
7
+ # `mcp.server.mcpserver.MCPServer`). The `@server.tool()` decorator and the
8
+ # synchronous `run()` entrypoint (transport defaults to "stdio") are unchanged.
9
+ from mcp.server import MCPServer
10
+
11
+ from . import stack as stack_ops
12
+
13
+ mcp = MCPServer("oddyssey")
14
+
15
+
16
+ @mcp.tool()
17
+ def odd_stack_up() -> dict:
18
+ """Start the local LGTM observability stack (Grafana, Tempo, Prometheus, Loki, OTLP)."""
19
+ return stack_ops.stack_up()
20
+
21
+
22
+ @mcp.tool()
23
+ def odd_stack_down() -> dict:
24
+ """Stop and remove the local LGTM stack; stored telemetry does not survive."""
25
+ return stack_ops.stack_down()
26
+
27
+
28
+ @mcp.tool()
29
+ def odd_stack_status() -> dict:
30
+ """Check whether the local LGTM stack is up (Prometheus and Tempo ready)."""
31
+ return stack_ops.stack_status()
32
+
33
+
34
+ @mcp.tool()
35
+ def odd_stack_reset() -> dict:
36
+ """Wipe all stored telemetry (traces, metrics, logs, profiles) and return a fresh, ready stack."""
37
+ return stack_ops.stack_reset()
38
+
39
+
40
+ def main() -> None:
41
+ mcp.run()
42
+
43
+
44
+ if __name__ == "__main__":
45
+ main()
@@ -0,0 +1,111 @@
1
+ """LGTM stack lifecycle: the server drives Docker directly, no compose file.
2
+
3
+ The container definition is embedded here (image pin, name, ports), so the
4
+ server is standalone: Docker is the only prerequisite.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import subprocess
10
+ import time
11
+
12
+ import httpx
13
+
14
+ IMAGE = "grafana/otel-lgtm:0.30.2"
15
+ CONTAINER_NAME = "oddyssey-lgtm"
16
+ PORTS = ("3000:3000", "4317:4317", "4318:4318")
17
+
18
+ # Readiness is probed through the Grafana datasource proxy: one request
19
+ # checks both Grafana and the backend behind it, and only Grafana's port
20
+ # needs to be exposed.
21
+ PROMETHEUS_READY = "http://localhost:3000/api/datasources/proxy/uid/prometheus/-/ready"
22
+ TEMPO_READY = "http://localhost:3000/api/datasources/proxy/uid/tempo/ready"
23
+ GRAFANA_URL = "http://localhost:3000"
24
+ OTLP_ENDPOINT = "http://localhost:4317"
25
+ STARTUP_TIMEOUT_S = 120
26
+ POLL_INTERVAL_S = 2
27
+
28
+
29
+ def run_args() -> list[str]:
30
+ """The docker run command that creates the stack container."""
31
+ port_flags = [flag for mapping in PORTS for flag in ("-p", mapping)]
32
+ return ["docker", "run", "-d", "--name", CONTAINER_NAME, *port_flags, IMAGE]
33
+
34
+
35
+ def _docker(*args: str) -> subprocess.CompletedProcess:
36
+ return subprocess.run(
37
+ ["docker", *args],
38
+ capture_output=True,
39
+ text=True,
40
+ check=False,
41
+ )
42
+
43
+
44
+ def _container_state() -> str:
45
+ """One of "running", "stopped", or "absent"."""
46
+ result = _docker("inspect", "--format", "{{.State.Running}}", CONTAINER_NAME)
47
+ if result.returncode != 0:
48
+ return "absent"
49
+ return "running" if result.stdout.strip() == "true" else "stopped"
50
+
51
+
52
+ def _probe(client: httpx.Client, url: str) -> bool:
53
+ try:
54
+ return client.get(url).status_code == 200
55
+ except httpx.TransportError:
56
+ return False
57
+
58
+
59
+ def stack_status(transport: httpx.BaseTransport | None = None) -> dict:
60
+ """Probe readiness endpoints; a down stack is a status, not an error."""
61
+ with httpx.Client(timeout=3.0, transport=transport) as client:
62
+ prometheus = _probe(client, PROMETHEUS_READY)
63
+ tempo = _probe(client, TEMPO_READY)
64
+ return {"running": prometheus and tempo, "prometheus": prometheus, "tempo": tempo}
65
+
66
+
67
+ def stack_up() -> dict:
68
+ """Start the stack container (idempotent) and wait until it is ready."""
69
+ state = _container_state()
70
+ if state == "stopped":
71
+ result = _docker("start", CONTAINER_NAME)
72
+ if result.returncode != 0:
73
+ raise RuntimeError(f"docker start failed: {result.stderr.strip()}")
74
+ elif state == "absent":
75
+ result = subprocess.run(run_args(), capture_output=True, text=True, check=False)
76
+ if result.returncode != 0:
77
+ raise RuntimeError(f"docker run failed: {result.stderr.strip()}")
78
+ status = stack_status()
79
+ deadline = time.monotonic() + STARTUP_TIMEOUT_S
80
+ while time.monotonic() < deadline:
81
+ if status["running"]:
82
+ return {
83
+ "running": True,
84
+ "grafana_url": GRAFANA_URL,
85
+ "otlp_endpoint": OTLP_ENDPOINT,
86
+ }
87
+ time.sleep(POLL_INTERVAL_S)
88
+ status = stack_status()
89
+ raise RuntimeError(
90
+ f"stack did not become ready within {STARTUP_TIMEOUT_S}s: {status}"
91
+ )
92
+
93
+
94
+ def stack_down() -> dict:
95
+ """Destroy the stack container (and its data); absent is already down."""
96
+ result = _docker("rm", "--force", "--volumes", CONTAINER_NAME)
97
+ if result.returncode != 0 and "No such container" not in result.stderr:
98
+ raise RuntimeError(f"docker rm failed: {result.stderr.strip()}")
99
+ return {"running": False}
100
+
101
+
102
+ def stack_reset() -> dict:
103
+ """Wipe all stored telemetry and return a fresh, ready stack.
104
+
105
+ The stack runs without volumes, so destroying the container erases every
106
+ stored signal (traces, metrics, logs, profiles) by construction; a new
107
+ container then starts from the image. After a reset, everything the
108
+ stack contains IS the next run - no window arithmetic needed.
109
+ """
110
+ stack_down()
111
+ return stack_up()
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: oddyssey-mcp
3
+ Version: 0.1.0
4
+ Summary: Oddyssey MCP server: pilots the local observability stack for CLI coding agents
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: mcp==2.0.0
8
+ Requires-Dist: httpx==0.28.1
@@ -0,0 +1,10 @@
1
+ pyproject.toml
2
+ app/__init__.py
3
+ app/server.py
4
+ app/stack.py
5
+ oddyssey_mcp.egg-info/PKG-INFO
6
+ oddyssey_mcp.egg-info/SOURCES.txt
7
+ oddyssey_mcp.egg-info/dependency_links.txt
8
+ oddyssey_mcp.egg-info/entry_points.txt
9
+ oddyssey_mcp.egg-info/requires.txt
10
+ oddyssey_mcp.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ oddyssey-mcp = oddyssey_mcp.server:main
@@ -0,0 +1,2 @@
1
+ mcp==2.0.0
2
+ httpx==0.28.1
@@ -0,0 +1 @@
1
+ oddyssey_mcp
@@ -0,0 +1,31 @@
1
+ [project]
2
+ name = "oddyssey-mcp"
3
+ version = "0.1.0"
4
+ description = "Oddyssey MCP server: pilots the local observability stack for CLI coding agents"
5
+ requires-python = ">=3.12"
6
+ license = "MIT"
7
+ dependencies = [
8
+ "mcp==2.0.0",
9
+ "httpx==0.28.1",
10
+ ]
11
+
12
+ [project.scripts]
13
+ oddyssey-mcp = "oddyssey_mcp.server:main"
14
+
15
+ [dependency-groups]
16
+ dev = [
17
+ "pytest==9.1.1",
18
+ ]
19
+
20
+ [build-system]
21
+ requires = ["setuptools==84.0.0"]
22
+ build-backend = "setuptools.build_meta"
23
+
24
+ [tool.setuptools]
25
+ packages = ["oddyssey_mcp"]
26
+
27
+ [tool.setuptools.package-dir]
28
+ oddyssey_mcp = "app"
29
+
30
+ [tool.pytest.ini_options]
31
+ addopts = ""
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+