Skip to content

feat(mcp): per-request ${context:NAME} resolution in MCP-headers config (MER-77) - #1

Merged
chungty merged 7 commits into
mainfrom
mer-77-context-name-interpolation
May 27, 2026
Merged

feat(mcp): per-request ${context:NAME} resolution in MCP-headers config (MER-77)#1
chungty merged 7 commits into
mainfrom
mer-77-context-name-interpolation

Conversation

@chungty

@chungty chungty commented May 27, 2026

Copy link
Copy Markdown

Summary

Adds two small, additive primitives so MCP-server consumers can attach per-call session-context values (most commonly delegated user identity) to outbound MCP HTTP headers — without monkey-patching httpx internals or routing through a sidecar process.

  1. gateway.session_context.register_session_context_var(name, var) — plugins register their own ContextVars under arbitrary names.
  2. tools/mcp_tool.py ${context:NAME} interpolationmcp_servers.<name>.headers values with ${context:NAME} placeholders resolved per request against the session-context registry.

Full design, behavior, and rationale — including the per-transport resolution table, usage example, and test inventory (23 new tests, all passing locally) — are in the upstream PR opened in parallel: NousResearch#32949.

Why this PR (in addition to the upstream one)

This PR lands the change on the Verdigris fork's main so Mercator can bump its pinned Hermes SHA in requirements.txt and unblock MER-78 (Mercator-side delegated-principal wiring). Upstream review timelines are unpredictable; the fork-side PR exists to keep MER-62's end-to-end ship date independent.

If/when NousResearch#32949 merges, this PR's content gets squashed into our main via rebase, and fork divergence drops to zero. Until then, ~50 lines of divergence carried.

Test plan

  • tests/gateway/test_session_env.py — 7 new tests for the registry, all passing
  • tests/tools/test_mcp_context_template.py — 16 new tests (unit + httpx integration), all passing
  • ruff check clean on changed files
  • ty check — no new diagnostics from this change
  • CI green
  • Reviewer bot comments addressed

Origin

MER-77 — Linear ticket.
Upstream sibling PR: NousResearch#32949.
Dependent tickets: MER-78 (Mercator wiring), MER-62 (Meridian gateway receive-side).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Plugins can register custom session-context variables usable by ${context:NAME} templates; registered names are discoverable and registration is thread-safe (last-writer-wins).
    • MCP HTTP headers support ${context:NAME} templates: resolved per-request for the new HTTP path, frozen for SSE/legacy, and omitted when empty; cross-origin redirects strip identity headers.
  • Tests

    • Added comprehensive tests for template detection/resolution, registration validation, per-request header injection, and asyncio task-local isolation.
  • Chores

    • Release tooling author mapping updated.

Review Change Stack

Adds two small, additive primitives so consumers can attach per-call
session-context values (most commonly delegated user identity) to
outbound MCP HTTP request headers without monkey-patching the httpx
client or routing through a sidecar process.

1. `gateway.session_context.register_session_context_var(name, var)`
   exposes the existing built-in `_VAR_MAP` to plugins so they can
   register their own `ContextVar`s under arbitrary names. Registered
   names resolve through the same `get_session_env` path, with the
   same asyncio-task-local isolation and `os.environ` fallback
   semantics as the built-in `HERMES_SESSION_*` vars.

2. `tools/mcp_tool.py` recognizes `${context:NAME}` placeholders in
   `mcp_servers.<name>.headers` config values and resolves them
   PER REQUEST against the session context registry. Static headers
   continue to be applied as `httpx.AsyncClient` defaults; templated
   headers are attached via an `event_hooks["request"]` injector that
   sees the calling asyncio task's current values.

Behavior:
  * Streamable HTTP transport (`mcp >= 1.24.0`) — per-request resolution.
  * SSE transport — resolves once at stream-open (single long-lived
    connection; per-request substitution not applicable).
  * Legacy HTTP transport (`mcp < 1.24.0`) — resolves once at startup
    with an info log, since Hermes doesn't own the httpx client there.

Empty resolved values cause the templated header to be omitted from
the outbound request (not sent as an empty `X-Foo:`).

Config-load-time `${ENV_VAR}` substitution is unchanged. Mixing
`${ENV_VAR}` and `${context:NAME}` in the same header value works
(env vars resolve at load time, context names per-request).

