3939from adcp .server .base import ADCPHandler , ToolContext
4040
4141if TYPE_CHECKING :
42+ from collections .abc import Sequence
43+
4244 from a2a .server .tasks .push_notification_config_store import (
4345 PushNotificationConfigStore ,
4446 )
4547 from a2a .server .tasks .task_store import TaskStore
4648
47- from adcp .server .serve import ContextFactory
49+ from adcp .server .serve import ContextFactory , SkillMiddleware
4850from adcp .server .helpers import STANDARD_ERROR_CODES
4951from adcp .server .mcp_tools import create_tool_caller , get_tools_for_handler
5052from adcp .server .test_controller import TestControllerStore , _handle_test_controller
@@ -69,9 +71,16 @@ def __init__(
6971 test_controller : TestControllerStore | None = None ,
7072 * ,
7173 context_factory : ContextFactory | None = None ,
74+ middleware : Sequence [SkillMiddleware ] | None = None ,
7275 ) -> None :
7376 self ._handler = handler
7477 self ._context_factory = context_factory
78+ # Store as a tuple so the executor can't be mutated from underneath
79+ # at runtime (a flaky test or a handler reaching self._middleware
80+ # can't corrupt the dispatch chain). Tuple ordering = runtime
81+ # ordering; first entry wraps outermost (see ``SkillMiddleware``
82+ # docstring for the composition semantics).
83+ self ._middleware : tuple [SkillMiddleware , ...] = tuple (middleware or ())
7584 self ._tool_callers : dict [str , Any ] = {}
7685
7786 # Build tool callers for all tools this handler supports.
@@ -117,7 +126,7 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non
117126
118127 tool_context = self ._build_tool_context (skill_name , context )
119128 try :
120- result = await self ._tool_callers [ skill_name ]( params , tool_context )
129+ result = await self ._dispatch_with_middleware ( skill_name , params , tool_context )
121130 await self ._send_result (event_queue , context , skill_name , result )
122131 except ADCPError as exc :
123132 # Application-layer AdCP error (IdempotencyConflictError etc.).
@@ -131,6 +140,43 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non
131140 logger .exception ("Error executing skill %s" , skill_name )
132141 await self ._send_error (event_queue , context , f"Skill execution failed: { skill_name } " )
133142
143+ async def _dispatch_with_middleware (
144+ self ,
145+ skill_name : str ,
146+ params : dict [str , Any ],
147+ tool_context : ToolContext ,
148+ ) -> Any :
149+ """Run the handler wrapped in the configured middleware chain.
150+
151+ Middleware composes outermost-first: the first entry in
152+ ``self._middleware`` sees every call *before* the later entries
153+ and *before* the handler. This matches Starlette / ASGI
154+ conventions so sellers porting from those stacks aren't
155+ surprised. Composition is done via a small recursive dispatcher
156+ (no mutable indices, no lambdas closing over loop variables) —
157+ the chain reads the same whether you have zero or ten
158+ middlewares.
159+
160+ Middleware exceptions propagate to the executor's normal error
161+ handling path in ``execute()``; this method does no try/except
162+ so short-circuiting, transform, and exception-observation all
163+ work the same way they do for the underlying handler.
164+ """
165+ if not self ._middleware :
166+ return await self ._tool_callers [skill_name ](params , tool_context )
167+
168+ async def _step (index : int ) -> Any :
169+ if index >= len (self ._middleware ):
170+ return await self ._tool_callers [skill_name ](params , tool_context )
171+ middleware = self ._middleware [index ]
172+
173+ async def call_next () -> Any :
174+ return await _step (index + 1 )
175+
176+ return await middleware (skill_name , params , tool_context , call_next )
177+
178+ return await _step (0 )
179+
134180 def _build_tool_context (self , skill_name : str , request : RequestContext ) -> ToolContext :
135181 """Build the :class:`ToolContext` handed to the skill dispatcher.
136182
@@ -445,6 +491,7 @@ def create_a2a_server(
445491 context_factory : ContextFactory | None = None ,
446492 task_store : TaskStore | None = None ,
447493 push_config_store : PushNotificationConfigStore | None = None ,
494+ middleware : Sequence [SkillMiddleware ] | None = None ,
448495) -> Any :
449496 """Create an A2A Starlette application from an ADCP handler.
450497
@@ -492,6 +539,14 @@ def create_a2a_server(
492539 (via a ``ContextVar`` your auth middleware populates) or by
493540 composition with a tenant-scoped ``TaskStore`` — the reference
494541 impl shows the ContextVar pattern.
542+ middleware: Optional sequence of :data:`~adcp.server.SkillMiddleware`
543+ callables wrapping every A2A skill dispatch. Composes
544+ outermost-first (first entry sees the call before later
545+ entries and before the handler). Use for audit logging,
546+ activity-feed hooks, rate limiting, per-skill tracing. See
547+ :data:`~adcp.server.SkillMiddleware` for the signature,
548+ composition semantics, and the exception-capture pattern
549+ audit hooks need.
495550
496551 Returns:
497552 A Starlette app ready to be run with uvicorn.
@@ -501,7 +556,10 @@ def create_a2a_server(
501556 resolved_port = port or int (os .environ .get ("PORT" , "3001" ))
502557
503558 executor = ADCPAgentExecutor (
504- handler , test_controller = test_controller , context_factory = context_factory
559+ handler ,
560+ test_controller = test_controller ,
561+ context_factory = context_factory ,
562+ middleware = middleware ,
505563 )
506564
507565 agent_card = _build_agent_card (
0 commit comments