Skip to content
Draft
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
18 changes: 11 additions & 7 deletions src/adcp/protocols/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -350,13 +350,17 @@ async def _get_session(self) -> ClientSession:
else:
headers[self.agent_config.auth_header] = self.agent_config.auth_token

# Try the user's exact URL first
urls_to_try = [self.agent_config.agent_uri]

# If URL doesn't end with /mcp, also try with /mcp suffix
if not self.agent_config.agent_uri.rstrip("/").endswith("/mcp"):
base_uri = self.agent_config.agent_uri.rstrip("/")
urls_to_try.append(f"{base_uri}/mcp")
# Try the user's exact URL first, then the alternate slash form, then
# /mcp discovery paths. MCP servers disagree on whether their endpoint
# is at /mcp or /mcp/ — try both rather than silently normalizing.
uri = self.agent_config.agent_uri
base = uri.rstrip("/")
urls_to_try = [uri]
if base.endswith("/mcp"):
# User pointed at the MCP endpoint; also try the other slash form.
urls_to_try.append(f"{base}/" if not uri.endswith("/") else base)
else:
urls_to_try.extend([f"{base}/mcp", f"{base}/mcp/"])

# RFC 9421 auto-signing: if ADCPClient installed a signing request
# hook, wire it into streamable_http via a custom httpx client
Expand Down
4 changes: 3 additions & 1 deletion src/adcp/signing/capability_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,9 @@ def build_capability_cache_key(
Format matches the JS SDK exactly:
``agent_uri[::sha256(auth_token)[:16]][::sig=signer_fingerprint]``
"""
parts = [agent_uri]
# Normalize trailing slash so http://host/mcp and http://host/mcp/ resolve
# to the same cache entry — the validator no longer strips it.
parts = [agent_uri.rstrip("/")]
if auth_token:
token_digest = hashlib.sha256(auth_token.encode("utf-8")).hexdigest()[:16]
parts.append(f"::{token_digest}")
Expand Down
3 changes: 1 addition & 2 deletions src/adcp/types/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,7 @@ def validate_agent_uri(cls, v: str) -> str:
"Example: https://agent.example.com"
)

# Remove trailing slash for consistency
return v.rstrip("/")
return v

@field_validator("timeout")
@classmethod
Expand Down
14 changes: 14 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,20 @@ def test_agent_config_creation():
assert config.protocol == Protocol.A2A


@pytest.mark.parametrize(
"input_uri,expected_uri",
[
("https://example.com/mcp/", "https://example.com/mcp/"),
("https://example.com/mcp", "https://example.com/mcp"),
("https://example.com", "https://example.com"),
("https://example.com/", "https://example.com/"),
],
)
def test_agent_uri_preserves_user_supplied_form(input_uri: str, expected_uri: str) -> None:
cfg = AgentConfig(id="x", agent_uri=input_uri, protocol=Protocol.MCP)
assert cfg.agent_uri == expected_uri


def test_client_creation():
"""Test creating ADCP client."""
config = AgentConfig(
Expand Down
5 changes: 2 additions & 3 deletions tests/test_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,7 @@ def test_mcp_config_structure():
"""Test TEST_AGENT_MCP_CONFIG has correct structure."""
assert TEST_AGENT_MCP_CONFIG.id == "test-agent-mcp"
assert TEST_AGENT_MCP_CONFIG.protocol == Protocol.MCP
# AgentConfig validator strips trailing slashes for consistency
assert TEST_AGENT_MCP_CONFIG.agent_uri == "https://test-agent.adcontextprotocol.org/mcp"
assert TEST_AGENT_MCP_CONFIG.agent_uri == "https://test-agent.adcontextprotocol.org/mcp/"
assert TEST_AGENT_MCP_CONFIG.auth_token is not None


Expand Down Expand Up @@ -195,7 +194,7 @@ def test_mcp_no_auth_config_structure():
"""Test TEST_AGENT_MCP_NO_AUTH_CONFIG has correct structure."""
assert TEST_AGENT_MCP_NO_AUTH_CONFIG.id == "test-agent-mcp-no-auth"
assert TEST_AGENT_MCP_NO_AUTH_CONFIG.protocol == Protocol.MCP
assert TEST_AGENT_MCP_NO_AUTH_CONFIG.agent_uri == "https://test-agent.adcontextprotocol.org/mcp"
assert TEST_AGENT_MCP_NO_AUTH_CONFIG.agent_uri == "https://test-agent.adcontextprotocol.org/mcp/"
assert TEST_AGENT_MCP_NO_AUTH_CONFIG.auth_token is None


Expand Down
60 changes: 60 additions & 0 deletions tests/test_protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -1832,6 +1832,66 @@ async def test_cleanup_handles_exception_group_with_cancelled_error(self, mcp_co
assert adapter._session is None


class TestMCPUrlFallback:
"""Tests for the MCP URL fallback list built in _get_session."""

@pytest.mark.parametrize(
"agent_uri,expected_urls",
[
# Slash-terminated /mcp/ — also try no-slash form
(
"https://host/mcp/",
["https://host/mcp/", "https://host/mcp"],
),
# No-slash /mcp — also try slash form
(
"https://host/mcp",
["https://host/mcp", "https://host/mcp/"],
),
# Bare host — discovery: try both /mcp and /mcp/
(
"https://host",
["https://host", "https://host/mcp", "https://host/mcp/"],
),
# Host with trailing slash — discovery: try both /mcp and /mcp/
(
"https://host/",
["https://host/", "https://host/mcp", "https://host/mcp/"],
),
],
)
@pytest.mark.asyncio
async def test_urls_to_try(self, agent_uri: str, expected_urls: list[str]) -> None:
from unittest.mock import patch

from adcp.protocols.mcp import MCPAdapter
from adcp.types.core import AgentConfig, Protocol

cfg = AgentConfig(id="t", agent_uri=agent_uri, protocol=Protocol.MCP)
adapter = MCPAdapter(cfg)

real_urls: list[str] = []

class _FakeCM:
async def __aenter__(self) -> None:
raise ConnectionError("abort")

async def __aexit__(self, *_: object) -> None:
pass

def capture_url(url: str, **_kw: object) -> _FakeCM:
real_urls.append(url)
return _FakeCM()

with patch("adcp.protocols.mcp.streamablehttp_client", side_effect=capture_url):
try:
await adapter._get_session()
except Exception:
pass

assert real_urls == expected_urls


class TestFromMcpClientFactory:
"""Tests for ADCPClient.from_mcp_client() factory method."""

Expand Down
Loading