Tests (23 new):
  * Registry resolution, env-fallback, explicit-empty, last-writer-wins,
    asyncio task isolation (`tests/gateway/test_session_env.py`).
  * Template detection / resolution / partitioning helpers.
  * End-to-end per-request injection through `httpx.MockTransport`
    verifying both per-request value change and concurrent-task isolation
    (`tests/tools/test_mcp_context_template.py`).

Origin: surfaced by Verdigris's Mercator while scoping per-call
delegated-principal propagation to its MCP gateway (MER-62). The
gateway-side receive piece can land independently; this PR is the
substrate primitive the send-side wiring depends on. Generic enough
that any Hermes consumer wiring per-request auth identity benefits.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@linear-code

linear-code Bot commented May 27, 2026

Copy link
Copy Markdown

MER-77

@coderabbitai

coderabbitai Bot commented May 27, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR adds plugin registration for session ContextVars, exposes introspection of registered names, and uses registered and built-in session variables to resolve ${context:NAME} templates in MCP HTTP headers with transport-specific wiring and tests.

Changes

Context-aware MCP headers via plugin-registered session variables

Layer / File(s) Summary
Session context plugin registration API
gateway/session_context.py
register_session_context_var and get_registered_var_names allow plugins to register custom ContextVar names that resolve through the same path as built-in HERMES_SESSION_* variables; added lock protection and get_session_env docs updated.
Session context plugin API tests
tests/gateway/test_session_env.py
Tests for registration, resolution via get_session_env, environment fallback when unset, explicit empty-string behavior, input validation, re-registration (last-writer-wins), introspection of registered names, and asyncio task isolation.
MCP header template detection and resolution
tools/mcp_tool.py
Adds helpers to detect ${context:NAME} templates in header values, resolve them via gateway.session_context.get_session_env, and split headers into static vs templated sets.
Per-transport header resolution in MCP HTTP
tools/mcp_tool.py
SSE resolves templates once at stream-open; new HTTP path injects resolved templated headers per-request via httpx request event hooks (omitting/deleting empty values); deprecated HTTP path resolves templates once at startup.
MCP header template tests
tests/tools/test_mcp_context_template.py
Unit tests validate template detection, resolution (set/unset/unknown, empty-string behavior, non-string pass-through), header splitting; integration tests verify per-request header injection, same-task mutation, redirect stripping, and asyncio task isolation.
Release author map update
scripts/release.py
Adds an AUTHOR_MAP entry mapping chungty@gmail.com to the GitHub mention handle.
TUI npm install test update
tests/hermes_cli/test_tui_npm_install.py
Test updated to expect node called with --expose-gc.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I hopped in quiet code, with ContextVars in tow,

Per-request headers bloom where gentle templates grow,
Static paths stay steady, templated ones now flow,
Each task keeps its own secret — no cross-talk to show,
Plugins plant their markers, and the gateway's gardens glow.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 49.06% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main feature: per-request context variable interpolation (${context:NAME}) in MCP headers configuration.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch mer-77-context-name-interpolation

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

github-actions Bot commented May 27, 2026

Copy link
Copy Markdown

🔎 Lint report: mer-77-context-name-interpolation vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 9061 on HEAD, 9057 on base (🆕 +4)

🆕 New issues (4):

Rule Count
unresolved-import 2
invalid-argument-type 2
First entries
tests/tools/test_mcp_context_template.py:15: [unresolved-import] unresolved-import: Cannot resolve imported module `httpx`
tests/tools/test_mcp_context_template.py:16: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/tools/test_mcp_context_template.py:108: [invalid-argument-type] invalid-argument-type: Argument to function `_resolve_context_templates` is incorrect: Expected `str`, found `Literal[42]`
tests/gateway/test_session_env.py:410: [invalid-argument-type] invalid-argument-type: Argument to function `register_session_context_var` is incorrect: Expected `ContextVar[Unknown]`, found `Literal["not a contextvar"]`

✅ Fixed issues: none

Unchanged: 4798 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a plugin-extensible registry for session context variables, allowing per-request interpolation of ${context:NAME} templates in MCP-server header configurations. The changes include new registration and resolution utilities, integration with httpx.AsyncClient event hooks for dynamic header injection, and comprehensive test coverage. The review feedback highlights opportunities to improve robustness and code quality, such as explicitly casting resolved context values to strings to prevent TypeError in regex substitution, validating registered variable names as valid ASCII identifiers to ensure they match template patterns, and simplifying redundant type checks in dictionary comprehensions.

