@@ -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+
8481064def create_mcp_server (
8491065 handler : ADCPHandler [Any ],
8501066 * ,
0 commit comments