-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathserve.py
More file actions
1878 lines (1661 loc) · 78.8 KB
/
Copy pathserve.py
File metadata and controls
1878 lines (1661 loc) · 78.8 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
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""One-liner server for ADCP handlers (MCP or A2A).
Stand up an ADCP-compliant server with a single function call:
from adcp.server import ADCPHandler, serve
from adcp.server.responses import capabilities_response
class MyAgent(ADCPHandler):
async def get_adcp_capabilities(self, params, context=None):
return capabilities_response(["media_buy"])
# MCP (default)
serve(MyAgent())
# A2A
serve(MyAgent(), transport="a2a")
"""
from __future__ import annotations
import logging
import os
import warnings
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Literal
logger = logging.getLogger("adcp.server")
from adcp.server.base import ADCPHandler, ToolContext
from adcp.server.mcp_tools import (
_HANDLER_TOOLS,
create_tool_caller,
get_tools_for_handler,
)
from adcp.validation.client_hooks import (
SERVER_DEFAULT_VALIDATION as DEFAULT_VALIDATION,
)
from adcp.validation.client_hooks import (
ValidationHookConfig,
)
# Re-exported as ``adcp.server.serve.DEFAULT_VALIDATION`` for adopters who
# want a non-magic name when constructing their own
# ``ValidationHookConfig`` overrides. The canonical definition lives in
# :mod:`adcp.validation.client_hooks` so both the server-side and any
# future server-creation seam can share one constant without a circular
# import via this module.
if TYPE_CHECKING:
from collections.abc import Sequence
from a2a.server.tasks.push_notification_config_store import (
PushNotificationConfigStore,
)
from a2a.server.tasks.task_store import TaskStore
from adcp.server.a2a_server import MessageParser
from adcp.server.test_controller import TestControllerStore
@dataclass(frozen=True)
class RequestMetadata:
"""Per-request metadata passed to :class:`ContextFactory`.
Populated by the SDK before invoking the factory. Stable across the
MCP and A2A transports — factories written against this shape work
on both sides. Additional fields may be added in minor releases;
factories should keep accepting ``RequestMetadata`` and pluck the
fields they need by name, not by positional unpacking.
:param tool_name: The AdCP operation being invoked (e.g.
``"get_products"``, ``"create_media_buy"``). Useful for
tool-level audit logging and feature flagging.
:param transport: ``"mcp"`` or ``"a2a"`` — the wire protocol
currently dispatching this call. Agents that expose both can
use this to branch on transport-specific behavior.
:param request_id: The transport-assigned request id when one
exists (A2A populates this from the task id; MCP leaves it
``None`` at the SDK layer today).
"""
tool_name: str
transport: Literal["mcp", "a2a"]
request_id: str | None = None
SkillMiddleware = Callable[
[str, dict[str, Any], ToolContext, Callable[[], Awaitable[Any]]],
Awaitable[Any],
]
"""Middleware that wraps skill dispatch on both the MCP and A2A
transports — the audit / activity-feed / rate-limiter / tracing hook.
Composition semantics are identical across transports (shared
composer); middleware written against one transport works unchanged
on the other.
Signature (conceptually a Protocol; declared as a ``Callable`` alias so
it's importable and consistent with ``ContextFactory``)::
async def middleware(
skill_name: str,
params: dict[str, Any],
context: ToolContext,
call_next: Callable[[], Awaitable[Any]],
) -> Any:
...
Middleware wraps ``call_next()`` — call it (possibly more than once to
implement retry, or never to short-circuit) to invoke the rest of the
chain plus the underlying handler. Anything the middleware returns
becomes the dispatch result the A2A transport serialises back to the
client, so middleware can short-circuit (skip the handler entirely) or
transform the result on the return side.
Middleware observes both success and failure — catch exceptions around
``call_next()`` to implement audit-on-failure or retry-classifier hooks.
Middleware re-raising propagates to the executor's normal error path
(application ``ADCPError`` → failed task w/ ``adcp_error`` DataPart;
other exceptions → opaque failed task per the spec's error-sanitisation
rule). **Swallowing an exception and returning a substitute result is
allowed but almost always wrong** — in particular, swallowing
``ADCPError`` subclasses (``IdempotencyConflictError``,
``ADCPTaskError``) serves a fake success for a failed mutation, which
double-bills / double-allocates in production.
``params`` is the parsed request dict passed to every middleware in
the chain and to the handler. Middleware cannot mutate what the next
layer sees by mutating ``params`` — transforms happen on the return
side only, by modifying the value returned from ``call_next()``.
Multiple middlewares compose outermost-first, matching Starlette/ASGI
semantics — if you pass ``middleware=[Audit(), RateLimit(), Metrics()]``,
the runtime order is::
Audit.__call__ → RateLimit.__call__ → Metrics.__call__ → handler
**Put audit outermost.** Middleware that short-circuits (rate limiter,
feature-flag gate) never calls ``call_next()``, so anything deeper in
the chain never sees the request. If your audit middleware sits
*after* the rate limiter, rejected calls disappear from the audit
trail — often the most interesting events for security review.
``call_next()`` runs in the same asyncio task as the middleware that
invoked it, so ``ContextVar`` values set before the call are visible
to downstream middleware and the handler. Don't ``asyncio.create_task``
your way around this unless you need the isolation.
**Security — middleware is a data processor for the full skill payload.**
``params`` is decoded business content (buyer briefs, budgets, brand
references, proposal text, PII in message parts). ``context`` carries
``caller_identity``, ``tenant_id``, and anything your ``context_factory``
populates. Installing a third-party middleware (observability vendor,
SaaS audit pipeline, external tracing) hands that vendor the complete
skill payload surface — treat it as a data processor under your
GDPR/CCPA controller-processor relationships and review the blast
radius before wiring vendors here.
**Security — do not format ``params`` or ``context.caller_identity``
into exception messages.** Middleware-raised exceptions pass through
``logger.exception`` in the executor (server-side trace with the raw
message) before the executor's sanitisation kicks in for the client
response. Exception text ends up in operator logs verbatim; keep it
opaque.
**Security — short-circuit caches MUST include principal + tenant in
the cache key.** A middleware that caches on ``skill_name + params``
alone and returns a cached result without calling ``call_next()``
will serve principal A's data to principal B on a matching-params
call. Key on ``(skill_name, params, context.caller_identity,
context.tenant_id)``.
Example — audit logging with exception capture::
from adcp.server import SkillMiddleware, ToolContext
async def audit_middleware(
skill_name: str,
params: dict[str, Any],
context: ToolContext,
call_next: Callable[[], Awaitable[Any]],
) -> Any:
started_at = time.monotonic()
try:
result = await call_next()
except Exception as exc:
# Keep exception text opaque — this ends up in server logs.
audit_log.failure(
skill_name, context.caller_identity, type(exc).__name__
)
raise
audit_log.success(
skill_name,
context.caller_identity,
elapsed_ms=(time.monotonic() - started_at) * 1000,
)
return result
create_a2a_server(MyAgent(), middleware=[audit_middleware])
The same middleware list also composes on the MCP side — pass it to
``create_mcp_server(middleware=...)`` or the transport-agnostic
``serve(middleware=...)``.
"""
def _log_advertised_tools(
*,
transport: Literal["mcp", "a2a"],
handler: ADCPHandler[Any],
advertise_all: bool,
registered: list[str],
) -> None:
"""Log which tools the server just advertised, plus the delta vs the
full spec surface the handler class could have supported.
Operators occasionally rename a handler method and silently drop it
from ``tools/list`` — discovering that during incident review is
the wrong time. Emitting the advertised set and the unadvertised
delta at startup turns a silent gap into a searchable log line.
Registered at ``INFO`` because operators routinely tail this; the
delta at ``DEBUG`` because it's noisy on fully-implemented handlers.
Also fires a one-time ``UserWarning`` at boot when the handler
class introduces a new specialism (a custom subclass that's not in
the framework's tool registry and doesn't declare
``advertised_tools``) but ``advertise_all`` is False — closes the
silent over-advertisement gap where adopters see the full
``ADCPHandler`` tool surface inherited via MRO when they meant to
declare a focused subset.
"""
registered_set = set(registered)
full_defs = get_tools_for_handler(handler, advertise_all=True)
full_names = {t["name"] for t in full_defs}
unadvertised = sorted(full_names - registered_set)
logger.info(
"%s server advertising %d of %d tools%s",
transport,
len(registered_set),
len(full_names),
" (advertise_all=True)" if advertise_all else "",
)
if unadvertised and not advertise_all:
logger.debug("%s server unadvertised tools: %s", transport, ", ".join(unadvertised))
# Stacklevel walks: warnings.warn → _warn_if_unregistered_subclass →
# _log_advertised_tools → operator's call site. The MCP path adds one
# extra frame (_register_handler_tools); A2A calls _log_advertised_tools
# directly from create_a2a_server.
caller_stacklevel = 4 if transport == "mcp" else 3
_warn_if_unregistered_subclass(
handler, advertise_all=advertise_all, stacklevel=caller_stacklevel
)
#: Bases whose tool set is broad-by-design — when an adopter subclass
#: lands on one of these via MRO without registering its own
#: ``advertised_tools``, the result is over-advertisement (the broad
#: base's full set inherited unintentionally). Naming the rule rather
#: than checking ``base.__name__ != "ADCPHandler"`` inline so future
#: broad bases (a hypothetical ``UniversalHandler``) get added to one
#: place — and a reviewer's first question becomes "is this base
#: broad-by-design?" not "what's special about ADCPHandler?".
_BROAD_SURFACE_BASES: frozenset[str] = frozenset({"ADCPHandler"})
def _warn_if_unregistered_subclass(
handler: ADCPHandler[Any], *, advertise_all: bool, stacklevel: int = 4
) -> None:
"""Emit a one-time ``UserWarning`` when a custom handler base bypasses
the tool-discovery registry.
The trigger: the concrete handler class itself isn't in
``_HANDLER_TOOLS``, has no ``advertised_tools`` declaration of its
own, and inherits its tool set from a broad-surface base (see
:data:`_BROAD_SURFACE_BASES`) rather than a specialized base like
``GovernanceHandler``. That combination almost always means the
adopter meant to declare a focused tool set but forgot to register
it; the framework over-advertises by silently falling through to
the broad base's full surface.
Suppressed when ``advertise_all=True`` — that's the explicit "yes,
advertise everything" opt-in.
"""
if advertise_all:
return
cls = type(handler)
if cls.__name__ in _HANDLER_TOOLS:
return
if "advertised_tools" in cls.__dict__:
# Should already have been auto-registered via __init_subclass__,
# but defensively skip the warning if the attribute exists.
return
# Walk MRO looking for a specialized (non-broad-surface) SDK base.
# If one is found, the adopter is subclassing a focused base and
# inheriting its tool set — that's the documented pattern, no
# warning needed.
has_specialized_parent = any(
base.__name__ in _HANDLER_TOOLS and base.__name__ not in _BROAD_SURFACE_BASES
for base in cls.__mro__
)
if has_specialized_parent:
return
# Default stacklevel=4 covers the MCP path (warn → this fn →
# _log_advertised_tools → _register_handler_tools → caller). The A2A
# path lacks _register_handler_tools and passes stacklevel=3.
warnings.warn(
f"Handler class {cls.__name__!r} subclasses ADCPHandler directly "
f"but isn't registered in the framework's tool-discovery "
f"registry. tools/list will inherit the full ADCPHandler tool "
f"surface — this almost always means over-advertising for a "
f"new specialism.\n\n"
f"Pick one:\n"
f" (a) declare ``advertised_tools: set[str] = {{...}}`` on "
f"{cls.__name__} (auto-registers via __init_subclass__)\n"
f" (b) call adcp.server.mcp_tools.register_handler_tools("
f"{cls.__name__!r}, {{...}}) before serve()\n"
f" (c) pass advertise_all=True to serve() to acknowledge the "
f"full advertisement\n\n"
f"Decisioning-platform adopters: codegen via "
f"`uv run python scripts/generate_decisioning_handler.py` "
f"emits the declaration for you.",
UserWarning,
stacklevel=stacklevel,
)
async def _dispatch_with_middleware(
middleware: tuple[SkillMiddleware, ...] | Sequence[SkillMiddleware],
skill_name: str,
params: dict[str, Any],
context: ToolContext,
call_handler: Callable[[], Awaitable[Any]],
) -> Any:
"""Run ``call_handler`` wrapped in the supplied middleware chain.
Shared by the MCP and A2A dispatch paths so composition semantics
stay identical across transports — middleware porting between
``create_mcp_server(middleware=...)`` and
``create_a2a_server(middleware=...)`` needs zero changes.
Outermost-first composition: the first entry in ``middleware`` sees
every call *before* later entries and *before* the handler. No
mutable indices, no loop-variable captures — a small recursive
dispatcher reads the same with zero or ten middlewares.
Middleware exceptions propagate to the caller unchanged; this
function does no try/except so short-circuiting, transform, and
exception-observation behaviors are owned by the transport-level
executor, not the composer.
"""
if not middleware:
return await call_handler()
async def _step(index: int) -> Any:
if index >= len(middleware):
return await call_handler()
mw = middleware[index]
async def call_next() -> Any:
return await _step(index + 1)
return await mw(skill_name, params, context, call_next)
return await _step(0)
ContextFactory = Callable[[RequestMetadata], ToolContext]
"""Factory invoked per tool call to build a :class:`ToolContext`.
The SDK's server-side idempotency middleware reads
``ToolContext.caller_identity`` (and ``tenant_id`` for multi-tenant
scope) for cache keying, so factories wiring auth MUST populate
``caller_identity``. See :class:`~adcp.server.base.ToolContext` for
the full field contract.
The SDK deliberately does not know how your auth middleware surfaces
the authenticated principal — different downstreams use Starlette
``request.state``, ``contextvars.ContextVar``, thread-locals, etc.
The factory closes over whatever mechanism your middleware populates
and returns a ``ToolContext`` (or subclass).
Example using ``contextvars`` (recommended — middleware-agnostic)::
from contextvars import ContextVar
from adcp.server import RequestMetadata, ToolContext, create_mcp_server
_principal: ContextVar[str | None] = ContextVar(
"adcp_principal", default=None
)
_tenant: ContextVar[str | None] = ContextVar(
"adcp_tenant", default=None
)
# Your HTTP middleware sets the ContextVars; tool calls read them.
def build_context(meta: RequestMetadata) -> ToolContext:
return ToolContext(
request_id=meta.request_id,
caller_identity=_principal.get(),
tenant_id=_tenant.get(),
metadata={"tool_name": meta.tool_name, "transport": meta.transport},
)
mcp = create_mcp_server(MyAgent(), context_factory=build_context)
"""
ASGIMiddlewareEntry = tuple[Callable[..., Any], dict[str, Any]] | Callable[..., Any]
"""A single ASGI middleware entry for :func:`serve`'s ``asgi_middleware`` param.
Each entry is either:
- A ``(callable, kwargs)`` tuple — invoked as ``callable(app, **kwargs)``.
Both plain class constructors and :func:`functools.partial` instances work
as the first element.
- A bare callable factory ``f(app) -> app`` — invoked as ``factory(app)``.
Both forms can be mixed in the same list.
"""
def serve(
handler: ADCPHandler[Any] | Any,
*,
name: str = "adcp-agent",
port: int | None = None,
host: str | None = None,
transport: str = "streamable-http",
instructions: str | None = None,
test_controller: TestControllerStore | None = None,
test_controller_account_resolver: Any | None = None,
context_factory: ContextFactory | None = None,
task_store: TaskStore | None = None,
push_config_store: PushNotificationConfigStore | None = None,
middleware: Sequence[SkillMiddleware] | None = None,
asgi_middleware: Sequence[ASGIMiddlewareEntry] | None = None,
message_parser: MessageParser | None = None,
advertise_all: bool = False,
max_request_size: int | None = None,
streaming_responses: bool = False,
validation: ValidationHookConfig | None = DEFAULT_VALIDATION,
enable_debug_endpoints: bool = False,
debug_traffic_source: Callable[[], dict[str, int]] | None = None,
base_url: str | None = None,
specialisms: list[str] | None = None,
description: str | None = None,
allowed_hosts: Sequence[str] | None = None,
allowed_origins: Sequence[str] | None = None,
enable_dns_rebinding_protection: bool | None = None,
) -> None:
"""Start an MCP or A2A server from an ADCP handler or server builder.
Accepts either an ``ADCPHandler`` instance or an ``ADCPServerBuilder``
(from ``adcp_server()``). Builders are auto-converted via ``build_handler()``.
This is the simplest way to run an ADCP agent. Set ``transport="a2a"``
to serve over the A2A protocol instead of MCP, or ``transport="both"``
to serve both protocols on the same port (MCP at ``/mcp``, A2A at
``/``).
Args:
handler: An ADCPHandler subclass instance with your tool implementations.
name: Server name shown to clients / in the A2A agent card.
port: Port to listen on. Defaults to PORT env var, then 3001.
transport: ``"streamable-http"`` (default, MCP), ``"a2a"``, or
``"both"`` (one Starlette binary serving MCP at ``/mcp``
and A2A at ``/``). Use ``"both"`` when you want adopters
on either protocol to reach the same handler with shared
``context_factory`` + ``middleware`` wiring — JS hosts both
on one Express app; this is the Python parity.
instructions: Optional system instructions for the agent (MCP only).
test_controller: Optional TestControllerStore instance for storyboard testing.
context_factory: Optional factory that builds a :class:`ToolContext`
per tool call — see :data:`ContextFactory`.
task_store: Optional a2a-sdk ``TaskStore`` for durable A2A task
persistence (A2A transport only). Defaults to ``InMemoryTaskStore``
— tasks don't survive restart. See
``examples/a2a_db_tasks.py`` for the production pattern.
push_config_store: Optional a2a-sdk ``PushNotificationConfigStore``
for push-notif subscription persistence (A2A transport only).
When unset, a2a-sdk surfaces the push-notif endpoints as
``UnsupportedOperationError`` — clients cannot register
subscriptions at all. See ``examples/a2a_db_tasks.py`` for
a durable reference implementation.
middleware: Optional sequence of :data:`SkillMiddleware` callables
wrapping every skill dispatch on both the MCP and A2A
transports. Use for audit logging, activity-feed hooks,
rate limiting, tracing. Composes outermost-first. See
:data:`SkillMiddleware` for the signature and composition
semantics.
asgi_middleware: Optional sequence of ASGI middleware entries
applied to the outer HTTP app before uvicorn binds. Use for
cross-cutting HTTP concerns the SDK does not own: tenant
resolution (:class:`adcp.server.SubdomainTenantMiddleware`),
CORS, request-id propagation, IP allowlists, custom auth.
Composes outermost-first — the first entry sees every request
before later entries. Applied on every HTTP transport
(``streamable-http``, ``sse``, ``a2a``, ``both``); ignored
on ``stdio``.
Each entry is either a ``(MiddlewareClass, kwargs)`` tuple
invoked as ``cls(app, **kwargs)``, or a callable factory
``f(app) -> app``. Both forms can appear in the same list.
Middleware sees ``lifespan`` and ``websocket`` scopes in
addition to ``http`` — guard non-HTTP scopes by passing
them through unchanged (``if scope['type'] != 'http':
await self.app(scope, receive, send); return``) so the
framework's lifespan composition still runs.
Example (tuple form)::
from starlette.middleware.cors import CORSMiddleware
serve(handler, asgi_middleware=[
(CORSMiddleware, {"allow_origins": ["*"]}),
])
Example (callable factory form, e.g. with ``functools.partial``)::
import functools
from starlette.middleware.cors import CORSMiddleware
serve(handler, asgi_middleware=[
functools.partial(CORSMiddleware, allow_origins=["*"]),
])
message_parser: Optional
:data:`~adcp.server.a2a_server.MessageParser` callable for
alternative A2A wire shapes (A2A transport only). The
default parser handles ``DataPart(data={"skill": ...,
"parameters": ...})`` plus a TextPart JSON fallback; supply
this hook to accept JSON-RPC 2.0 message bodies or vendor-
specific DataPart schemas. MCP does not use this kwarg
(FastMCP owns the wire shape).
advertise_all: When True, advertise every tool the handler type
supports even if the subclass didn't override the method.
Defaults to ``False`` — ``tools/list`` only shows tools the
handler actually implements, which dramatically shrinks the
advertised surface. Turn on for spec-compliance storyboards
or when you want to signal ``not_supported`` on a specific
tool to clients.
max_request_size: Maximum request body size in bytes. Defaults
to 10 MB. Set higher for sellers that legitimately transmit
very large creative asset payloads; set lower for stricter
public-facing deployments. Set to ``0`` to disable the cap
entirely (not recommended — the cap is the only guard
against adversarial payloads exhausting Pydantic validation
CPU/memory). See :mod:`adcp.server._size_limit`.
host: Network interface to bind to (MCP transports only). Defaults
to the ``ADCP_HOST`` environment variable, then ``"0.0.0.0"``
(all interfaces). Use ``"127.0.0.1"`` for local-only
development. Container deployments (Fly.io, k8s, Cloud Run)
require ``"0.0.0.0"`` so the process listens on the
container's external interface.
streaming_responses: When ``False`` (default), the streamable-http
transport returns one ``application/json`` response per
request. AdCP tools today don't emit progress events, and
FastMCP's SSE-internal streaming default has an upstream bug
that drops the ASGI response without completing — making the
storyboard runner report ``overall_status: "unreachable"``.
Set to ``True`` only if your tools genuinely emit progress
notifications and your clients consume the SSE stream
(MCP transports only). Note: the legacy ``transport="sse"``
is a separate (deprecated) MCP transport, unrelated to this
flag.
enable_debug_endpoints: When ``True``, mount ``GET /_debug/traffic``
on the outer HTTP app. Returns the JSON dict from
``debug_traffic_source()`` — typically wired to the
seller's :class:`adcp.decisioning.MockAdServer.get_traffic`.
Defaults to ``False`` so production deployments stay
closed; reference / dev sellers turn it on. Ignored on
stdio. The endpoint exposes per-method outbound call
counts for storyboard runners' anti-façade assertions.
debug_traffic_source: Zero-arg callable returning the
per-method count snapshot for ``/_debug/traffic``. Required
when ``enable_debug_endpoints=True``; otherwise ignored.
Typically ``mock_ad_server.get_traffic``.
base_url: Optional public origin URL for the binary, used to
populate the ``url`` field of each entry in the
``/.well-known/adcp-agents.json`` discovery manifest.
Adopters behind a TLS-terminating reverse proxy SHOULD set
this (e.g. ``"https://sales.example.com"``). When ``None``
the manifest URLs fall back to ``http://<bind-host>:<port>``,
which is correct for local development but wrong for
production.
specialisms: Optional list of AdCP specialism tags surfaced in
the discovery manifest (e.g. ``["sales-non-guaranteed"]``).
See :data:`adcp.server.discovery` for the full list.
Defaults to a placeholder when omitted — adopters who know
their specialism SHOULD pass it.
description: Optional human-readable description surfaced in
the discovery manifest's per-agent ``description`` field.
validation: :class:`ValidationHookConfig` enabling schema
validation of every request and response against the
bundled AdCP JSON schemas. ``requests="strict"`` raises
``VALIDATION_ERROR`` before the handler runs on a malformed
payload; ``responses="strict"`` raises after the handler
returns when the response shape drifts from spec.
**Defaults to** :data:`DEFAULT_VALIDATION` (strict on both
sides) — wire-conformance by default. This catches the
class of bug that shipped the ``pricing_options``
regression past Pydantic ``extra="allow"`` silently
swallowing an unknown shape. Adopters mid-migration who
need response drift to warn rather than fail pass
``ValidationHookConfig(responses="warn")``; adopters who
want validation off entirely pass
``ValidationHookConfig(requests="off", responses="off")``
or ``validation=None``. Applies to both MCP and A2A
transports.
Security:
This function does NOT configure authentication. In production,
use a reverse proxy or middleware that validates credentials
before forwarding to the endpoint. Without authentication,
MCP exposes tools/list and A2A exposes /.well-known/agent.json,
both of which reveal the agent's full capability surface.
Example (MCP):
from adcp.server import ADCPHandler, serve
from adcp.server.responses import capabilities_response
class MyAgent(ADCPHandler):
async def get_adcp_capabilities(self, params, context=None):
return capabilities_response(["media_buy"])
serve(MyAgent(), name="my-agent")
Example (A2A):
serve(MyAgent(), name="my-agent", transport="a2a")
With test controller:
from adcp.server.test_controller import TestControllerStore
class MyStore(TestControllerStore):
async def force_account_status(self, account_id, status):
...
serve(MyAgent(), name="my-agent", test_controller=MyStore())
"""
# Accept ADCPServerBuilder from adcp_server() decorator pattern
from adcp.server.builder import ADCPServerBuilder
if isinstance(handler, ADCPServerBuilder):
if not name or name == "adcp-agent":
name = handler.name
handler = handler.build_handler()
# Compose the debug-traffic endpoint as the outermost ASGI
# middleware. Mounting it ahead of any seller-provided
# ``asgi_middleware`` means a runner's ``GET /_debug/traffic``
# short-circuits before tenant-resolution / auth middleware runs —
# the endpoint is for storyboard runners, not authenticated
# buyers, and should not require buyer credentials to reach.
asgi_middleware = _prepend_debug_endpoint(
asgi_middleware,
enable_debug_endpoints=enable_debug_endpoints,
debug_traffic_source=debug_traffic_source,
)
if transport == "a2a":
_serve_a2a(
handler,
name=name,
port=port,
test_controller=test_controller,
test_controller_account_resolver=test_controller_account_resolver,
context_factory=context_factory,
task_store=task_store,
push_config_store=push_config_store,
middleware=middleware,
asgi_middleware=asgi_middleware,
message_parser=message_parser,
advertise_all=advertise_all,
max_request_size=max_request_size,
validation=validation,
base_url=base_url,
specialisms=specialisms,
description=description,
)
elif transport in ("streamable-http", "sse", "stdio"):
_serve_mcp(
handler,
name=name,
port=port,
host=host,
transport=transport,
instructions=instructions,
test_controller=test_controller,
test_controller_account_resolver=test_controller_account_resolver,
context_factory=context_factory,
middleware=middleware,
asgi_middleware=asgi_middleware,
advertise_all=advertise_all,
max_request_size=max_request_size,
streaming_responses=streaming_responses,
validation=validation,
base_url=base_url,
specialisms=specialisms,
description=description,
allowed_hosts=allowed_hosts,
allowed_origins=allowed_origins,
enable_dns_rebinding_protection=enable_dns_rebinding_protection,
)
elif transport == "both":
_serve_mcp_and_a2a(
handler,
name=name,
port=port,
host=host,
instructions=instructions,
test_controller=test_controller,
test_controller_account_resolver=test_controller_account_resolver,
context_factory=context_factory,
task_store=task_store,
push_config_store=push_config_store,
middleware=middleware,
asgi_middleware=asgi_middleware,
message_parser=message_parser,
advertise_all=advertise_all,
max_request_size=max_request_size,
streaming_responses=streaming_responses,
validation=validation,
base_url=base_url,
specialisms=specialisms,
description=description,
allowed_hosts=allowed_hosts,
allowed_origins=allowed_origins,
enable_dns_rebinding_protection=enable_dns_rebinding_protection,
)
else:
valid = ", ".join(sorted(("a2a", "both", "streamable-http", "sse", "stdio")))
raise ValueError(f"Unknown transport {transport!r}. Valid: {valid}")
def _prepend_debug_endpoint(
asgi_middleware: Sequence[ASGIMiddlewareEntry] | None,
*,
enable_debug_endpoints: bool,
debug_traffic_source: Callable[[], dict[str, int]] | None,
) -> Sequence[ASGIMiddlewareEntry] | None:
"""Prepend :class:`DebugTrafficMiddleware` to the asgi_middleware
sequence when debug endpoints are enabled.
No-op when ``enable_debug_endpoints=False`` — the middleware isn't
mounted, ``/_debug/traffic`` falls through to the inner app, and
the inner app returns 404. Production-default closed posture.
Raises ``ValueError`` when debug endpoints are enabled but no
traffic source is supplied — silently mounting an endpoint that
would error on every request is worse than a clear configuration
error at boot.
"""
if not enable_debug_endpoints:
return asgi_middleware
if debug_traffic_source is None:
raise ValueError(
"enable_debug_endpoints=True requires debug_traffic_source= "
"(typically mock_ad_server.get_traffic). Without a source the "
"/_debug/traffic endpoint has nothing to return."
)
from adcp.server.debug_endpoints import DebugTrafficMiddleware
debug_entry = (
DebugTrafficMiddleware,
{"traffic_source": debug_traffic_source},
)
if asgi_middleware is None:
return [debug_entry]
return [debug_entry, *asgi_middleware]
def _apply_asgi_middleware(
app: Any,
asgi_middleware: Sequence[ASGIMiddlewareEntry] | None,
) -> Any:
"""Wrap ``app`` with operator-supplied Starlette-style ASGI middleware.
Each entry is either ``(MiddlewareClass, kwargs)`` invoked as
``cls(app, **kwargs)``, or a callable factory ``f(app) -> app`` invoked
as ``factory(app)``. Both forms can appear in the same list. Composition
is outermost-first — the first entry sees every request before later
entries — so we wrap in reverse, matching :meth:`Starlette.add_middleware`
semantics.
No-op when the sequence is empty or ``None``.
"""
if not asgi_middleware:
return app
for entry in reversed(list(asgi_middleware)):
if isinstance(entry, tuple):
cls, kwargs = entry
app = cls(app, **kwargs)
else:
app = entry(app)
return app
def _wrap_with_discovery(
app: Any,
*,
name: str,
transports: list[Literal["mcp", "a2a"]],
base_url: str,
description: str | None = None,
specialisms: list[str] | None = None,
) -> Any:
"""Wrap an ASGI app to serve ``/.well-known/adcp-agents.json``.
Intercepts the discovery path and serves the AdCP multi-agent
topology manifest; every other request passes through unchanged.
Sits outside the inner transport apps (FastMCP / a2a-sdk Starlette)
so adding the route doesn't require monkey-patching either upstream.
GET returns the manifest as JSON; non-GET methods at the discovery
path 404 back to the inner app — letting the inner Starlette
return its standard 405 / 404 keeps the well-known surface
read-only without baking method-policy into this wrapper.
"""
from adcp.server.discovery import (
DISCOVERY_PATH,
build_manifest,
)
async def _middleware(scope: Any, receive: Any, send: Any) -> None:
if (
scope.get("type") == "http"
and scope.get("path") == DISCOVERY_PATH
and scope.get("method") == "GET"
):
from starlette.responses import JSONResponse
manifest = build_manifest(
name=name,
transports=transports,
base_url=base_url,
description=description,
specialisms=specialisms,
)
response = JSONResponse(manifest)
await response(scope, receive, send)
return
await app(scope, receive, send)
return _middleware
def _wrap_with_path_normalize(app: Any) -> Any:
"""Wrap an ASGI app so trailing-slash variants of the same path
route to the same handler instead of returning 307.
The FastMCP streamable-http app mounts the JSON-RPC endpoint at
``/mcp`` (no trailing slash). Buyer libraries that POST to
``/mcp/`` get a 307 redirect, which:
1. Costs an extra RTT per call (visible in the access log;
Emma signals + AudioStack reports both noted this).
2. Silently breaks buyer libs that don't follow redirects on POST
(most HTTP clients don't, by default — POSTing to a redirect
reverts to GET on the redirected URL, losing the body).
Stripping a single trailing slash before dispatch is the standard
fix; this middleware mutates ``scope["path"]`` and
``scope["raw_path"]`` in-place so downstream routing sees the
canonical form. Only applies to non-root paths so we don't
accidentally route ``/`` to ``''``.
"""
async def _middleware(scope: Any, receive: Any, send: Any) -> None:
if scope.get("type") in {"http", "websocket"}:
path = scope.get("path", "")
if len(path) > 1 and path.endswith("/"):
# Mutate the scope's mutable copy — Starlette guarantees
# a fresh dict per request so this doesn't leak across
# connections.
new_scope = dict(scope)
new_scope["path"] = path.rstrip("/")
raw_path = new_scope.get("raw_path")
if isinstance(raw_path, bytes) and len(raw_path) > 1 and raw_path.endswith(b"/"):
new_scope["raw_path"] = raw_path.rstrip(b"/")
scope = new_scope
await app(scope, receive, send)
return _middleware
def _wrap_with_size_limit(app: Any, max_request_size: int | None) -> Any:
"""Wrap an ASGI app with the request-body size cap.
``None`` = use the module default (10 MB). ``0`` = disable — skip
the middleware entirely so sellers who genuinely need unlimited
bodies can opt out. Any positive int overrides the default.
Negative values raise ``ValueError`` — they have no meaningful
interpretation and almost certainly indicate a typo (e.g. the
author meant ``0`` for "disable" or a positive cap for "N bytes").
Failing loudly at configure time beats a silent opt-out that only
surfaces when an attacker finds it.
"""
import logging
from adcp.server._size_limit import (
DEFAULT_MAX_REQUEST_BYTES,
RequestSizeLimitMiddleware,
)
if max_request_size is not None and max_request_size < 0:
raise ValueError(
f"max_request_size must be >= 0 (got {max_request_size}). "
"Use 0 to disable the cap entirely, or a positive int in bytes."
)
if max_request_size == 0:
# Load-bearing warning — 0 disables the only Pydantic-validation
# DoS guard. Operators should know, and a typo that lands on 0
# should leave a breadcrumb in the startup log rather than
# silently opt out.
logging.getLogger("adcp.server").warning(
"max_request_size=0 disables ASGI body cap; relying on upstream "
"proxy or WAF to bound request size. This is a security-relevant "
"configuration choice."
)
return app
cap = max_request_size if max_request_size is not None else DEFAULT_MAX_REQUEST_BYTES
return RequestSizeLimitMiddleware(app, max_bytes=cap)
def _bind_reusable_socket(host: str, port: int) -> Any:
"""Create a listening socket with SO_REUSEADDR set.
Without ``SO_REUSEADDR``, rapid restarts (common during tests and
storyboard runs) hit ``TIME_WAIT`` on the prior socket and the new
process hangs on bind for up to 2×MSL (roughly a minute on macOS).
Setting ``SO_REUSEADDR`` on the listening socket is the standard,
portable fix on Linux and macOS; it is safe because listeners are
unique by (addr, port) and the kernel still rejects a second live
listener on the same tuple.
On Windows ``SO_REUSEADDR`` has different semantics (it allows
hijacking a live listener). FastMCP's streamable-http and uvicorn
support Windows, so we guard with ``SO_EXCLUSIVEADDRUSE`` there —
but since the ADCP server primarily targets Linux/macOS and the
Windows path is rarely exercised, the guard is best-effort.
EADDRINUSE collisions (port already bound by another process) are
re-raised as ``OSError`` with a friendly remediation hint —
every Emma backend test reported being lost in a raw ``[Errno 48]
Address already in use`` with no pointer to the fix. The wrapped
error tells adopters exactly what to do (set ``port=`` or
``ADCP_PORT``).
"""
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
if os.name == "nt":
# Windows: prevent hijacking; don't set SO_REUSEADDR.
exclusive = getattr(socket, "SO_EXCLUSIVEADDRUSE", None)
if exclusive is not None:
sock.setsockopt(socket.SOL_SOCKET, exclusive, 1)
else:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((host, port))
sock.listen(128)
sock.set_inheritable(True)
except OSError as exc:
sock.close()
# EADDRINUSE on Linux/macOS = errno 98/48 (per platform). The
# raw message is opaque ("[Errno 48] Address already in use"
# — Emma reports flagged this as P1 friction). Project to a
# remediation-bearing message that points adopters at the
# ``port=`` / ``ADCP_PORT`` knobs.
import errno
if exc.errno in (errno.EADDRINUSE, getattr(errno, "WSAEADDRINUSE", -1)):
raise OSError(
exc.errno,
(
f"Port {port} on {host} is already in use — another process "
"is bound there (a stale dev server, a peer agent, or your "
"previous run). Pick a different port: pass ``port=<N>`` to "
"``adcp.decisioning.serve.serve(...)`` (or "
"``adcp.server.serve(...)``), or set the ``ADCP_PORT`` "
"environment variable. Default ADCP port is 3001 — common "
"alternates are 3011, 3021, 8080."
),
) from exc
raise
except Exception:
sock.close()
raise
return sock
def _serve_mcp(
handler: ADCPHandler[Any],
*,
name: str,
port: int | None,
host: str | None = None,
transport: str,
instructions: str | None,