Comment thread tools/mcp_tool.py Outdated
Comment thread gateway/session_context.py Outdated
Comment thread tools/mcp_tool.py Outdated
Comment thread tools/mcp_tool.py Outdated
Required by the check-attribution CI job; mirror of the existing
mapping pattern (e.g. Stark-X immediately above).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@chungty

chungty commented May 27, 2026

Copy link
Copy Markdown
Author

⚠️ Note: `test` job failure is pre-existing on `main`, unrelated to this PR

The single failing test is tests/hermes_cli/test_tui_npm_install.py::test_make_tui_argv_skips_build_only_on_termux_when_fresh. It fails on plain origin/main of this fork with the exact same error — verified locally just now.

Root cause: commit 3d2f14646 added --expose-gc to the Node.js launch argv at hermes_cli/main.py:1276,1282,1359 but didn't update the test at tests/hermes_cli/test_tui_npm_install.py:171. The test still expects:

assert argv == ["/bin/node", str(tmp_path / "dist" / "entry.js")]

…when production now returns ["/bin/node", "--expose-gc", str(...)]. One-line drive-by fix; separable from this PR.

This PR's substance: my changes touch gateway/session_context.py and tools/mcp_tool.py (MCP transport headers) — completely orthogonal to the TUI/CLI code path. The relevant test surfaces I added/modified all pass:

  • tests/gateway/test_session_env.py — 19/19 passing (12 pre-existing + 7 new)
  • tests/tools/test_mcp_context_template.py — 16/16 passing (new)

Treating the test failure as a known pre-existing issue rather than a regression introduced here.

chungty and others added 2 commits May 26, 2026 19:33
The Termux skip-rebuild test missed an update when commit
3d2f146 (fix(tui): also pass --expose-gc on the wheel-bundled launch
path) added --expose-gc to all three node-launch paths in
hermes_cli/main.py (lines 1276, 1282, 1359). The test still expected
the pre-fix argv, causing it to fail on every PR against this fork.

Drive-by fix on the MER-77 branch to get the test suite green —
unrelated to this PR's substance (MCP-headers per-request
interpolation), but blocking otherwise-green CI.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Four review comments addressed:

1. (HIGH) `_resolve_context_templates._replace` now str()-coerces the
   resolved value before returning to re.sub. A plugin-registered
   ContextVar holding a non-string (int, bool, None) would have raised
   `TypeError: expected string or bytes-like object` during substitution.
   Defensive coercion keeps the resolver robust against plugin authors
   who didn't read the str-only convention.

2. (MEDIUM) `register_session_context_var` now validates that the name
   is a valid ASCII identifier. The ${context:NAME} template regex only
   matches `[A-Za-z_][A-Za-z0-9_]*`, so names with hyphens, spaces,
   leading digits, or unicode would register successfully but silently
   fail to resolve in templates. Surfacing the invariant at registration
   time means broken names fail loudly, not silently.

3. (MEDIUM) SSE-path headers comprehension dropped the redundant
   `isinstance(value, str)` check — `_resolve_context_templates` already
   guards non-string inputs internally.

4. (MEDIUM) Legacy-HTTP-path headers comprehension: same redundant
   isinstance check removed.

Tests +5 (TestResolveContextTemplates::test_non_string_contextvar_value_coerces_to_str
plus four ValueError cases in test_register_session_context_var_rejects_invalid_inputs
covering hyphens, spaces, leading digits, and non-ASCII names).
36 tests pass; ruff clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@chungty

chungty commented May 27, 2026

Copy link
Copy Markdown
Author

Thanks @gemini-code-assist — addressed all 4 in 95d9157a2:

HIGH _replace non-string TypeError — now str()-coerces. Added a test (test_non_string_contextvar_value_coerces_to_str) that registers a ContextVar holding 42 and asserts it resolves to "42".

MEDIUM Validate ASCII identifier in register_session_context_var — now also rejects hyphens, spaces, leading digits, and non-ASCII. Failure mode at registration is much friendlier than silent template-resolve failure at request time. Added 4 new test cases covering each invalid shape.

MEDIUM × 2 Redundant isinstance(value, str) checks in SSE-path and legacy-HTTP-path headers comprehensions — dropped. _resolve_context_templates already guards non-string input internally.

36 tests pass; ruff clean. Pushed as a separate commit on top of the existing branch so the review-fix diff is reviewable as-such.

@gemini-code-assist

Copy link
Copy Markdown

