Stamp the deployment (local vs hosted) into the client telemetry headers - #551
Open
mocha06 wants to merge 3 commits into
Open
Stamp the deployment (local vs hosted) into the client telemetry headers#551mocha06 wants to merge 3 commits into
mocha06 wants to merge 3 commits into
Conversation
The surface says which entry point ran (mcp, cli, sdk) but not where it ran,
so a stdio MCP server on a user's machine and a hosted remote-profile server
emit byte-identical client headers and cannot be told apart server-side.
Add a second, orthogonal axis: ClientDeployment ("local" | "hosted"). With a
surface and a deployment, telemetry_user_agent emits
"<product>/<version> (<surface>; <deployment>)" and telemetry_headers adds a
parsed X-Client-Deployment beside the X-Client-Name / X-Client-Version pair,
so a consumer never matches on the User-Agent string to group traffic.
The MCP composition root derives the deployment from the already-validated
settings.mcp.profile ("remote" -> hosted, otherwise local), keeping the
property #336 established for the surface: stamped programmatically, never
read from env or TOML, unforgeable by a caller. No PIPEFY_*_DEPLOYMENT knob
is added, and a test fails if one ever is.
Both sides are labelled explicitly, so a bare "(mcp)" unambiguously reads as
a client older than this change rather than a local one; the one-time cost is
that the existing "(mcp)" series is renamed rather than continued.
deployment defaults to None everywhere, so the cli and sdk surfaces and the
OAuth path stay byte-identical: the CLI keeps plain "(cli)" and sends three
client headers, and auth_telemetry_headers is untouched since the IdP path has
neither a surface nor a deployment.
Closes #550
The back-merge that stamped `## [0.4.0-beta.2]` did so above the `### Added` list this entry sat in, so a line-based merge left it inside a section that predates the change — claiming the deployment axis shipped in 0.4.0-beta.2. Textually clean, factually wrong. Unreleased is the correct home either way: an alpha deliberately does not stamp the changelog, so `## [Unreleased]` accumulates across alpha.1..alpha.N and the beta promotion stamps the whole set into one section. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
adriannoes
approved these changes
Aug 1, 2026
adriannoes
left a comment
Collaborator
There was a problem hiding this comment.
Looks merge-ready. The second telemetry axis lands where it should: pure builders in pipefy_infra, keyword-only forwarding through the existing SDK seams, and a single stamp at McpRuntime derived from the already-resolved profile (remote to hosted, otherwise local). CLI, direct SDK, and the IdP path stay byte-identical when deployment is omitted, which matches the stated boundaries.
What worked well:
- Orthogonal axes with explicit dual labelling so bare
(mcp)stays the pre-axis signal, not an ambiguous local default. - Exact UA and header-dict pins plus the profile wiring tests (including the env-forge guard) lock the wire contract without inventing a config knob.
- The Unreleased CHANGELOG placement after the
devback-merge is the right fix; the rename cost for the MCP series is disclosed there.
Nothing required before merge from this review. CI is green on the reviewed head.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
telemetry_headersidentifies the client by product, version, and surface, so an outbound API request carriesUser-Agent: pipefy-sdk/<version> (mcp)plus theX-Client-Name/X-Client-Versionpair. The surface says which entry point ran (mcp,cli,sdk) but not where it ran: a stdio MCP server on a user's own machine and a hosted remote-profile server emit byte-identical headers. Anything aggregating those fields can count MCP traffic and break it down by version, but cannot separate the two deployments — so there is no read at all on hosted adoption versus local installs.The fact was already resolved in-process and then dropped.
McpRuntime.__init__computesself.is_remote = settings.mcp.profile == "remote"on the line directly above thePipefyEngine.build(settings.pipefy, surface="mcp")call, and still passed the bare constant.Outcome
A second axis beside
ClientSurface, orthogonal to it — the surface says which entry point, the deployment says where that entry point runs:telemetry_user_agenttakes an optionaldeployment, and with both a surface and a deployment it emitspipefy-sdk/<version> (<surface>; <deployment>).telemetry_headersgainsX-Client-Deployment, matching the intent of theX-Client-Name/X-Client-Versionpair it already sends: parsed fields, so a consumer never has to match on theUser-Agentstring to group traffic.The SDK forwards a keyword-only
deployment: ClientDeployment | None = Nonethroughbuild_endpoints(the single site that callstelemetry_headers),build_executors,PipefyEngine.build, andPipefyClient.__init__, and re-exportsClientDeploymentbesideClientSurface. The MCP composition root passesdeployment="hosted" if self.is_remote else "local".Deployment is derived, never configured
#336 established that the surface is stamped programmatically at each composition root, is never read from env or TOML, and cannot be forged by a caller. The deployment keeps that property: it is derived from the already-validated
settings.mcp.profile, and noPIPEFY_*_DEPLOYMENTknob is added.test_no_env_var_can_forge_the_deploymentfails if such a setting is ever introduced.Both sides are labelled explicitly (
localandhosted) rather than marking onlyhosted, so a bare(mcp)unambiguously reads as "client older than this change" and never as "local". The one-time cost is that the existing(mcp)series is renamed rather than continued.A local operator can set
PIPEFY_MCP_PROFILE=remoteand self-report ashosted, but doing so also demands a resource-server URL and an inbound issuer, so it is not a casual mislabel.Behavior boundaries
deploymentdefaults toNoneat every seam, so every path that does not pass one is byte-identical to before:(cli)and sends exactly three client headers. A CLI is local by definition, and splitting that series buys nothing.(sdk), three headers.auth_telemetry_headersis untouched — the IdP path has neither a surface nor a deployment.Out of scope, unchanged: the S3 attachment upload/download in
packages/sdk/src/pipefy_sdk/services/attachment_service.py(not a Pipefy API host, so it does not belong in these counts) and the iPaaS gateway inpackages/mcp/src/pipefy_mcp/core/ipaas_gateway.py, which sends onlyAuthorization. Neither carries telemetry headers today and neither gains them here.Docs touched
CHANGELOG.md(Unreleased → Added) documents the header trio and the two axes. Nodocs/page or skill describes the client headers, so nothing else needed updating; the module and function docstrings inpipefy_infra.telemetrycarry the two-axis contract. No MCP tool, CLI command, or tool count changed, sodocs/parity.md, the README counts, the CLI help golden, and the remote seed are untouched.Testing
Gates (all run locally, from the directory CI runs them in)
uv run pytest -m "not integration"uv run ruff check packages/*/srcuv run ruff format --check packages/*/srccd packages/mcp && uv run lint-importsuv run python scripts/bump_version.py verifyuv run python .github/workflows/scripts/lint_skill_refs.pyBytes on the wire
Unit tests pin the strings, but the contract is what the sink receives.
PIPEFY_BASE_URLpointed at a local HTTP sink, driving the real composition root (McpRuntime.for_profile→ session → a facade call) and then the CLI/SDK surfaces:(mcp; local)+X-Client-Deployment: localpipefy-sdk/0.4.0-beta.1 (mcp; local),X-Client-Name: mcp,X-Client-Deployment: local--profile remote(mcp; hosted)+X-Client-Deployment: hostedpipefy-sdk/0.4.0-beta.1 (mcp; hosted),X-Client-Name: mcp,X-Client-Deployment: hosted(cli), no deployment headerpipefy-sdk/0.4.0-beta.1 (cli),X-Client-Name: cli,X-Client-Deploymentabsent(sdk), no deployment headerpipefy-sdk/0.4.0-beta.1 (sdk),X-Client-Name: sdk,X-Client-DeploymentabsentUnit coverage added
packages/sdk/tests/test_telemetry.py: the(mcp; hosted)/(mcp; local)user-agent cases, the four-key header dict (exact-length, both deployments), and an explicit case that no deployment keeps the three-key dict. The pre-existing exact-length three-key cases are unchanged.packages/mcp/tests/core/test_runtime.py: the built endpoints (all three) carryhostedunderprofile="remote"andlocalunderprofile="local", plus the no-env-knob drift-guard.Closes #550