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
129 changes: 128 additions & 1 deletion tests/tools/test_mcp_context_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
register_session_context_var,
)
from tools.mcp_tool import (
MCPServerTask,
_apply_resolved_headers,
_has_context_template,
_resolve_context_templates,
_resolve_frozen_headers,
Expand Down Expand Up @@ -250,7 +252,132 @@ def test_non_string_values_treated_as_static(self):


# ---------------------------------------------------------------------------
# Integration test: the actual event-hook injection pattern using httpx
# Z2O-1694: caller-side resolution + instance-attr bridge to the MCP loop
# ---------------------------------------------------------------------------
#
# The real MCP request runs on a dedicated background event loop (and a task
# spawned by the SDK's post_writer), so resolving ``${context:}`` inside the
# httpx request hook *there* always saw an empty context — the per-turn
# session vars are set on the caller's thread, never the MCP loop's. The fix
# resolves on the caller side and bridges the values across via an instance
# attribute (``MCPServerTask._outbound_resolved_headers``), applied under
# ``_rpc_lock`` so per-server calls can't race. These tests pin that contract.

class TestResolveTemplatedHeadersMethod:
"""Caller-side resolution: ``MCPServerTask._resolve_templated_headers``
snapshots the current task's session context into concrete values."""

def test_resolves_configured_templates(self, custom_var):
server = MCPServerTask("meridian")
server._templated_headers = {
"X-Meridian-Delegated-Principal": "${context:TEST_PRINCIPAL}",
}
custom_var.set("thomas@verdigris.co")
assert server._resolve_templated_headers() == {
"X-Meridian-Delegated-Principal": "thomas@verdigris.co",
}

def test_unset_value_omitted_from_dict(self, custom_var):
server = MCPServerTask("meridian")
server._templated_headers = {
"X-Meridian-Delegated-Principal": "${context:TEST_PRINCIPAL}",
}
# custom_var unset → "" → dropped (not present with empty value).
assert server._resolve_templated_headers() == {}

def test_no_templates_returns_empty(self):
server = MCPServerTask("meridian")
assert server._resolve_templated_headers() == {}


class TestApplyResolvedHeaders:
"""Loop-side application: ``_apply_resolved_headers`` stamps the already-
resolved values onto the outbound request (no context lookup here)."""

def test_injects_when_same_origin(self):
req = httpx.Request("POST", "http://meridian.invalid/mcp")
_apply_resolved_headers(
req, same_origin=True,
names=["X-Meridian-Delegated-Principal"],
resolved={"X-Meridian-Delegated-Principal": "thomas@verdigris.co"},
)
assert req.headers["X-Meridian-Delegated-Principal"] == "thomas@verdigris.co"

def test_drops_when_value_missing(self):
req = httpx.Request("POST", "http://meridian.invalid/mcp")
req.headers["X-Meridian-Delegated-Principal"] = "stale@example.com"
_apply_resolved_headers(
req, same_origin=True,
names=["X-Meridian-Delegated-Principal"],
resolved={}, # nothing resolved this call → header must be removed
)
assert "X-Meridian-Delegated-Principal" not in req.headers

def test_strips_on_cross_origin(self):
req = httpx.Request("POST", "http://attacker.invalid/harvest")
req.headers["X-Meridian-Delegated-Principal"] = "thomas@verdigris.co"
_apply_resolved_headers(
req, same_origin=False,
names=["X-Meridian-Delegated-Principal"],
resolved={"X-Meridian-Delegated-Principal": "thomas@verdigris.co"},
)
assert "X-Meridian-Delegated-Principal" not in req.headers


def test_resolved_headers_survive_lost_caller_context(custom_var):
"""The crux of Z2O-1694: resolve while the per-turn var is set, then
apply *after* that context is gone (simulating the MCP background loop,
whose task never saw the var). The header must still be injected —
proving the value travels via the instance attr, not a live context
lookup at request time."""
server = MCPServerTask("meridian")
server._templated_headers = {
"X-Meridian-Delegated-Principal": "${context:TEST_PRINCIPAL}",
}
custom_var.set("thomas@verdigris.co")
resolved = server._resolve_templated_headers() # caller thread/context
server._outbound_resolved_headers = resolved # bridged onto instance
custom_var.set(_UNSET) # caller context gone

req = httpx.Request("POST", "http://meridian.invalid/mcp")
_apply_resolved_headers(
req, same_origin=True,
names=server._templated_headers,
resolved=server._outbound_resolved_headers,
)
assert req.headers["X-Meridian-Delegated-Principal"] == "thomas@verdigris.co"


@pytest.mark.asyncio
async def test_rpc_scopes_headers_to_call_and_clears():
"""``_rpc`` exposes the resolved headers only for the duration of the call
and clears them after — so a later system RPC (keepalive) passing no
headers can't leak the prior user call's delegated principal (Z2O-1694)."""
server = MCPServerTask("meridian")
assert server._outbound_resolved_headers == {}

async with server._rpc({"X-Meridian-Delegated-Principal": "thomas@verdigris.co"}):
assert server._outbound_resolved_headers == {
"X-Meridian-Delegated-Principal": "thomas@verdigris.co",
}
assert server._outbound_resolved_headers == {} # cleared after the call

# A system RPC (keepalive/discovery) passes no headers → slot stays empty.
async with server._rpc():
assert server._outbound_resolved_headers == {}
assert server._outbound_resolved_headers == {}


# ---------------------------------------------------------------------------
# Integration tests: httpx request-event-hook mechanics
# ---------------------------------------------------------------------------
# NOTE: these exercise the httpx event-hook *mechanism* in the test's own
# event loop, where the calling task's context IS visible. The real MCP
# request runs on a dedicated background loop whose task can't see the
# caller's context (Z2O-1694), so the production hook no longer resolves
# ``${context:}`` here — it applies values resolved caller-side (see the
# TestResolveTemplatedHeadersMethod / _apply_resolved_headers tests above).
# These remain valid as httpx-behavior documentation.
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
Expand Down
12 changes: 10 additions & 2 deletions tests/tools/test_mcp_structured_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import asyncio
import json
from types import SimpleNamespace
from types import MethodType, SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
Expand Down Expand Up @@ -56,7 +56,15 @@ def _patch_mcp_server():
# `_rpc_lock` is acquired by _make_tool_handler's call path (mcp_tool.py
# ~L2008) to serialize JSON-RPC against the server — build it inside the
# fresh loop that _fake_run_on_mcp_loop spins up, not at fixture import.
fake_server = SimpleNamespace(session=fake_session, _rpc_lock=None)
# The tool handler now resolves ${context:} headers caller-side and applies
# them via server._rpc() under the lock (Z2O-1694). Give the fake those
# members: an empty templated-header resolver and the real _rpc CM (which
# only needs _rpc_lock + _outbound_resolved_headers, both present here).
fake_server = SimpleNamespace(
session=fake_session, _rpc_lock=None, _outbound_resolved_headers={},
)
fake_server._resolve_templated_headers = lambda: {}
fake_server._rpc = MethodType(mcp_tool.MCPServerTask._rpc, fake_server)
with patch.dict(mcp_tool._servers, {"test-server": fake_server}), \
patch("tools.mcp_tool._run_on_mcp_loop", side_effect=_fake_run_on_mcp_loop):
yield fake_session
Expand Down
Loading
Loading