The changes in 95d9157a2 effectively address the identified edge cases. The str()-coercion in _replace and the ASCII identifier validation in register_session_context_var provide the necessary defensive guards. The removal of redundant isinstance checks simplifies the resolution logic while maintaining safety. The added test coverage for non-string coercion and invalid identifiers ensures these invariants are preserved.

Addresses findings from the pr-review-toolkit code-reviewer +
test-analyzer + comment-analyzer passes.

Security
========

* **Cross-origin redirect could leak templated identity headers** —
  `_strip_auth_on_cross_origin_redirect` only stripped `Authorization`;
  the new ``${context:NAME}``-templated headers (delegated principal,
  signed assertions) are identity-carrying and follow the same threat
  model.  Two-layer fix: rename hook to
  `_strip_identity_on_cross_origin_redirect` and have it also pop every
  templated header on cross-origin redirect; AND make the request hook
  origin-aware so it does not RE-inject identity headers when the
  redirect target is on a different origin (the response strip alone
  was insufficient because the request hook fires again on the
  redirected request).
  Regression test in
  ``tests/tools/test_mcp_context_template.py::test_templated_header_dropped_on_cross_origin_redirect``
  drives a 302 from ``original.invalid`` to ``attacker.invalid`` and
  asserts both ``Authorization`` and ``X-Delegated-Principal`` are
  absent on the redirect target.

Robustness
==========

* **`_VAR_MAP` mutation without a lock** could `RuntimeError: dictionary
  changed size during iteration` if a plugin registers from a thread
  while `get_registered_var_names()` iterates.  Added
  ``_VAR_MAP_LOCK = threading.Lock()`` guarding both mutation and
  iteration.

* **SSE / legacy-HTTP paths silently froze templated headers** at
  startup with no signal to the operator.  Both branches now
  `logger.warning(...)` at server-init time, naming the templated
  headers that will be frozen, and pointing at the fix
  ("switch to Streamable HTTP" / "upgrade mcp package").  Also
  refactored both branches to merge `static_headers + resolved
  templated_headers` instead of running the resolver on the full dict
  (symmetry with the HTTP path, drops wasted work on static headers).

Test coverage gaps
==================

* **Cross-task isolation gap** — the previous test ran one request per
  task, which would miss a bug where the principal was captured at
  hook-install time. Added
  ``test_principal_mutated_between_requests_in_same_task`` interleaving
  three principals within a single task.

* **Non-string ContextVar value coercion** — previously only tested
  `int`. Parametrized over `[42, 0, None, False, True, [], {"k": "v"}]`
  and pinned the documented `str(...)` behavior so future refactors
  (e.g. "drop header when None") must update the test deliberately.

* **Registry-not-mutated-on-error invariant** — previous test only
  asserted `pytest.raises`. Parametrized + added
  ``assert bad_name not in get_registered_var_names()`` so future
  refactors that move validation below the assignment fail loudly.

Comment / docstring quality
===========================

* Removed cross-repo proper-noun ("Mercator's docker/entrypoint.sh") in
  the per-request-resolution header comment — phrased generically as
  "a deploy-time entrypoint script".
* Neutralized the public docstring example's variable name
  (`DELEGATED_PRINCIPAL_EMAIL` -> `PRINCIPAL_EMAIL`) so the example
  doesn't read as if Hermes owns Mercator's concept.
* Added class-level docstrings on `TestHasContextTemplate`,
  `TestResolveContextTemplates`, `TestSplitStaticAndTemplatedHeaders`
  (CodeRabbit docstring-coverage threshold).

Test count: 49 (24 in session_env + 25 in context_template). All pass;
ruff clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tools/mcp_tool.py (1)

651-655: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Reserve the context: namespace from config-time env interpolation.

The new contract says ${context:NAME} survives config load, but _interpolate_env_vars() still matches every ${...} token. If a process environment contains context:NAME, this placeholder gets frozen at startup instead of resolving per request.

Suggested fix
 def _interpolate_env_vars(value):
     """Recursively resolve ``${VAR}`` placeholders from ``os.environ``."""
     if isinstance(value, str):
         def _replace(m):
