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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]

Expand Down
38 changes: 36 additions & 2 deletions src/devhelm_mcp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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]:
Expand Down
28 changes: 28 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading