Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
cb9e260
Rename SUPPORTED_PROTOCOL_VERSIONS to HANDSHAKE_PROTOCOL_VERSIONS; co…
maxisbey Jun 22, 2026
e0990bf
Thread per-message headers from session to transport via CallOptions/…
maxisbey Jun 22, 2026
52546fa
ClientSession: install per-request stamp at connect time; transport b…
maxisbey Jun 22, 2026
1d33743
Client gains mode= and prior_discover= policy knobs (legacy and versi…
maxisbey Jun 22, 2026
1fbd075
ClientSession.discover() with the error ladder; Client mode='auto'
maxisbey Jun 22, 2026
46c0742
modern_on_request driver + Client in-process modern path via DirectDi…
maxisbey Jun 22, 2026
dcbe6e8
Sweep: route report_progress through DispatchContext.progress; bump L…
maxisbey Jun 22, 2026
a97459d
Add lifecycle:envelope/discover/mode requirement entries and interact…
maxisbey Jun 22, 2026
52f200b
Conformance client fixture: drive Client(mode='auto') for the modern leg
maxisbey Jun 22, 2026
b6be755
Transport: pre-session 404 maps to METHOD_NOT_FOUND; POSTs never read…
maxisbey Jun 23, 2026
a82042a
discover() returns DiscoverResult; fallback ladder moves to Client; e…
maxisbey Jun 23, 2026
cdfdfd1
migration.md: drop v2-only churn entries; document ctx.report_progres…
maxisbey Jun 23, 2026
b326347
Add named protocol-version scalars and replace tuple indexing
maxisbey Jun 23, 2026
bdcdeb0
serve_one returns dict; Client accessor and re-entry guard fixes
maxisbey Jun 23, 2026
78823d4
Move to_jsonrpc_response to the HTTP entry; tighten migration.md and …
maxisbey Jun 23, 2026
f9a15e1
Client: collapse _inproc_server/_transport into a single _connect clo…
maxisbey Jun 23, 2026
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
205 changes: 132 additions & 73 deletions .github/actions/conformance/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
initialize - Connect, initialize, list tools, close
tools_call - Connect, call add_numbers(a=5, b=3), close
sse-retry - Connect, call test_reconnection, close
json-schema-ref-no-deref - Connect, list tools (no $ref deref)
request-metadata - Connect with all callbacks; client stamps _meta
http-standard-headers - Connect, call a tool (Mcp-* headers checked)
elicitation-sep1034-client-defaults - Elicitation with default accept callback
auth/client-credentials-jwt - Client credentials with private_key_jwt
auth/client-credentials-basic - Client credentials with client_secret_basic
Expand All @@ -35,16 +38,18 @@
import httpx
from pydantic import AnyUrl

from mcp import ClientSession, types
from mcp import types
from mcp.client.auth import OAuthClientProvider, TokenStorage
from mcp.client.auth.extensions.client_credentials import (
ClientCredentialsOAuthProvider,
PrivateKeyJWTOAuthProvider,
SignedJWTParameters,
)
from mcp.client.client import Client
from mcp.client.context import ClientRequestContext
from mcp.client.streamable_http import streamable_http_client
from mcp.shared.auth import AuthorizationCodeResult, OAuthClientInformationFull, OAuthClientMetadata, OAuthToken
from mcp.shared.version import MODERN_PROTOCOL_VERSIONS

# Set up logging to stderr (stdout is for conformance test output)
logging.basicConfig(
Expand All @@ -58,10 +63,24 @@
#: "2026-07-28"). The harness always sets this (when --spec-version is omitted
#: it picks per-scenario: LATEST_SPEC_VERSION for active scenarios,
#: DRAFT_PROTOCOL_VERSION for draft-only ones), so None means we were invoked
#: outside the harness. Handlers that need to take the stateless 2026 path will
#: branch on this once the SDK has one; today it is logged only.
#: outside the harness.
PROTOCOL_VERSION: str | None = os.environ.get("MCP_CONFORMANCE_PROTOCOL_VERSION")


def client_mode() -> str:
"""Pick the Client(mode=) for the harness leg.

On a modern leg (2026-07-28+) -> 'auto' so Client.discover() runs and the
_meta envelope + MCP-Protocol-Version header are stamped on every request.
On a handshake-era leg -> 'legacy' so the initialize handshake runs exactly
as before (no server/discover probe is sent against a mock that would 400 it).
Outside the harness -> 'auto' (probe + fallback).
"""
if PROTOCOL_VERSION is None or PROTOCOL_VERSION in MODERN_PROTOCOL_VERSIONS:
return "auto"
return "legacy"


# Type for async scenario handler functions
ScenarioHandler = Callable[[str], Coroutine[Any, None, None]]

Expand Down Expand Up @@ -165,52 +184,22 @@ async def handle_callback(self) -> AuthorizationCodeResult:
return result


# --- Scenario Handlers ---


@register("initialize")
async def run_initialize(server_url: str) -> None:
"""Connect, initialize, list tools, close."""
async with streamable_http_client(url=server_url) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
logger.debug("Initialized successfully")
await session.list_tools()
logger.debug("Listed tools successfully")


@register("json-schema-ref-no-deref")
async def run_json_schema_ref_no_deref(server_url: str) -> None:
"""Initialize and list tools; the scenario fails only if the client fetches a network $ref.

ClientSession never walks inputSchema or resolves $refs, so listing is enough (SEP-2106).
"""
async with streamable_http_client(url=server_url) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
await session.list_tools()
# --- Stub callbacks (declare capabilities in _meta without doing real work) ---


@register("tools_call")
async def run_tools_call(server_url: str) -> None:
"""Connect, initialize, list tools, call add_numbers(a=5, b=3), close."""
async with streamable_http_client(url=server_url) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
await session.list_tools()
result = await session.call_tool("add_numbers", {"a": 5, "b": 3})
logger.debug(f"add_numbers result: {result}")
async def stub_sampling_callback(
context: ClientRequestContext,
params: types.CreateMessageRequestParams,
) -> types.CreateMessageResult | types.ErrorData:
return types.CreateMessageResult(
role="assistant",
content=types.TextContent(type="text", text=""),
model="conformance-stub",
)


@register("sse-retry")
async def run_sse_retry(server_url: str) -> None:
"""Connect, initialize, list tools, call test_reconnection, close."""
async with streamable_http_client(url=server_url) as (read_stream, write_stream):
async with ClientSession(read_stream, write_stream) as session:
await session.initialize()
await session.list_tools()
result = await session.call_tool("test_reconnection", {})
logger.debug(f"test_reconnection result: {result}")
async def stub_list_roots_callback(context: ClientRequestContext) -> types.ListRootsResult | types.ErrorData:
return types.ListRootsResult(roots=[])


async def default_elicitation_callback(
Expand All @@ -233,17 +222,87 @@ async def default_elicitation_callback(
return types.ElicitResult(action="accept", content=content)


# --- Scenario Handlers ---


@register("initialize")
async def run_initialize(server_url: str) -> None:
"""Connect, initialize, list tools, close."""
async with Client(server_url, mode=client_mode()) as client:
logger.debug("Initialized successfully")
await client.list_tools()
logger.debug("Listed tools successfully")


@register("json-schema-ref-no-deref")
async def run_json_schema_ref_no_deref(server_url: str) -> None:
"""Initialize and list tools; the scenario fails only if the client fetches a network $ref.

The client never walks inputSchema or resolves $refs, so listing is enough (SEP-2106).
Pinned to mode='legacy': the harness reports PROTOCOL_VERSION=2026-07-28 for this
scenario but its mock server only speaks the handshake-era lifecycle and 400s a
modern-stamped tools/list. The check is lifecycle-agnostic so this is harmless.
"""
async with Client(server_url, mode="legacy") as client:
await client.list_tools()


@register("tools_call")
async def run_tools_call(server_url: str) -> None:
"""Connect, list tools, call add_numbers(a=5, b=3), close."""
async with Client(server_url, mode=client_mode()) as client:
await client.list_tools()
result = await client.call_tool("add_numbers", {"a": 5, "b": 3})
logger.debug(f"add_numbers result: {result}")


@register("sse-retry")
async def run_sse_retry(server_url: str) -> None:
"""Connect, list tools, call test_reconnection, close."""
async with Client(server_url, mode=client_mode()) as client:
await client.list_tools()
result = await client.call_tool("test_reconnection", {})
logger.debug(f"test_reconnection result: {result}")


@register("request-metadata")
async def run_request_metadata(server_url: str) -> None:
"""Connect on the modern path with every client capability declared.

The scenario inspects every request's `_meta` envelope (SEP-2575) for
protocolVersion / clientInfo / clientCapabilities, and the matching
MCP-Protocol-Version header. mode='auto' makes the SDK send
server/discover (covering the unsupported-version retry check), then adopt
and stamp the envelope on the follow-up requests.
"""
async with Client(
server_url,
mode=client_mode(),
sampling_callback=stub_sampling_callback,
list_roots_callback=stub_list_roots_callback,
elicitation_callback=default_elicitation_callback,
) as client:
await client.list_tools()
result = await client.call_tool("add_numbers", {"a": 5, "b": 3})
logger.debug(f"add_numbers result: {result}")


@register("http-standard-headers")
async def run_http_standard_headers(server_url: str) -> None:
"""Connect on the modern path so Mcp-Method / Mcp-Name / MCP-Protocol-Version are sent (SEP-2243)."""
async with Client(server_url, mode=client_mode()) as client:
await client.list_tools()
result = await client.call_tool("add_numbers", {"a": 5, "b": 3})
logger.debug(f"add_numbers result: {result}")


@register("elicitation-sep1034-client-defaults")
async def run_elicitation_defaults(server_url: str) -> None:
"""Connect with elicitation callback that applies schema defaults."""
async with streamable_http_client(url=server_url) as (read_stream, write_stream):
async with ClientSession(
read_stream, write_stream, elicitation_callback=default_elicitation_callback
) as session:
await session.initialize()
await session.list_tools()
result = await session.call_tool("test_client_elicitation_defaults", {})
logger.debug(f"test_client_elicitation_defaults result: {result}")
async with Client(server_url, mode=client_mode(), elicitation_callback=default_elicitation_callback) as client:
await client.list_tools()
result = await client.call_tool("test_client_elicitation_defaults", {})
logger.debug(f"test_client_elicitation_defaults result: {result}")


@register("auth/client-credentials-jwt")
Expand Down Expand Up @@ -343,25 +402,22 @@ async def run_auth_code_client(server_url: str) -> None:

async def _run_auth_session(server_url: str, oauth_auth: OAuthClientProvider) -> None:
"""Common session logic for all OAuth flows."""
client = httpx.AsyncClient(auth=oauth_auth, timeout=30.0)
async with streamable_http_client(url=server_url, http_client=client) as (read_stream, write_stream):
async with ClientSession(
read_stream, write_stream, elicitation_callback=default_elicitation_callback
) as session:
await session.initialize()
logger.debug("Initialized successfully")

tools_result = await session.list_tools()
logger.debug(f"Listed tools: {[t.name for t in tools_result.tools]}")

# Call the first available tool (different tests have different tools)
if tools_result.tools:
tool_name = tools_result.tools[0].name
try:
result = await session.call_tool(tool_name, {})
logger.debug(f"Called {tool_name}, result: {result}")
except Exception as e:
logger.debug(f"Tool call result/error: {e}")
http_client = httpx.AsyncClient(auth=oauth_auth, timeout=30.0)
transport = streamable_http_client(url=server_url, http_client=http_client)
async with Client(transport, mode=client_mode(), elicitation_callback=default_elicitation_callback) as client:
logger.debug("Initialized successfully")

tools_result = await client.list_tools()
logger.debug(f"Listed tools: {[t.name for t in tools_result.tools]}")

# Call the first available tool (different tests have different tools)
if tools_result.tools:
tool_name = tools_result.tools[0].name
try:
result = await client.call_tool(tool_name, {})
logger.debug(f"Called {tool_name}, result: {result}")
except Exception as e:
logger.debug(f"Tool call result/error: {e}")

logger.debug("Connection closed successfully")

Expand All @@ -374,7 +430,7 @@ def main() -> None:

server_url = sys.argv[1]
scenario = os.environ.get("MCP_CONFORMANCE_SCENARIO")
logger.debug(f"Conformance protocol version: {PROTOCOL_VERSION!r}")
logger.debug(f"Conformance protocol version: {PROTOCOL_VERSION!r} -> mode={client_mode()!r}")

if scenario:
logger.debug(f"Running explicit scenario '{scenario}' against {server_url}")
Expand All @@ -384,6 +440,9 @@ def main() -> None:
elif scenario.startswith("auth/"):
asyncio.run(run_auth_code_client(server_url))
else:
# Unhandled scenarios:
# - sep-2322-client-request-state (SEP-2322 / S6: MRTR client loop)
# - http-custom-headers, http-invalid-tool-headers (SEP-2243 / S8: Mcp-Param-* headers)
print(f"Unknown scenario: {scenario}", file=sys.stderr)
sys.exit(1)
else:
Expand Down
25 changes: 1 addition & 24 deletions .github/actions/conformance/expected-failures.2026-07-28.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,36 +21,13 @@
# milestone.

client:
# --- No stateless client path on main yet ---
# client.py drives the 2025 stateful lifecycle (initialize handshake +
# session). The 2026-mode mock server is stateless, so the call sequence
# never reaches the assertion. Unblocks when client.py's is_modern_protocol()
# branch takes the per-request _meta path.
- tools_call

# --- Auth scenarios cut short by the 2026 connection lifecycle ---
# The auth fixture flow drives the 2025 stateful lifecycle; the 2026-mode
# mock rejects the MCP POST before the scope-escalation behaviour these
# scenarios measure, so no authorization requests are observed. Unblocks
# when client.py's auth flow speaks the 2026 per-request lifecycle.
- auth/scope-step-up
- auth/scope-retry-limit

# --- Same gaps as the 2025 baseline (fail identically when forced to 2026-07-28) ---
# SEP-2575 (request metadata / _meta envelope): client does not populate the
# _meta envelope or the MCP-Protocol-Version header semantics yet.
- request-metadata
# SEP-2322 (multi-round-trip requests): client does not echo requestState /
# handle IncompleteResult yet.
- sep-2322-client-request-state
# SEP-2243 (HTTP standardization): no fixture handler / client header support yet.
# SEP-2243 (HTTP standardization): no fixture handler / client Mcp-Param-* support yet.
- http-custom-headers
- http-invalid-tool-headers
# SEP-2352 (authorization server migration): the client re-registers and does not reuse the old
# AS credentials, but the 2026-mode mock rejects the MCP POST before the migration 401 fires
# (client.py drives the 2025 stateful lifecycle), so the re-register check is never reached.
# Unblocks with the 2026 stateless client lifecycle.
- auth/authorization-server-migration
# auth/enterprise-managed-authorization (SEP-990) is in the 2025 baseline but
# NOT here: the harness skips it as inapplicable at --spec-version 2026-07-28
# (it is an extension scenario not carried into the 2026 wire), so it is
Expand Down
10 changes: 1 addition & 9 deletions .github/actions/conformance/expected-failures.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,12 @@

client:
# --- Draft-spec scenarios (in `--suite draft`, also part of `--suite all`) ---
# SEP-2575 (request metadata / _meta envelope): client does not populate the
# _meta envelope or the MCP-Protocol-Version header semantics yet.
- request-metadata
# SEP-2322 (multi-round-trip requests): client does not echo requestState /
# handle IncompleteResult yet.
- sep-2322-client-request-state
# SEP-2243 (HTTP standardization): no fixture handler / client header support yet.
# SEP-2243 (HTTP standardization): no fixture handler / client Mcp-Param-* support yet.
- http-custom-headers
- http-invalid-tool-headers
# SEP-2352 (authorization server migration): the client re-registers and does not reuse the old
# AS credentials, but this 2026-introduced scenario runs at 2026-07-28, where client.py's 2025
# stateful lifecycle is rejected (400 on initialize) before the migration 401 fires, so the
# re-register check is never reached. Unblocks with the 2026 stateless client lifecycle.
- auth/authorization-server-migration

# --- Pre-existing scenarios that fail on checks added after conformance 0.1.15 ---
# SEP-990 (enterprise-managed authorization extension): no fixture handler /
Expand Down
Loading
Loading