-            return os.environ.get(m.group(1), m.group(0))
+            name = m.group(1)
+            if name.startswith("context:"):
+                return m.group(0)
+            return os.environ.get(name, m.group(0))
         return _ENV_VAR_PATTERN.sub(_replace, value)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tools/mcp_tool.py` around lines 651 - 655, The environment interpolator
_interpolate_env_vars() is currently replacing all ${...} tokens at config-load
time, which causes `${context:NAME}` to be frozen; update the function so that
when the matched token's inner text starts with "context:" it is returned
unchanged (i.e., skip interpolation for tokens where
inner.strip().startswith("context:")). Locate the regex/match handling in
_interpolate_env_vars(), check the replacement callback (or loop) that receives
the match group, and add a conditional that returns the original full match for
context: tokens while continuing to interpolate all other environment variables.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tools/mcp_tool.py`:
- Around line 1506-1509: The SSE/frozen-transport header dict currently keeps
keys where _resolve_context_templates(v) returns an empty string, causing a
blank identity header to be sent; update the sse_headers construction (and the
analogous frozen-transport/legacy HTTP header block around the other occurrence)
to filter out empty resolved values—e.g., build the templated dict with {k: val
for k, val in ((k, _resolve_context_templates(v)) for k,v in
templated_headers.items()) if val != ""} or post-process sse_headers to pop keys
with "" values so templated_headers entries that resolve to "" are omitted.

---

Outside diff comments:
In `@tools/mcp_tool.py`:
- Around line 651-655: The environment interpolator _interpolate_env_vars() is
currently replacing all ${...} tokens at config-load time, which causes
`${context:NAME}` to be frozen; update the function so that when the matched
token's inner text starts with "context:" it is returned unchanged (i.e., skip
interpolation for tokens where inner.strip().startswith("context:")). Locate the
regex/match handling in _interpolate_env_vars(), check the replacement callback
(or loop) that receives the match group, and add a conditional that returns the
original full match for context: tokens while continuing to interpolate all
other environment variables.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: e0c00391-b897-4e14-81a5-0e01121cc1e0

📥 Commits

Reviewing files that changed from the base of the PR and between 95d9157 and be682c9.

📒 Files selected for processing (4)
  • gateway/session_context.py
  • tests/gateway/test_session_env.py
  • tests/tools/test_mcp_context_template.py
  • tools/mcp_tool.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • gateway/session_context.py
  • tests/gateway/test_session_env.py

Comment thread tools/mcp_tool.py Outdated
@chungty

chungty commented May 27, 2026

Copy link
Copy Markdown
Author

PR-review-toolkit pass complete — pushed be682c973.

Ran three review agents (code-reviewer / pr-test-analyzer / comment-analyzer) against the diff. Findings + resolutions:

Security (blocking)

  • 🔴 Cross-origin redirect could leak templated identity headers. The existing _strip_auth_on_cross_origin_redirect only stripped Authorization; the new ${context:NAME}-templated headers (delegated principal, signed assertions) are also identity-carrying and follow the same threat model. Two-layer fix: response hook also pops templated headers on cross-origin redirect, AND the request hook is now origin-aware (it does NOT re-inject identity headers when redirected away from the configured origin — the response-strip alone was insufficient because the request hook fires again on the redirected request). Regression test in test_templated_header_dropped_on_cross_origin_redirect.

Robustness

  • _VAR_MAP mutation/iteration now under _VAR_MAP_LOCK = threading.Lock() (avoids RuntimeError: dictionary changed size during iteration under concurrent plugin registration).
  • SSE / legacy-HTTP branches now logger.warning(...) at startup when templated headers are present (frozen-at-open semantics are a real footgun for identity-carrying headers on multi-user systems).
  • Both SSE + legacy paths now use the same static_headers + resolved templated_headers merge as the new HTTP path (symmetry + drops wasted resolver work on static headers).

Test gaps

  • New test_principal_mutated_between_requests_in_same_task — would catch a future regression where the principal got captured at hook-install time.
  • test_non_string_contextvar_value_coerces_to_str parametrized to cover [42, 0, None, False, True, [], {}] (was only int).
  • test_register_session_context_var_rejects_invalid_inputs_and_keeps_registry_clean parametrized + asserts registry isn't mutated on rejection (catches refactors that move validation below the assignment).

Comments / docstrings

  • Replaced cross-repo proper-noun "Mercator's docker/entrypoint.sh" with generic "a deploy-time entrypoint script" — cross-repo references rot.
  • Neutralized the public docstring example name (DELEGATED_PRINCIPAL_EMAILPRINCIPAL_EMAIL).
  • Added class-level docstrings to TestHasContextTemplate / TestResolveContextTemplates / TestSplitStaticAndTemplatedHeaders (CodeRabbit docstring-coverage threshold).

