From c5701380c58379df2b130ad0e4bd16985bfffe58 Mon Sep 17 00:00:00 2001 From: caballeto Date: Fri, 1 May 2026 18:17:26 +0200 Subject: [PATCH] feat(client): override SDK surface to "mcp" for usage telemetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Constructs the underlying devhelm.Devhelm SDK with surface="mcp" so the API attributes traffic to the MCP server rather than to bare-SDK use. The SDK's X-DevHelm-Sdk-Name header is preserved alongside, so the API can still see which devhelm SDK version this MCP build is on for client-version skew debugging. Surface version comes from importlib.metadata; reports the installed devhelm-mcp-server release. Falls back to "unknown" for source-tree installs. Detecting the host MCP client (Cursor vs Claude Desktop vs ...) is deferred — fastmcp's Context.session.client_params.clientInfo carries that info, but threading Context through every tool would be a wide surgery against this PR's "no callsite changes" goal. The wire contract already supports X-DevHelm-Mcp-Client via surface_metadata so we can layer it in later without an API change. Depends on devhelm>=0.6.0 (which ships the surface=/surface_version=/ surface_metadata= constructor kwargs). pyproject pin bumped accordingly. Tests: 1 new test in test_client.py asserting the SDK is built with the mcp surface override. Full suite (58) green; ruff + mypy clean. Wire contract docs: https://devhelm.io/telemetry API-side handler: devhelmhq/mono#332 SDK-side change: devhelmhq/sdk-python#19 Co-authored-by: Cursor --- pyproject.toml | 2 +- src/devhelm_mcp/client.py | 38 ++++++++++++++++++++++++++++++++++++-- tests/test_client.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 83c2d9c..dafded5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ classifiers = [ dependencies = [ # Floor bumped to 0.5.0 for the forensic read resource (timeline / trace / # evaluations / transitions / policy snapshot) used by tools/forensics.py. - "devhelm>=0.5.0", + "devhelm>=0.6.0", "fastmcp>=2.0.0", ] diff --git a/src/devhelm_mcp/client.py b/src/devhelm_mcp/client.py index 26f72fa..b6d7996 100644 --- a/src/devhelm_mcp/client.py +++ b/src/devhelm_mcp/client.py @@ -3,6 +3,8 @@ from __future__ import annotations import os +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as _pkg_version from typing import Any from devhelm import ( @@ -19,9 +21,41 @@ ToolResult = dict[str, Any] | list[dict[str, Any]] | str +def _server_version() -> str: + """Resolve the installed ``devhelm-mcp-server`` version once. + + Reported on every API call as ``X-DevHelm-Surface-Version`` so the API + can track which MCP server release is in active use across the fleet. + Falls back to ``"unknown"`` for source-tree installs. + """ + try: + return _pkg_version("devhelm-mcp-server") + except PackageNotFoundError: + return "unknown" + + def get_client(api_token: str) -> Devhelm: - """Build a Devhelm SDK client from the user's API token.""" - return Devhelm(token=api_token, base_url=API_BASE_URL) + """Build a Devhelm SDK client from the user's API token. + + Overrides the SDK's default surface (``sdk-py``) with ``mcp`` so the + API attributes traffic to the MCP server rather than to bare-SDK use. + The SDK's ``X-DevHelm-Sdk-Name`` header is preserved, so the API can + still see *which* SDK version this MCP server release is built on for + debugging client-version skew. + + Detecting the host MCP client (Cursor vs Claude Desktop vs ...) is a + follow-up: ``fastmcp.Context.session.client_params.clientInfo`` carries + that info, but threading Context through every tool would be a wide + surgery against the no-callsite-changes goal of this PR. The wire + contract already supports ``X-DevHelm-Mcp-Client`` via + ``surface_metadata`` so we can layer it in later without an API change. + """ + return Devhelm( + token=api_token, + base_url=API_BASE_URL, + surface="mcp", + surface_version=_server_version(), + ) def as_payload(model: BaseModel) -> dict[str, Any]: diff --git a/tests/test_client.py b/tests/test_client.py index d2fe81d..576dab5 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -92,3 +92,31 @@ def test_format_transport_error_passthrough() -> None: err = DevhelmTransportError("connection refused") out = format_error(err) assert out == "TransportError: connection refused" + + +# ---------- Surface telemetry override ---------- + + +def test_get_client_overrides_surface_to_mcp() -> None: + """The MCP server wraps the SDK; its traffic must be attributed to + surface=mcp on the API side, not to the SDK's default sdk-py. + + See https://devhelm.io/telemetry for the wire contract. + """ + from devhelm_mcp.client import get_client + + client = get_client("dummy-token-for-test") + # The SDK's underlying httpx.Client lives under several private attributes + # depending on the SDK release; reach in via the resources to touch the + # one we know exists. Resources hold the same httpx.Client by reference. + httpx_client = client.monitors._client # type: ignore[attr-defined] + headers = httpx_client.headers + assert headers["x-devhelm-surface"] == "mcp" + # SDK identity is preserved alongside the wrapper surface so the API can + # still see which SDK version this MCP build is on. + assert headers["x-devhelm-sdk-name"] == "sdk-py" + # surface_version comes from importlib.metadata; in source-tree installs + # it may be "unknown", in published installs it's the package version. + # Either way it must be a non-empty string. + assert headers["x-devhelm-surface-version"] + httpx_client.close()