-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathtest_unified_mcp_a2a.py
More file actions
169 lines (131 loc) · 6.21 KB
/
Copy pathtest_unified_mcp_a2a.py
File metadata and controls
169 lines (131 loc) · 6.21 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
"""Tests for ``transport="both"`` — unified MCP+A2A binary.
Both transports run on a single Starlette parent app via ``Mount``:
``Mount("/mcp", mcp_app)`` plus ``Mount("/", a2a_app)``. Tests use
Starlette's :class:`TestClient` (with the lifespan context entered)
so FastMCP's session manager initializes properly before requests.
Coverage:
* ``/.well-known/agent.json`` reaches the A2A app.
* POST ``/`` reaches A2A (its message endpoint).
* GET ``/mcp`` and ``/mcp/`` both route to FastMCP (Mount issues a
307 redirect for the bare prefix; the inner endpoint matches
``/mcp/`` after Starlette's ``root_path`` accounting).
* Unknown paths (``/random``) fall through to A2A.
* ``transport="bogus"`` on the public ``serve()`` raises ValueError
listing ``"both"`` as a valid option.
"""
from __future__ import annotations
import pytest
starlette = pytest.importorskip("starlette")
from starlette.testclient import TestClient
from adcp.server import ADCPHandler, ToolContext
from adcp.server.responses import capabilities_response
from adcp.server.serve import _build_mcp_and_a2a_app
class _UnifiedTestHandler(ADCPHandler[ToolContext]):
"""Minimal handler that surfaces a ``get_adcp_capabilities``
response on both transports."""
async def get_adcp_capabilities(self, params, context=None):
return capabilities_response(["media_buy"])
@pytest.fixture
def unified_client():
"""TestClient with lifespan context entered.
``with TestClient(app):`` runs the parent's lifespan (and through
it, FastMCP's session-manager init) — without it, requests to
the MCP path fail with ``Task group is not initialized``."""
app = _build_mcp_and_a2a_app(
_UnifiedTestHandler(),
name="unified-test",
port=3001,
host="127.0.0.1",
instructions=None,
test_controller=None,
)
with TestClient(app) as client:
yield client
# ----- A2A path ----------------------------------------------------------
def test_a2a_agent_card_served_on_root_path(unified_client) -> None:
"""``/.well-known/agent.json`` (0.3 alias) resolves to a 200 agent-card
response. The route must be registered — a 404 means the alias was
stripped from create_a2a_server's route list."""
resp = unified_client.get("/.well-known/agent.json")
assert resp.status_code == 200, (
f"/.well-known/agent.json returned {resp.status_code}; "
"expected 200 — the 0.3 alias route is missing from create_a2a_server"
)
def test_a2a_root_path_routed_to_a2a_app(unified_client) -> None:
"""A POST to ``/`` (A2A's message endpoint) reaches A2A —
not MCP, which would have no route there."""
resp = unified_client.post("/", json={})
assert resp.status_code != 404, (
"POST / should reach the A2A app, but got 404 — likely the " "dispatcher misrouted to MCP."
)
def test_unknown_path_routes_to_a2a_default(unified_client) -> None:
"""Paths that aren't ``/mcp...`` fall through to A2A (the
catch-all Mount). A2A returns 404 for unknown routes — the
test confirms the dispatch reached A2A cleanly."""
resp = unified_client.get("/some-random-path")
assert resp.status_code in (404, 405)
# ----- MCP path ----------------------------------------------------------
def test_mcp_path_routed_to_mcp_app(unified_client) -> None:
"""A GET to ``/mcp`` resolves through the dispatcher to the
FastMCP app. Starlette Mount issues a 307 redirect from the
bare prefix to the trailing-slash form; TestClient follows by
default. FastMCP then returns its own status (typically 421
"Misdirected Request" on a GET without proper MCP framing).
A 404 here would mean the dispatcher misrouted to A2A."""
resp = unified_client.get("/mcp")
assert resp.status_code != 404, (
f"GET /mcp should reach the FastMCP app, got {resp.status_code} — "
"the dispatcher likely misrouted to A2A."
)
def test_mcp_trailing_slash_resolves(unified_client) -> None:
"""``/mcp/`` resolves to the same FastMCP endpoint as the
redirected ``/mcp`` form. Both should land on the inner
streamable-http route after Mount + ``root_path`` accounting."""
resp_no_slash = unified_client.get("/mcp")
resp_slash = unified_client.get("/mcp/")
# Both reach the same FastMCP inner endpoint; the response
# status reflects FastMCP's view (typically 421 on GET) and
# MUST be the same for both forms.
assert resp_no_slash.status_code == resp_slash.status_code, (
f"Trailing slash mismatched: /mcp={resp_no_slash.status_code}, "
f"/mcp/={resp_slash.status_code}"
)
def test_mcp_subpath_routed_to_mcp_app(unified_client) -> None:
"""Paths under ``/mcp/`` (e.g., ``/mcp/anything``) also route
to the MCP app via Mount's prefix matching. FastMCP returns
its own 404/405 for unknown subpaths inside its app; the
test confirms the dispatcher reached MCP rather than A2A."""
resp = unified_client.get("/mcp/anything")
assert resp.status_code in (
404,
405,
406,
400,
421,
), f"Unexpected status from /mcp subpath: {resp.status_code}"
# ----- Construction sanity ----------------------------------------------
def test_unified_app_builds_end_to_end() -> None:
"""The unified-app builder constructs both inner apps from the
same handler instance and returns a working ASGI callable.
Adopters writing context_factory or middleware wire one place
and reach both transports."""
handler = _UnifiedTestHandler()
app = _build_mcp_and_a2a_app(
handler,
name="unified-identity",
port=3001,
host="127.0.0.1",
instructions=None,
test_controller=None,
)
assert app is not None
assert callable(app)
# ----- Public surface: serve() validation -------------------------------
def test_serve_rejects_unknown_transport_lists_both() -> None:
"""``serve(transport=...)`` validates the transport. An invalid
value raises ValueError whose message lists ``"both"`` alongside
the existing options — confirming the new option made it onto
the public surface."""
from adcp.server import serve
with pytest.raises(ValueError, match="both"):
serve(_UnifiedTestHandler(), transport="bogus")