49 tests pass; ruff clean. CI re-running.

CodeRabbit MAJOR on be682c9: when a templated header
(``${context:NAME}``) resolves to an empty string at stream-open time,
SSE and legacy-HTTP transports would send the header with a blank
value for the connection's entire lifetime.  The new HTTP path
already drops empty templated headers per-request via its event hook;
this commit mirrors that discipline to the frozen-at-init transports.

Both branches now build a ``resolved_templated_headers`` dict that
includes only entries whose resolution is non-empty, then merge with
``static_headers``.  Using the walrus operator (``:=``) keeps the
condition + binding in one expression so the resolver doesn't fire
twice per header.

49 tests pass; ruff clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@chungty

chungty commented May 27, 2026

Copy link
Copy Markdown
Author

Iterating per the re-review:

CodeRabbit MAJOR — SSE + legacy paths sending empty templated headers when context is unset. Fixed in 4e5c1fa2f. Both branches now build a resolved_templated_headers dict that only includes entries with a non-empty resolved value (using the walrus operator so the resolver fires once per header), then merge with static_headers. Mirrors the HTTP path's per-request drop-on-empty behavior.

🛈 Gemini MEDIUM × 2 (mcp_tool.py:1509 + 1663, "redundant isinstance(value, str) check") — these comments look stale. The current code at those lines has already removed the isinstance check in be682c973 — see the SSE branch (now {k: _resolve_context_templates(v) for k, v in templated_headers.items()} and after this push, the drop-empty form). Gemini may be re-reading the file and re-issuing prior comments without checking the diff against its previous review. No action needed.

49 tests pass; ruff clean.

…rs helper

Addresses two findings from the pr-review-toolkit re-review of
4e5c1fa (SSE/legacy empty-drop fix):

1. **Whitespace-only resolved value would still produce a blank
   header.**  ``if resolved`` filters ``""`` but treats ``"   "`` /
   ``"\t"`` as truthy — so a paste-artifact / env-injection that put
   whitespace in the context var would silently ship ``X-Foo:    `` for
   the connection's lifetime (or in the HTTP path, on every request).
   Fix: ``.strip()`` the substituted result inside
   ``_resolve_context_templates``.  Centralized so all three transports
   inherit the same discipline; intentional inner whitespace
   (``"Bearer abc 123"``) is preserved — only outer edges stripped.

2. **No regression tests for the SSE / legacy at-init resolution
   behavior.**  Production logic was inline in each branch — hard to
   test without spinning up a real ``sse_client`` /
   ``streamablehttp_client``.  Extracted both branches' resolve+merge
   logic to ``_resolve_frozen_headers(static, templated)``: keeps
   semantics identical, makes the freeze-time empty-drop contract
   testable in isolation.

New tests (+8):

* ``TestResolveFrozenHeaders`` — pins the empty-drop / whitespace-drop /
  static-passthrough / empty-input contracts for the new helper.
* ``TestResolveContextTemplates::test_whitespace_only_resolved_value_is_stripped_to_empty``
* ``TestResolveContextTemplates::test_leading_trailing_whitespace_stripped``
* ``TestResolveContextTemplates::test_inner_whitespace_preserved``

57 tests pass (up from 49); ruff clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@chungty

chungty commented May 27, 2026

Copy link
Copy Markdown
Author

PR-review-toolkit delta-review found 2 more items on 4e5c1fa2f. Both fixed in 7c8a1686a:

Whitespace-only values still slipped through. The walrus if (resolved := ...) filters "" but not " " / "\t". A paste artifact / env-injection putting whitespace in a context var would have shipped X-Delegated-Principal: for the entire connection lifetime (or every request on the HTTP path). Centralized fix: .strip() inside _resolve_context_templates so all three transports inherit the same discipline. Intentional inner whitespace ("Bearer abc 123") is preserved — only outer edges stripped. 3 new tests pin this.

No regression test for SSE/legacy at-init resolution. Inline dict-comp in each branch was hard to test without spinning up real sse_client / streamablehttp_client. Extracted to _resolve_frozen_headers(static, templated) helper — identical semantics, unit-testable. New TestResolveFrozenHeaders class pins empty-drop, whitespace-drop, static-passthrough, empty-input contracts.

57 tests (+8); ruff clean.

@chungty
chungty merged commit 2e3aaa2 into main May 27, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant