Skip to content

bug(client): agent_uri Pydantic validator strips trailing slash, breaks connections to FastMCP servers mounted at /mcp/ #581

@bokelley

Description

@bokelley

Motivation

adcp/types/core.py:48 — the Pydantic validator validate_agent_uri calls v.rstrip("/") on the entire URI before the AgentConfig is constructed. The comment says "Remove trailing slash for consistency."

This breaks the CLI (and any caller of AgentConfig) against any MCP server whose streamable_http endpoint is mounted at a slash-terminated path. FastMCP — the most common Python MCP server framework — mounts at /mcp/ by default; FastAPI/Starlette Mount(path="/mcp", app=mcp_app) similarly serves only /mcp/ and returns 404 to /mcp.

Repro (reproducible against any FastMCP-served agent)

# Server: any FastMCP agent with default mount, e.g. the FastAPI mount pattern
#   app.mount("/mcp", mcp_streamable_http_app)
adcp http://my-agent.example.com:8000/mcp/ --auth <token> list_tools

Expected: connects, lists tools.

Actual:

Failed to connect to MCP agent  using streamable_http transport.
Tried URLs: http://my-agent.example.com:8000/mcp     ← trailing slash dropped
FAILED
Error: Failed to list tools: Failed to connect: Session terminated

Server logs confirm POST /mcp HTTP/1.1" 404.

Cause

adcp/types/core.py:38-48:

@classmethod
def validate_agent_uri(cls, v: str) -> str:
    ...
    # Remove trailing slash for consistency
    return v.rstrip("/")

Subsequent code paths consume the stripped form:

  • protocols/mcp.py:354-359 — builds urls_to_try from self.agent_config.agent_uri (already stripped).
  • protocols/mcp.py:386 — passes that URL into streamablehttp_client(...).

The urls_to_try fallback list adds <base>/mcp when the URI doesn't already end with /mcp (after rstrip), but never re-adds the slash form that the user originally provided.

Why this matters

  • The user's explicit input is being normalized away — they wrote /mcp/, the library decided /mcp was "more consistent." That's information loss in a place where path semantics matter.
  • The MCP spec (2024-11-05) is silent on whether servers must accept trailing-slash variants. In practice many do not — including FastMCP itself, which is what adcp.serve() uses internally. So this client cannot reliably connect to servers built with the project's own server-side helpers.
  • The workaround (use fastmcp.client.Client + StreamableHttpTransport directly) defeats the purpose of having a CLI / SDK abstraction for the protocol.

Proposed fix

Two changes, ~10 LOC + a unit test:

1. src/adcp/types/core.py — preserve the user's URI

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

(If "consistency" is genuinely desired for non-MCP-path URLs — e.g., https://agent.example.com/ vs. https://agent.example.com — the right place for that normalization is when those URIs are used as a base for joining other paths, not in the validator that captures user intent.)

2. src/adcp/protocols/mcp.py — make fallbacks cover both forms

-        urls_to_try = [self.agent_config.agent_uri]
-
-        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")
+        uri = self.agent_config.agent_uri
+        urls_to_try = [uri]
+        # MCP servers vary on whether they mount at "/mcp" or "/mcp/".
+        # Try the alternate form as a fallback regardless of which the user supplied.
+        if uri.endswith("/"):
+            urls_to_try.append(uri.rstrip("/"))
+        elif uri.rstrip("/").endswith("/mcp"):
+            urls_to_try.append(f"{uri}/")
+        else:
+            base = uri.rstrip("/")
+            urls_to_try.extend([f"{base}/mcp", f"{base}/mcp/"])

3. Test

Add to the existing AgentConfig validator tests:

def test_agent_uri_preserves_trailing_slash():
    cfg = AgentConfig(id="x", agent_uri="https://example.com/mcp/", protocol="mcp")
    assert cfg.agent_uri == "https://example.com/mcp/"

Add to the mcp.py connect-fallback tests a parametrized case for slash-terminated input.

Workaround until merged

from fastmcp.client import Client
from fastmcp.client.transports import StreamableHttpTransport

t = StreamableHttpTransport(
    url="http://my-agent.example.com:8000/mcp/",
    headers={"x-adcp-auth": "<token>"},
)
async with Client(t) as c:
    print(await c.list_tools())

Environment

  • adcp 4.4.0 (PyPI)
  • Spec target: 3.0.5
  • Server: FastMCP via app.mount("/mcp", mcp_streamable_http_app) (Anthropic Python MCP SDK pattern)
  • Client: macOS, uvx adcp

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions