Skip to content

Commit 1d5d907

Browse files
bokelleyclaude
andauthored
feat(server): transport="both" — host MCP and A2A on a single binary (#354) (#370)
JS sellers host both transports on one Express/Hono app; this is the Python parity. ``serve(transport="both")`` builds both apps from the same handler and routes by URL path on a single Starlette parent: ``/mcp`` and ``/mcp/...`` go to FastMCP's streamable-http app, everything else (``/``, ``/.well-known/agent.json``, A2A push endpoints) goes to the a2a-sdk app. Adopters writing context_factory, middleware, AccountStore, BuyerAgentRegistry, etc., wire one place and reach both transports automatically. * New ``"both"`` value on ``serve(transport=)``. * Internal ``_build_mcp_and_a2a_app`` constructs the dispatcher; ``_serve_mcp_and_a2a`` drives uvicorn against it. * Lifespan composition: FastMCP's session manager and a2a-sdk's stores both initialize via a composed asynccontextmanager on the parent Starlette. Without this, requests to the MCP path fail with ``Task group is not initialized``. * 8 tests via Starlette TestClient. Closes #354. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5f1f3a0 commit 1d5d907

2 files changed

Lines changed: 391 additions & 3 deletions

File tree

src/adcp/server/serve.py

Lines changed: 219 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -418,13 +418,20 @@ def serve(
418418
(from ``adcp_server()``). Builders are auto-converted via ``build_handler()``.
419419
420420
This is the simplest way to run an ADCP agent. Set ``transport="a2a"``
421-
to serve over the A2A protocol instead of MCP.
421+
to serve over the A2A protocol instead of MCP, or ``transport="both"``
422+
to serve both protocols on the same port (MCP at ``/mcp``, A2A at
423+
``/``).
422424
423425
Args:
424426
handler: An ADCPHandler subclass instance with your tool implementations.
425427
name: Server name shown to clients / in the A2A agent card.
426428
port: Port to listen on. Defaults to PORT env var, then 3001.
427-
transport: ``"streamable-http"`` (default, MCP) or ``"a2a"``.
429+
transport: ``"streamable-http"`` (default, MCP), ``"a2a"``, or
430+
``"both"`` (one Starlette binary serving MCP at ``/mcp``
431+
and A2A at ``/``). Use ``"both"`` when you want adopters
432+
on either protocol to reach the same handler with shared
433+
``context_factory`` + ``middleware`` wiring — JS hosts both
434+
on one Express app; this is the Python parity.
428435
instructions: Optional system instructions for the agent (MCP only).
429436
test_controller: Optional TestControllerStore instance for storyboard testing.
430437
context_factory: Optional factory that builds a :class:`ToolContext`
@@ -551,8 +558,25 @@ async def force_account_status(self, account_id, status):
551558
max_request_size=max_request_size,
552559
streaming_responses=streaming_responses,
553560
)
561+
elif transport == "both":
562+
_serve_mcp_and_a2a(
563+
handler,
564+
name=name,
565+
port=port,
566+
host=host,
567+
instructions=instructions,
568+
test_controller=test_controller,
569+
context_factory=context_factory,
570+
task_store=task_store,
571+
push_config_store=push_config_store,
572+
middleware=middleware,
573+
message_parser=message_parser,
574+
advertise_all=advertise_all,
575+
max_request_size=max_request_size,
576+
streaming_responses=streaming_responses,
577+
)
554578
else:
555-
valid = ", ".join(sorted(("a2a", "streamable-http", "sse", "stdio")))
579+
valid = ", ".join(sorted(("a2a", "both", "streamable-http", "sse", "stdio")))
556580
raise ValueError(f"Unknown transport {transport!r}. Valid: {valid}")
557581

558582

@@ -845,6 +869,198 @@ async def _serve() -> None:
845869
sock.close()
846870

847871

872+
def _build_mcp_and_a2a_app(
873+
handler: ADCPHandler[Any],
874+
*,
875+
name: str,
876+
port: int,
877+
host: str,
878+
instructions: str | None,
879+
test_controller: TestControllerStore | None,
880+
context_factory: ContextFactory | None = None,
881+
task_store: TaskStore | None = None,
882+
push_config_store: PushNotificationConfigStore | None = None,
883+
middleware: Sequence[SkillMiddleware] | None = None,
884+
message_parser: MessageParser | None = None,
885+
advertise_all: bool = False,
886+
max_request_size: int | None = None,
887+
streaming_responses: bool = False,
888+
) -> Any:
889+
"""Build the unified MCP+A2A ASGI app without starting a server.
890+
891+
Split out from :func:`_serve_mcp_and_a2a` so tests can route
892+
requests through Starlette's ``TestClient`` against the same
893+
dispatcher production uses.
894+
895+
Returns the size-limit-wrapped ASGI app. Wire to uvicorn /
896+
Starlette / your test harness as you would any other ASGI app.
897+
"""
898+
import contextlib
899+
900+
from starlette.applications import Starlette
901+
from starlette.types import ASGIApp, Receive, Scope, Send
902+
903+
from adcp.server.a2a_server import create_a2a_server
904+
905+
# MCP app — FastMCP registers its streamable-http endpoint at
906+
# ``streamable_http_path`` (default ``/mcp``). The dispatcher
907+
# below preserves the full request path when routing to MCP, so
908+
# the inner Starlette router matches ``/mcp`` directly without
909+
# needing a Mount-based prefix strip.
910+
mcp = create_mcp_server(
911+
handler,
912+
name=name,
913+
port=port,
914+
host=host,
915+
instructions=instructions,
916+
include_test_controller=test_controller is not None,
917+
context_factory=context_factory,
918+
middleware=middleware,
919+
advertise_all=advertise_all,
920+
streaming_responses=streaming_responses,
921+
)
922+
if test_controller is not None:
923+
from adcp.server.test_controller import register_test_controller
924+
925+
register_test_controller(mcp, test_controller, context_factory=context_factory)
926+
mcp_inner = mcp.streamable_http_app()
927+
# Wrap with the standard trailing-slash normalizer so ``/mcp/``
928+
# and ``/mcp`` resolve to the same FastMCP endpoint. Keep the
929+
# unwrapped ``mcp_inner`` reference so the lifespan composer
930+
# below can reach ``.router.lifespan_context``.
931+
mcp_app = _wrap_with_path_normalize(mcp_inner)
932+
933+
# A2A app — built via the a2a-sdk wrapper. It mounts at the root
934+
# of its own app and handles ``/.well-known/agent.json``, ``/``,
935+
# and the message / push-notif endpoints.
936+
a2a_app = create_a2a_server(
937+
handler,
938+
name=name,
939+
port=port,
940+
test_controller=test_controller,
941+
context_factory=context_factory,
942+
task_store=task_store,
943+
push_config_store=push_config_store,
944+
middleware=middleware,
945+
message_parser=message_parser,
946+
advertise_all=advertise_all,
947+
)
948+
949+
# Lifespan composition: FastMCP's session manager initializes a
950+
# task group on startup; a2a-sdk's stores have their own init.
951+
# Compose both inner lifespans on a parent Starlette; the
952+
# dispatcher routes ``lifespan`` scope events to the parent so
953+
# both initializers run before any request lands.
954+
@contextlib.asynccontextmanager
955+
async def _composed_lifespan(_app): # type: ignore[no-untyped-def]
956+
async with mcp_inner.router.lifespan_context(mcp_inner):
957+
async with a2a_app.router.lifespan_context(a2a_app):
958+
yield
959+
960+
parent = Starlette(lifespan=_composed_lifespan)
961+
962+
async def _dispatch(scope: Scope, receive: Receive, send: Send) -> None:
963+
"""Path-based ASGI dispatcher.
964+
965+
``/mcp`` and ``/mcp/...`` route to the FastMCP streamable-http
966+
app with the full original path preserved (FastMCP's inner
967+
route is at ``/mcp``). Everything else goes to A2A. Lifespan
968+
events route to the parent Starlette which composes both
969+
inner lifespans.
970+
"""
971+
if scope["type"] == "http":
972+
path = scope.get("path", "")
973+
if path == "/mcp" or path.startswith("/mcp/"):
974+
await mcp_app(scope, receive, send)
975+
return
976+
await a2a_app(scope, receive, send)
977+
return
978+
if scope["type"] == "lifespan":
979+
await parent(scope, receive, send)
980+
return
981+
# Websocket and other scopes: route to A2A by default. MCP
982+
# streamable-http doesn't use websockets; A2A doesn't either
983+
# in the default a2a-sdk shape, but if either grows that
984+
# surface the dispatcher needs an explicit branch.
985+
await a2a_app(scope, receive, send)
986+
987+
app: ASGIApp = _dispatch
988+
return _wrap_with_size_limit(app, max_request_size)
989+
990+
991+
def _serve_mcp_and_a2a(
992+
handler: ADCPHandler[Any],
993+
*,
994+
name: str,
995+
port: int | None,
996+
host: str | None = None,
997+
instructions: str | None,
998+
test_controller: TestControllerStore | None,
999+
context_factory: ContextFactory | None = None,
1000+
task_store: TaskStore | None = None,
1001+
push_config_store: PushNotificationConfigStore | None = None,
1002+
middleware: Sequence[SkillMiddleware] | None = None,
1003+
message_parser: MessageParser | None = None,
1004+
advertise_all: bool = False,
1005+
max_request_size: int | None = None,
1006+
streaming_responses: bool = False,
1007+
) -> None:
1008+
"""Serve MCP and A2A on a single port via path dispatch.
1009+
1010+
JS sellers host both transports on one Express/Hono app; this is
1011+
the Python parity. Build both apps independently with the same
1012+
handler, ``context_factory``, ``middleware``, etc., then route
1013+
by URL path: ``/mcp`` and ``/mcp/...`` go to the MCP streamable-http
1014+
app, everything else (``/``, ``/.well-known/agent.json``,
1015+
A2A push endpoints) goes to the A2A app.
1016+
1017+
Both apps see the same ``ToolContext`` and middleware chain because
1018+
they share the same ``handler`` instance — adopters writing audit
1019+
or rate-limit middleware get one wiring point that applies to both
1020+
transports automatically.
1021+
"""
1022+
import anyio
1023+
import uvicorn
1024+
1025+
resolved_port = port or int(os.environ.get("PORT", "3001"))
1026+
resolved_host = host or os.environ.get("ADCP_HOST", "0.0.0.0")
1027+
log_level = "info"
1028+
1029+
app = _build_mcp_and_a2a_app(
1030+
handler,
1031+
name=name,
1032+
port=resolved_port,
1033+
host=resolved_host,
1034+
instructions=instructions,
1035+
test_controller=test_controller,
1036+
context_factory=context_factory,
1037+
task_store=task_store,
1038+
push_config_store=push_config_store,
1039+
middleware=middleware,
1040+
message_parser=message_parser,
1041+
advertise_all=advertise_all,
1042+
max_request_size=max_request_size,
1043+
streaming_responses=streaming_responses,
1044+
)
1045+
1046+
sock = _bind_reusable_socket(resolved_host, resolved_port)
1047+
try:
1048+
logger.info(
1049+
"MCP+A2A unified listening on http://%s:%s " "(MCP at /mcp, A2A at /)",
1050+
resolved_host,
1051+
resolved_port,
1052+
)
1053+
config = uvicorn.Config(app, log_level=log_level)
1054+
server = uvicorn.Server(config)
1055+
1056+
async def _serve() -> None:
1057+
await server.serve(sockets=[sock])
1058+
1059+
anyio.run(_serve)
1060+
finally:
1061+
sock.close()
1062+
1063+
8481064
def create_mcp_server(
8491065
handler: ADCPHandler[Any],
8501066
*,

0 commit comments

Comments
 (0)