From 0cc414dcb5b6bb1c257d4b96792d59b1a1423608 Mon Sep 17 00:00:00 2001 From: using-system Date: Sun, 23 Aug 2026 21:05:26 +0200 Subject: [PATCH 1/4] feat(mcp): make odd_stack_reset machine-wide wipe visible The stack is shared machine-wide, so a reset issued while observing one project silently destroyed every other project's telemetry (#35). The wipe stays global (the backends offer no per-service delete), but it is no longer silent: the tool description now states the machine-wide scope, and the reset result returns services_wiped - the service.name values stored across Tempo, Loki, and Prometheus just before the wipe - so a calling agent can warn when unexpected services are present. Refs #35 Co-Authored-By: Claude Fable 5 --- README.md | 6 ++ .../mcp-server/test-stack-reset.sh | 4 ++ src/mcp-server/app/server.py | 11 +++- src/mcp-server/app/stack.py | 50 ++++++++++++++- tests/mcp-server/test_server.py | 10 +++ tests/mcp-server/test_stack.py | 61 +++++++++++++++++++ 6 files changed, 139 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index bab6507..d2932eb 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,12 @@ than the local otel-lgtm container. | `odd_stack_status` | Probe whether it is up | | `odd_stack_reset` | Wipe all stored telemetry and return a fresh, ready stack — the next run starts from a clean slate | +One stack per machine: every project observed on the same workstation shares +it, so `odd_stack_reset` (and `odd_stack_down`) destroys the telemetry of +every project, not just the current one. The reset result's `services_wiped` +field lists the `service.name` values that were stored, so an unexpected +name is the cue to warn before wiping. + The server is instrumented with OpenTelemetry and, by default, exports its own traces and metrics to the local stack (`http://localhost:4318`, OTLP `http/protobuf` — the protocol is fixed, `OTEL_EXPORTER_OTLP_PROTOCOL` set diff --git a/integration-tests/mcp-server/test-stack-reset.sh b/integration-tests/mcp-server/test-stack-reset.sh index 74cefbc..3ac6cd4 100755 --- a/integration-tests/mcp-server/test-stack-reset.sh +++ b/integration-tests/mcp-server/test-stack-reset.sh @@ -56,6 +56,10 @@ MCP_SERVER_REQUEST_TIMEOUT="${MCP_SERVER_REQUEST_TIMEOUT:-420000}" \ mcp_call odd_stack_reset > "$workdir/reset.json" assert_result_contains "$workdir/reset.json" '"running": true' +step "the reset names the services it wiped (issue #35)" +assert_result_contains "$workdir/reset.json" 'services_wiped' +assert_result_contains "$workdir/reset.json" 'reset-proof' + step "the OTLP->Loki pipeline is live again (post-reset canary)" inject_log "$CANARY" wait_for_hits "$CANARY" diff --git a/src/mcp-server/app/server.py b/src/mcp-server/app/server.py index bfd3616..1065761 100644 --- a/src/mcp-server/app/server.py +++ b/src/mcp-server/app/server.py @@ -41,7 +41,7 @@ def odd_stack_up() -> dict: @mcp.tool() @telemetry.traced_tool def odd_stack_down() -> dict: - """Stop and remove the local LGTM stack; stored telemetry does not survive.""" + """Stop and remove the local LGTM stack; stored telemetry does not survive. The stack is shared by every project on this machine, so their data is destroyed too.""" return stack_ops.stack_down() @@ -55,7 +55,14 @@ def odd_stack_status() -> dict: @mcp.tool() @telemetry.traced_tool def odd_stack_reset() -> dict: - """Wipe all stored telemetry (traces, metrics, logs, profiles) and return a fresh, ready stack.""" + """Wipe ALL stored telemetry (traces, metrics, logs, profiles) and return a fresh, ready stack. + + The wipe is machine-wide and irreversible: one shared stack per machine, so + data from every project ever observed on it is destroyed, not just the + current one. The result's services_wiped field lists the service.name + values that were stored. If it may contain services outside the current + project, warn the user before calling this tool. + """ return stack_ops.stack_reset() diff --git a/src/mcp-server/app/stack.py b/src/mcp-server/app/stack.py index 486d3d1..fccd5ca 100644 --- a/src/mcp-server/app/stack.py +++ b/src/mcp-server/app/stack.py @@ -22,6 +22,17 @@ # needs to be exposed. PROMETHEUS_READY = "http://localhost:3000/api/datasources/proxy/uid/prometheus/-/ready" TEMPO_READY = "http://localhost:3000/api/datasources/proxy/uid/tempo/ready" +TEMPO_SERVICE_NAMES = ( + "http://localhost:3000/api/datasources/proxy/uid/tempo" + "/api/search/tag/service.name/values" +) +PROMETHEUS_JOB_VALUES = ( + "http://localhost:3000/api/datasources/proxy/uid/prometheus/api/v1/label/job/values" +) +LOKI_SERVICE_NAMES = ( + "http://localhost:3000/api/datasources/proxy/uid/loki" + "/loki/api/v1/label/service_name/values" +) GRAFANA_URL = "http://localhost:3000" OTLP_ENDPOINT = "http://localhost:4317" OTLP_HTTP_INGEST = "http://localhost:4318/v1/traces" @@ -122,6 +133,38 @@ def stack_up() -> dict: ) +def stored_services(transport: httpx.BaseTransport | None = None) -> list[str]: + """Best-effort list of service.name values currently stored in the stack. + + Union across the queryable backends, since a service may have emitted + only one signal: Tempo's service.name tag values, Loki's service_name + label values, and Prometheus job values (OTLP ingestion maps + service.name onto job, prefixed by service.namespace/ when one is + set). The list exists to warn before a wipe, so every failure degrades + to fewer names, never to an error. + """ + services: set[str] = set() + with httpx.Client(timeout=3.0, transport=transport) as client: + try: + tag_values = client.get(TEMPO_SERVICE_NAMES).json().get("tagValues", []) + services.update(v for v in tag_values if isinstance(v, str)) + except (httpx.HTTPError, ValueError, AttributeError): + pass + try: + names = client.get(LOKI_SERVICE_NAMES).json().get("data", []) + services.update(v for v in names if isinstance(v, str)) + except (httpx.HTTPError, ValueError, AttributeError): + pass + try: + jobs = client.get(PROMETHEUS_JOB_VALUES).json().get("data", []) + services.update( + job.rsplit("/", 1)[-1] for job in jobs if isinstance(job, str) + ) + except (httpx.HTTPError, ValueError, AttributeError): + pass + return sorted(services) + + def stack_down() -> dict: """Destroy the stack container (and its data); absent is already down.""" telemetry.force_flush() @@ -138,6 +181,11 @@ def stack_reset() -> dict: stored signal (traces, metrics, logs, profiles) by construction; a new container then starts from the image. After a reset, everything the stack contains IS the next run - no window arithmetic needed. + + The stack is shared machine-wide (issue #35), so the wipe is never + scoped to one project: the result names the services that were stored + so the destruction is at least visible to the caller. """ + services = stored_services() stack_down() - return stack_up() + return {**stack_up(), "services_wiped": services} diff --git a/tests/mcp-server/test_server.py b/tests/mcp-server/test_server.py index ec488b1..e714514 100644 --- a/tests/mcp-server/test_server.py +++ b/tests/mcp-server/test_server.py @@ -20,6 +20,16 @@ def test_tools_have_descriptions(): assert all(tool.description for tool in tools) +def test_reset_description_states_the_machine_wide_wipe(): + # Issue #35: the wipe is machine-wide (one shared stack per machine), + # and the tool result names the services it destroyed. Both facts must + # be visible to the calling agent through the tool description. + tools = asyncio.run(server.mcp.list_tools()) + reset = next(tool for tool in tools if tool.name == "odd_stack_reset") + assert "machine" in reset.description.lower() + assert "services_wiped" in reset.description + + def test_sdk_otel_middleware_removed(): # mcp 2.0 installs its own OpenTelemetryMiddleware by default, which # duplicated every tool span (observation report finding 1). The diff --git a/tests/mcp-server/test_stack.py b/tests/mcp-server/test_stack.py index 2a7619e..fe682b4 100644 --- a/tests/mcp-server/test_stack.py +++ b/tests/mcp-server/test_stack.py @@ -1,4 +1,5 @@ import httpx +from oddyssey_mcp import stack from oddyssey_mcp.stack import ( CONTAINER_NAME, IMAGE, @@ -6,6 +7,7 @@ _otlp_ingest_ready, run_args, stack_status, + stored_services, ) @@ -52,3 +54,62 @@ def refuse(request): transport = httpx.MockTransport(refuse) with httpx.Client(transport=transport) as client: assert _otlp_ingest_ready(client) is False + + +def test_stored_services_unions_tempo_prometheus_and_loki(): + # A service may have emitted only one signal, so all three queryable + # backends contribute. Prometheus OTLP ingestion maps service.name onto + # the job label, prefixed by service.namespace/ when one is set - the + # prefix must be stripped so backends report the same service.name values. + def handler(request: httpx.Request) -> httpx.Response: + url = str(request.url) + if "uid/tempo" in url: + return httpx.Response(200, json={"tagValues": ["checkout", "oddyssey-mcp"]}) + if "uid/loki" in url: + return httpx.Response( + 200, json={"status": "success", "data": ["logs-only"]} + ) + return httpx.Response( + 200, json={"status": "success", "data": ["shop/checkout", "billing"]} + ) + + services = stored_services(transport=httpx.MockTransport(handler)) + assert services == ["billing", "checkout", "logs-only", "oddyssey-mcp"] + + +def test_stored_services_is_empty_when_stack_is_down(): + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("connection refused") + + assert stored_services(transport=httpx.MockTransport(handler)) == [] + + +def test_stored_services_survives_a_malformed_backend_payload(): + # The list warns before a wipe; a broken backend answer must degrade + # to fewer names, never to an exception that blocks the reset. + def handler(request: httpx.Request) -> httpx.Response: + if "uid/tempo" in str(request.url): + return httpx.Response(200, content=b"not json") + return httpx.Response(200, json={"status": "success", "data": ["billing"]}) + + assert stored_services(transport=httpx.MockTransport(handler)) == ["billing"] + + +def test_stack_reset_reports_the_services_it_wiped(monkeypatch): + monkeypatch.setattr(stack, "stored_services", lambda: ["billing", "checkout"]) + monkeypatch.setattr(stack, "stack_down", lambda: {"running": False}) + monkeypatch.setattr( + stack, + "stack_up", + lambda: { + "running": True, + "grafana_url": "http://localhost:3000", + "otlp_endpoint": "http://localhost:4317", + }, + ) + + result = stack.stack_reset() + + assert result["services_wiped"] == ["billing", "checkout"] + assert result["running"] is True + assert result["grafana_url"] == "http://localhost:3000" From 4bba5398d7d14cc767bc520e7cfa60d1d1308250 Mon Sep 17 00:00:00 2001 From: using-system Date: Sun, 23 Aug 2026 21:26:48 +0200 Subject: [PATCH 2/4] fix(mcp): harden services_wiped against review findings Multi-agent review of the branch surfaced two defects that emptied the list in the most common scenarios, plus hardening gaps: - Tempo/Loki were queried without start/end: Tempo then only reads its live store and Loki looks back 6h, so day-old services were wiped without being listed. Both backends reject ranges over their caps (168h / 30d1h) rather than clamping, so the queries now use the widest accepted window (verified against the live stack). - A stopped container (normal after a host reboot) answers nothing on :3000, so the reset reported services_wiped: [] while destroying real data. The reset now boots a stopped container first (best-effort: a container too broken to boot must still be wipeable). - Wrong-typed JSON fields no longer leak (string iterated char-by-char) or raise (null field -> TypeError escaping the except tuples). - Namespace stripping uses split("/", 1): job is one namespace segment plus service.name, which may itself contain "/". - Issue #35 side note now addressed: docs and tool description state that oddyssey-mcp and otelcol-contrib are always listed and are never another project's leftover state. Refs #35 Co-Authored-By: Claude Fable 5 --- README.md | 5 +- src/mcp-server/app/server.py | 5 ++ src/mcp-server/app/stack.py | 68 ++++++++++++++---- tests/mcp-server/test_server.py | 5 ++ tests/mcp-server/test_stack.py | 121 +++++++++++++++++++++++++++++--- 5 files changed, 178 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index d2932eb..7688783 100644 --- a/README.md +++ b/README.md @@ -231,7 +231,10 @@ One stack per machine: every project observed on the same workstation shares it, so `odd_stack_reset` (and `odd_stack_down`) destroys the telemetry of every project, not just the current one. The reset result's `services_wiped` field lists the `service.name` values that were stored, so an unexpected -name is the cue to warn before wiping. +name is the cue to warn before wiping. Two names are always present and are +never leftover project state: `oddyssey-mcp` (the server observes itself and +exports to the stack it pilots, so even a fresh stack contains the reset's +own trace) and `otelcol-contrib` (the embedded collector's own metrics). The server is instrumented with OpenTelemetry and, by default, exports its own traces and metrics to the local stack (`http://localhost:4318`, OTLP diff --git a/src/mcp-server/app/server.py b/src/mcp-server/app/server.py index 1065761..4e2d99b 100644 --- a/src/mcp-server/app/server.py +++ b/src/mcp-server/app/server.py @@ -62,6 +62,11 @@ def odd_stack_reset() -> dict: current one. The result's services_wiped field lists the service.name values that were stored. If it may contain services outside the current project, warn the user before calling this tool. + + services_wiped always includes oddyssey-mcp (this server observes itself + and exports to the stack it pilots) and otelcol-contrib (the embedded + collector's own metrics): those two are never another project's leftover + state - only other names are. """ return stack_ops.stack_reset() diff --git a/src/mcp-server/app/stack.py b/src/mcp-server/app/stack.py index fccd5ca..e77fbc0 100644 --- a/src/mcp-server/app/stack.py +++ b/src/mcp-server/app/stack.py @@ -39,6 +39,14 @@ STARTUP_TIMEOUT_S = 120 POLL_INTERVAL_S = 2 +# Widest lookback each backend accepts for the pre-wipe service listing: +# requests beyond the cap are rejected outright (not clamped), so the +# window sits just under Tempo's 168h search max_duration and Loki's +# 30d1h max_query_length. Signals older than these windows can be missed; +# Prometheus (queried without a range) covers its full TSDB. +TEMPO_SEARCH_WINDOW_S = 167 * 3600 +LOKI_SEARCH_WINDOW_S = 30 * 24 * 3600 + def run_args() -> list[str]: """The docker run command that creates the stack container.""" @@ -139,28 +147,49 @@ def stored_services(transport: httpx.BaseTransport | None = None) -> list[str]: Union across the queryable backends, since a service may have emitted only one signal: Tempo's service.name tag values, Loki's service_name label values, and Prometheus job values (OTLP ingestion maps - service.name onto job, prefixed by service.namespace/ when one is - set). The list exists to warn before a wipe, so every failure degrades - to fewer names, never to an error. + service.name onto job, prefixed by one service.namespace/ segment when + a namespace is set). Tempo and Loki need an explicit start/end pair: + unscoped, Tempo only reads its live store (flushed blocks are + invisible) and Loki defaults to a 6-hour lookback, so day-old services + would be wiped without ever being listed. The list exists to warn + before a wipe, so every failure degrades to fewer names, never to an + error. """ + now_s = int(time.time()) + + def values(payload: object, field: str) -> list[str]: + if not isinstance(payload, dict): + return [] + items = payload.get(field) + if not isinstance(items, list): + return [] + return [v for v in items if isinstance(v, str)] + services: set[str] = set() with httpx.Client(timeout=3.0, transport=transport) as client: try: - tag_values = client.get(TEMPO_SERVICE_NAMES).json().get("tagValues", []) - services.update(v for v in tag_values if isinstance(v, str)) - except (httpx.HTTPError, ValueError, AttributeError): + tempo = client.get( + TEMPO_SERVICE_NAMES, + params={"start": now_s - TEMPO_SEARCH_WINDOW_S, "end": now_s}, + ).json() + services.update(values(tempo, "tagValues")) + except (httpx.HTTPError, ValueError, TypeError): pass try: - names = client.get(LOKI_SERVICE_NAMES).json().get("data", []) - services.update(v for v in names if isinstance(v, str)) - except (httpx.HTTPError, ValueError, AttributeError): + loki = client.get( + LOKI_SERVICE_NAMES, + params={ + "start": (now_s - LOKI_SEARCH_WINDOW_S) * 1_000_000_000, + "end": now_s * 1_000_000_000, + }, + ).json() + services.update(values(loki, "data")) + except (httpx.HTTPError, ValueError, TypeError): pass try: - jobs = client.get(PROMETHEUS_JOB_VALUES).json().get("data", []) - services.update( - job.rsplit("/", 1)[-1] for job in jobs if isinstance(job, str) - ) - except (httpx.HTTPError, ValueError, AttributeError): + prometheus = client.get(PROMETHEUS_JOB_VALUES).json() + services.update(job.split("/", 1)[-1] for job in values(prometheus, "data")) + except (httpx.HTTPError, ValueError, TypeError): pass return sorted(services) @@ -184,8 +213,17 @@ def stack_reset() -> dict: The stack is shared machine-wide (issue #35), so the wipe is never scoped to one project: the result names the services that were stored - so the destruction is at least visible to the caller. + so the destruction is at least visible to the caller. A stopped + container (normal after a host reboot) still holds telemetry but + answers nothing on :3000, so it is booted first to be enumerable - + best-effort, because wiping a container too broken to boot is also + reset's job. """ + if _container_state() == "stopped": + try: + stack_up() + except RuntimeError: + pass services = stored_services() stack_down() return {**stack_up(), "services_wiped": services} diff --git a/tests/mcp-server/test_server.py b/tests/mcp-server/test_server.py index e714514..d689c4f 100644 --- a/tests/mcp-server/test_server.py +++ b/tests/mcp-server/test_server.py @@ -28,6 +28,11 @@ def test_reset_description_states_the_machine_wide_wipe(): reset = next(tool for tool in tools if tool.name == "odd_stack_reset") assert "machine" in reset.description.lower() assert "services_wiped" in reset.description + # Issue #35 side note: the server observes itself and the embedded + # collector self-reports, so these two names are always listed - the + # agent must not read them as another project's leftover state. + assert "oddyssey-mcp" in reset.description + assert "otelcol-contrib" in reset.description def test_sdk_otel_middleware_removed(): diff --git a/tests/mcp-server/test_stack.py b/tests/mcp-server/test_stack.py index fe682b4..bae9b08 100644 --- a/tests/mcp-server/test_stack.py +++ b/tests/mcp-server/test_stack.py @@ -77,6 +77,65 @@ def handler(request: httpx.Request) -> httpx.Response: assert services == ["billing", "checkout", "logs-only", "oddyssey-mcp"] +def test_stored_services_queries_tempo_and_loki_with_their_widest_time_range(): + # Without explicit start/end, Tempo's tag-values endpoint only reads the + # live store (flushed blocks are invisible) and Loki defaults to a 6-hour + # lookback - a day-old project would be wiped without ever being listed. + # Both cap the queryable range (Tempo max_duration 168h, Loki + # max_query_length 30d1h) and reject wider requests outright, so the + # window must sit just under each cap, not at epoch 0. + seen: dict[str, httpx.URL] = {} + + def handler(request: httpx.Request) -> httpx.Response: + url = str(request.url) + if "uid/tempo" in url: + seen["tempo"] = request.url + return httpx.Response(200, json={"tagValues": []}) + if "uid/loki" in url: + seen["loki"] = request.url + return httpx.Response(200, json={"status": "success", "data": []}) + return httpx.Response(200, json={"status": "success", "data": []}) + + stored_services(transport=httpx.MockTransport(handler)) + + tempo_start = int(seen["tempo"].params["start"]) + tempo_end = int(seen["tempo"].params["end"]) + assert tempo_start > 0 + assert tempo_end - tempo_start == stack.TEMPO_SEARCH_WINDOW_S + + loki_start = int(seen["loki"].params["start"]) + loki_end = int(seen["loki"].params["end"]) + assert loki_start > 0 + assert loki_end - loki_start == stack.LOKI_SEARCH_WINDOW_S * 1_000_000_000 + + +def test_stored_services_strips_only_the_namespace_prefix(): + # job is "/" with a single namespace + # segment; a service.name containing "/" must survive intact. + def handler(request: httpx.Request) -> httpx.Response: + if "uid/prometheus" in str(request.url): + return httpx.Response( + 200, json={"status": "success", "data": ["eu/shop/checkout"]} + ) + return httpx.Response(200, json={"tagValues": []}) + + assert stored_services(transport=httpx.MockTransport(handler)) == ["shop/checkout"] + + +def test_stored_services_ignores_wrong_typed_json_fields(): + # A string-typed field must not be iterated char-by-char, and a null + # field must not raise: both degrade to fewer names per the contract. + def handler(request: httpx.Request) -> httpx.Response: + url = str(request.url) + if "uid/tempo" in url: + return httpx.Response(200, json={"tagValues": "checkout"}) + if "uid/loki" in url: + return httpx.Response(200, json={"status": "success", "data": None}) + return httpx.Response(200, json={"status": "success", "data": ["billing"]}) + + assert stored_services(transport=httpx.MockTransport(handler)) == ["billing"] + + def test_stored_services_is_empty_when_stack_is_down(): def handler(request: httpx.Request) -> httpx.Response: raise httpx.ConnectError("connection refused") @@ -95,21 +154,63 @@ def handler(request: httpx.Request) -> httpx.Response: assert stored_services(transport=httpx.MockTransport(handler)) == ["billing"] -def test_stack_reset_reports_the_services_it_wiped(monkeypatch): - monkeypatch.setattr(stack, "stored_services", lambda: ["billing", "checkout"]) - monkeypatch.setattr(stack, "stack_down", lambda: {"running": False}) +UP_RESULT = { + "running": True, + "grafana_url": "http://localhost:3000", + "otlp_endpoint": "http://localhost:4317", +} + + +def _trace_reset(monkeypatch, state: str, up=None) -> tuple[list[str], dict]: + """Run stack_reset with docker/backends stubbed; return (call order, result).""" + calls: list[str] = [] + monkeypatch.setattr(stack, "_container_state", lambda: state) monkeypatch.setattr( stack, - "stack_up", - lambda: { - "running": True, - "grafana_url": "http://localhost:3000", - "otlp_endpoint": "http://localhost:4317", - }, + "stored_services", + lambda: calls.append("stored_services") or ["billing", "checkout"], ) + monkeypatch.setattr( + stack, "stack_down", lambda: calls.append("stack_down") or {"running": False} + ) + monkeypatch.setattr( + stack, "stack_up", up or (lambda: calls.append("stack_up") or UP_RESULT) + ) + return calls, stack.stack_reset() - result = stack.stack_reset() + +def test_stack_reset_reports_the_services_it_wiped(monkeypatch): + calls, result = _trace_reset(monkeypatch, "running") assert result["services_wiped"] == ["billing", "checkout"] assert result["running"] is True assert result["grafana_url"] == "http://localhost:3000" + # The query must happen before the wipe, or the list is always empty. + assert calls == ["stored_services", "stack_down", "stack_up"] + + +def test_stack_reset_boots_a_stopped_container_before_querying(monkeypatch): + # A stopped container (normal after a host reboot) still holds telemetry + # but answers nothing on :3000 - querying it directly would report + # services_wiped: [] while destroying real data. + calls, result = _trace_reset(monkeypatch, "stopped") + + assert calls == ["stack_up", "stored_services", "stack_down", "stack_up"] + assert result["services_wiped"] == ["billing", "checkout"] + + +def test_stack_reset_still_wipes_a_stopped_container_that_cannot_boot(monkeypatch): + # Recovering a broken stack is part of reset's job: if the pre-query + # boot fails, the wipe must proceed rather than error out. + boots: list[int] = [] + + def up(): + boots.append(1) + if len(boots) == 1: + raise RuntimeError("container will not start") + return UP_RESULT + + calls, result = _trace_reset(monkeypatch, "stopped", up=up) + + assert result["running"] is True + assert result["services_wiped"] == ["billing", "checkout"] From 7db4b862b0301581cdfe8817530643aecab98ea8 Mon Sep 17 00:00:00 2001 From: using-system Date: Sun, 23 Aug 2026 21:27:51 +0200 Subject: [PATCH 3/4] style(tests): silence RUF059 on unused unpacked variable Co-Authored-By: Claude Fable 5 --- tests/mcp-server/test_stack.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/mcp-server/test_stack.py b/tests/mcp-server/test_stack.py index bae9b08..f39b036 100644 --- a/tests/mcp-server/test_stack.py +++ b/tests/mcp-server/test_stack.py @@ -210,7 +210,7 @@ def up(): raise RuntimeError("container will not start") return UP_RESULT - calls, result = _trace_reset(monkeypatch, "stopped", up=up) + _, result = _trace_reset(monkeypatch, "stopped", up=up) assert result["running"] is True assert result["services_wiped"] == ["billing", "checkout"] From 1f793c137f8c130dd5c3e699c0eab5e0c1810d76 Mon Sep 17 00:00:00 2001 From: using-system Date: Sun, 23 Aug 2026 21:30:29 +0200 Subject: [PATCH 4/4] docs(readme): drop the stack-sharing paragraph, the tool description is the contract Co-Authored-By: Claude Fable 5 --- README.md | 9 --------- 1 file changed, 9 deletions(-) diff --git a/README.md b/README.md index 7688783..bab6507 100644 --- a/README.md +++ b/README.md @@ -227,15 +227,6 @@ than the local otel-lgtm container. | `odd_stack_status` | Probe whether it is up | | `odd_stack_reset` | Wipe all stored telemetry and return a fresh, ready stack — the next run starts from a clean slate | -One stack per machine: every project observed on the same workstation shares -it, so `odd_stack_reset` (and `odd_stack_down`) destroys the telemetry of -every project, not just the current one. The reset result's `services_wiped` -field lists the `service.name` values that were stored, so an unexpected -name is the cue to warn before wiping. Two names are always present and are -never leftover project state: `oddyssey-mcp` (the server observes itself and -exports to the stack it pilots, so even a fresh stack contains the reset's -own trace) and `otelcol-contrib` (the embedded collector's own metrics). - The server is instrumented with OpenTelemetry and, by default, exports its own traces and metrics to the local stack (`http://localhost:4318`, OTLP `http/protobuf` — the protocol is fixed, `OTEL_EXPORTER_OTLP_PROTOCOL` set