Module adcp.server.mcp_tools
MCP server integration helpers.
Provides utilities for registering ADCP handlers with MCP servers.
Note
Function signatures in this module use ADCPHandler[Any] rather
than a propagated TContext TypeVar. The rationale: these
functions (get_tools_for_handler(), create_mcp_tools(), etc.)
treat the handler opaquely — they walk the MRO and dispatch by tool
name without ever touching the context argument's typed fields.
Binding a TypeVar here would force callers to narrow at the call
site for no runtime benefit, and cascade the TypeVar through every
plumbing function in :mod:serve(). Any keeps the
plumbing honest: the static type says "this code works with any
ToolContext subclass," which is exactly true.
Functions
def create_mcp_tools(handler: ADCPHandler[Any],
*,
advertise_all: bool = False,
validation: ValidationHookConfig | None = None,
pre_validation_hooks: PreValidationHooks | None = None,
response_enhancer: ResponseEnhancer | None = None) ‑> MCPToolSet-
Expand source code
def create_mcp_tools( handler: ADCPHandler[Any], *, advertise_all: bool = False, validation: ValidationHookConfig | None = None, pre_validation_hooks: PreValidationHooks | None = None, response_enhancer: ResponseEnhancer | None = None, ) -> MCPToolSet: """Create MCP tools from an ADCP handler. This is the main entry point for MCP server integration. Example with mcp library: from mcp.server import Server from adcp.server import ContentStandardsHandler, create_mcp_tools class MyHandler(ContentStandardsHandler): # ... implement methods handler = MyHandler() tools = create_mcp_tools(handler) server = Server("my-content-agent") @server.list_tools() async def list_tools(): return tools.tool_definitions @server.call_tool() async def call_tool(name: str, arguments: dict): return await tools.call_tool(name, arguments) Args: handler: ADCP handler instance. advertise_all: When True, advertise every tool the handler type supports — even those whose method is still the SDK's ``not_supported`` default. See :func:`get_tools_for_handler`. validation: Opt-in schema validation config. When supplied, every tool caller validates requests and responses against the bundled AdCP JSON schemas. See :func:`create_tool_caller` for mode semantics. pre_validation_hooks: Optional dict mapping tool name to a ``(tool_name, args) -> args`` callable or ordered sequence. Applied before schema + Pydantic validation. See :func:`create_tool_caller`. response_enhancer: Optional server-wide :data:`ResponseEnhancer` applied to every successful response. See :func:`create_tool_caller`. Returns: MCPToolSet with tool definitions and handlers. """ return MCPToolSet( handler, advertise_all=advertise_all, validation=validation, pre_validation_hooks=pre_validation_hooks, response_enhancer=response_enhancer, )Create MCP tools from an ADCP handler.
This is the main entry point for MCP server integration.
Example with mcp library: from mcp.server import Server from adcp.server import ContentStandardsHandler, create_mcp_tools
class MyHandler(ContentStandardsHandler): # ... implement methods handler = MyHandler() tools = create_mcp_tools(handler) server = Server("my-content-agent") @server.list_tools() async def list_tools(): return tools.tool_definitions @server.call_tool() async def call_tool(name: str, arguments: dict): return await tools.call_tool(name, arguments)Args
handler- ADCP handler instance.
advertise_all- When True, advertise every tool the handler type
supports — even those whose method is still the SDK's
not_supporteddefault. See :func:get_tools_for_handler(). validation- Opt-in schema validation config. When supplied,
every tool caller validates requests and responses against
the bundled AdCP JSON schemas. See
:func:
create_tool_caller()for mode semantics. pre_validation_hooks- Optional dict mapping tool name to a
(tool_name, args) -> argscallable or ordered sequence. Applied before schema + Pydantic validation. See :func:create_tool_caller(). response_enhancer- Optional server-wide :data:
ResponseEnhancerapplied to every successful response. See :func:create_tool_caller().
Returns
MCPToolSet with tool definitions and handlers.
def create_tool_caller(handler: ADCPHandler[Any],
method_name: str,
*,
validation: ValidationHookConfig | None = None,
pre_validation_hook: PreValidationHookChain | None = None,
default_unnegotiated_adcp_version: str | None = '3.0',
response_enhancer: ResponseEnhancer | None = None) ‑> Callable[..., typing.Any]-
Expand source code
def create_tool_caller( handler: ADCPHandler[Any], method_name: str, *, validation: ValidationHookConfig | None = None, pre_validation_hook: PreValidationHookChain | None = None, default_unnegotiated_adcp_version: str | None = DEFAULT_UNNEGOTIATED_ADCP_VERSION, response_enhancer: ResponseEnhancer | None = None, ) -> Callable[..., Any]: """Create a tool caller function for an ADCP handler method. Automatically injects context passthrough: if the request contains a ``context`` field, it is echoed back in the response (ADCP requirement). Handlers no longer need to call ``inject_context()`` manually. **Typed params (closes #214).** When the handler method declares its ``params`` parameter as a Pydantic model (e.g. ``params: GetProductsRequest``), the dispatcher deserialises the raw dict into the model before calling the handler — giving authors IDE autocomplete, Pydantic validation at the boundary, and typed attribute access instead of ``params.get(...)``. Handlers still declaring ``params: dict[str, Any]`` keep working unchanged. A Pydantic ``ValidationError`` surfaces as a structured ``INVALID_REQUEST`` AdCP error so callers see a spec-typed recovery classification rather than a raw stack trace. **Schema-driven validation (issue #249).** When ``validation`` is supplied, the dispatcher validates incoming requests and outgoing responses against the bundled AdCP JSON schemas. Request failures raise ``ADCPTaskError(VALIDATION_ERROR)`` before the handler runs, so malformed payloads never hit business logic. Response failures either raise ``VALIDATION_ERROR`` (strict) or log a warning (warn). Defaults to off on the server side — the client-side hooks already catch drift for SDK-built clients, and enabling server validation is a deliberate opt-in for authors who want dispatcher-level enforcement. **Pre-validation hooks (issues #614, #859).** When ``pre_validation_hook`` is supplied, each hook is called with ``(tool_name, shallow_copy_of_args)`` and must return a ``dict`` that replaces the wire args before schema validation and Pydantic ``model_validate`` run. Pass either one hook or an ordered sequence of hooks; sequences run left-to-right. The framework passes a shallow copy of the incoming params dict to each hook, so a hook may mutate its argument freely or return a brand-new dict — either style is safe. The original wire params are captured before the copy is made, so context echo always reflects what the buyer sent. Use this to apply spec-mandated defaults for pre-v3 buyers that omit required fields (e.g. ``buying_mode``, ``format_id`` shape coercion, ``asset_type`` inference). The hook runs on every call; keep it fast. Exceptions from the hook surface as ``INVALID_REQUEST`` — do not raise for missing-but-defaultable fields, only for structurally unusable args. **Unknown-field policy (issue #858).** When ``validation=ValidationHookConfig(unknown_fields=...)`` is supplied, unsupported top-level tool arguments are handled after pre-validation hooks and legacy adapters, but before request schema validation and Pydantic coercion. ``"reject"`` raises ``INVALID_REQUEST``, ``"strip"`` removes unsupported keys, and ``"ignore"`` preserves the current permissive behavior. .. note:: For the specific case of buyers omitting ``account``, see issue #623 ("Typed dispatcher rejects valid request when ``account`` is omitted") — that will be the canonical spec-level fix for that field. Once #623 lands you can drop any ``account`` placeholder hook entry. Args: handler: The ADCP handler instance method_name: Name of the method to call validation: Optional :class:`ValidationHookConfig` with per-side modes (``strict`` / ``warn`` / ``off``). Omitting it disables server-side schema validation entirely. pre_validation_hook: Optional callable or ordered sequence of callables ``(tool_name, args) -> args`` invoked on the raw wire dict before schema + Pydantic validation. See the **Pre-validation hooks** section above. default_unnegotiated_adcp_version: Release-precision version to use when the buyer supplies no version envelope. MCP uses ``"3.0"`` for legacy compatibility. A2A passes ``None`` so omitted version means the current SDK wire shape. response_enhancer: Optional server-wide :data:`ResponseEnhancer` applied to every successful response after context echo and before schema validation. See :data:`ResponseEnhancer` for the two supported arities and the failure/idempotency semantics. Returns: Async callable ``call_tool(params, context=None)``. The ``context`` parameter is optional — transports that can extract caller identity from their auth layer (A2A's ``ServerCallContext.user``, custom FastMCP auth middleware, etc.) should pass a populated :class:`ToolContext` so the server middleware layer (idempotency per-principal scoping, audit logging) gets the real principal. When no context is supplied, a bare :class:`ToolContext` is used. """ from pydantic import ValidationError from adcp.compat.legacy import LEGACY_ADAPTER_VERSIONS, get_legacy_adapter from adcp.exceptions import ADCPTaskError from adcp.server.helpers import inject_context from adcp.types import Error from adcp.validation.envelope import UnsupportedVersionError, detect_wire_version from adcp.validation.schema_errors import build_adcp_validation_error_payload from adcp.validation.schema_validator import ( format_issues, validate_request, validate_response, ) method = getattr(handler, method_name) params_model = _resolve_params_pydantic_model(method) # Opt-in server-side schema modes. ``None`` keeps validation off # entirely (zero overhead on the hot path) — the TS-port default for # ``createAdcpServer`` is the same: validation is an explicit opt-in. request_mode = validation.requests if validation is not None else None response_mode = validation.responses if validation is not None else None unknown_field_policy = _normalize_unknown_field_policy( validation.unknown_fields if validation is not None else None ) pre_validation_hooks = _flatten_pre_validation_hooks(pre_validation_hook) async def call_tool(params: dict[str, Any], context: ToolContext | None = None) -> Any: ctx = context if context is not None else ToolContext() raw_params = params # Preserve original wire params for context echo. if pre_validation_hooks: try: params = _apply_pre_validation_hooks( pre_validation_hooks, method_name, dict(params) ) except PreValidationHookError as exc: raise ADCPTaskError( operation=method_name, errors=[ Error( code="INVALID_REQUEST", message=str(exc), ) ], ) from exc # Wire-version detection: read ``adcp_version`` / ``adcp_major_version`` # off the post-hook params (legacy buyers may rely on a hook to # populate the envelope, so this runs after pre_validation_hook). # ``None`` initially means the buyer didn't claim a version. # After legacy shape probes run, native unnegotiated traffic is # pinned to 3.0 compatibility because those buyers predate the # release-precision ``adcp_version`` field and the 3.1 status split. # # Strictness gate: setting ``ADCP_STRICT_VERSION_ENVELOPE=1`` # raises ``VERSION_UNSUPPORTED`` for unsupported claims (the # spec-prescribed behaviour). The default (off) logs a warning # and falls through to SDK-pin validation — adopters with test # fixtures using placeholder version values (``adcp_major_version=4`` # was a common sentinel before this gate existed) keep working # while they migrate. Strict will become the default in 5.3. wire_version_rejected = False try: wire_version = detect_wire_version(params) except UnsupportedVersionError as exc: wire_version_rejected = True if os.environ.get("ADCP_STRICT_VERSION_ENVELOPE", "0") == "1": raise ADCPTaskError( operation=method_name, errors=[ Error( code="VERSION_UNSUPPORTED", message=str(exc), # Preserve the wire field's original type so # buyer telemetry sees the same shape they # sent (int for ``adcp_major_version``, str # for ``adcp_version``). details={ "claimed_version": exc.wire_value, "supported_versions": list(exc.supported), }, ) ], ) from exc logger.warning( "Wire-version envelope rejected by detect_wire_version (%s); " "falling through to SDK-pin validation. " "Set ADCP_STRICT_VERSION_ENVELOPE=1 to raise " "VERSION_UNSUPPORTED instead. Strict will become the default " "in 5.3.", exc, ) wire_version = None # Shape-based legacy detection (issue: real v2.5 buyers can't # send ``adcp_version`` — the field didn't exist in the v2.5 # schema). When the envelope is empty and a legacy adapter # registers an ``is_legacy_shape`` probe, run it. A match # promotes ``wire_version`` to the probe's version so the # adapter path below fires normally. Bias is conservative: # probes return ``True`` only on fields v3 doesn't emit # (``brand_manifest``, ``creative_ids``, bare-string # ``format_id``). False positives downgrade a real v3 buyer # to legacy validation, which is the worst outcome. if wire_version is None: for candidate in LEGACY_ADAPTER_VERSIONS: candidate_adapter = get_legacy_adapter(candidate, method_name) if candidate_adapter is None: continue probe = candidate_adapter.is_legacy_shape if probe is None: continue try: matched = probe(params) if isinstance(params, dict) else False except Exception: # noqa: BLE001 — defensive: probes are pure-ish matched = False if matched: logger.info( "Detected %s wire shape for %s (no envelope version " "supplied); routing through legacy adapter.", candidate, method_name, ) wire_version = candidate break if wire_version is None and not wire_version_rejected: wire_version = default_unnegotiated_adcp_version ctx.resolved_adcp_version = wire_version # Legacy-version routing: if the buyer claims (or shape-detected) # a version handled via the adapter path (e.g. ``"2.5"``), # validate the params against the legacy schema first, *then* # translate to the current shape. Pre-adapter validation # surfaces structural errors with the legacy schema's field # paths — far easier for the buyer to act on than a v3 # field-path error after a confusing translation. Post-adapter # validation (further down) catches translator bugs against # the SDK pin. legacy_adapter: Any = None if wire_version in LEGACY_ADAPTER_VERSIONS: legacy_adapter = get_legacy_adapter(wire_version, method_name) if legacy_adapter is None: raise ADCPTaskError( operation=method_name, errors=[ Error( code="INVALID_REQUEST", message=( f"Tool {method_name!r} is not available on " f"AdCP {wire_version}; upgrade to a " f"supported version or call a tool exposed " f"on this legacy surface." ), details={"legacy_version": wire_version}, ) ], ) # Pre-adapter validation against the legacy schema. # Only runs when validation is enabled at all # (``request_mode != off`` AND a config is supplied) — keeps # the zero-overhead path for adopters who haven't opted in. # ``strict`` rejects; ``warn`` logs and proceeds so the # adapter still gets to translate (matching the existing # post-adapter contract). if request_mode is not None and request_mode != "off": pre_outcome = validate_request(method_name, params, version=wire_version) if not pre_outcome.valid: summary = format_issues(pre_outcome.issues) if request_mode == "strict": payload = build_adcp_validation_error_payload( method_name, "request", pre_outcome.issues ) # Annotate with the wire version so adopter # telemetry knows which schema rejected. payload_details = dict(payload.get("details") or {}) payload_details["claimed_version"] = wire_version payload["details"] = payload_details raise ADCPTaskError( operation=method_name, errors=[Error(**payload)], ) logger.warning( "Schema validation warning (pre-adapter %s) for %s: %s", wire_version, method_name, summary, ) try: params = legacy_adapter.adapt_request(params) except Exception as exc: raise ADCPTaskError( operation=method_name, errors=[ Error( code="INVALID_REQUEST", message=( f"Legacy adapter for {method_name!r} at " f"AdCP {wire_version} failed: " f"{type(exc).__name__}: {exc}" ), ) ], ) from exc # Adapter output is validated against the SDK pin # (catches translator bugs with v3 field paths). The # ``post_adapter_validator_version`` name documents # which side of the adapter this value plays. post_adapter_validator_version: str | None = None else: post_adapter_validator_version = wire_version if isinstance(params, dict): params = _apply_unknown_field_policy( method_name, params, policy=unknown_field_policy, version=post_adapter_validator_version, params_model=params_model, ) if request_mode is not None and request_mode != "off": outcome = validate_request(method_name, params, version=post_adapter_validator_version) if not outcome.valid: summary = format_issues(outcome.issues) if request_mode == "strict": payload = build_adcp_validation_error_payload( method_name, "request", outcome.issues ) raise ADCPTaskError( operation=method_name, errors=[Error(**payload)], ) logger.warning( "Schema validation warning (request) for %s: %s", method_name, summary, ) call_params: Any = params if params_model is not None and isinstance(params, dict): try: call_params = params_model.model_validate(params) except ValidationError as exc: # Surface as a structured AdCP error so MCP clients see # INVALID_REQUEST with a field-level pointer instead of # a raw Pydantic traceback. translate_error maps this # to ToolError (MCP) / ServerError (A2A) per transport. # # Strip ``input``/``ctx``/``url`` from the Pydantic error # details — they echo the raw offending value verbatim # (``input`` in particular). In multi-hop agent chains the # response flows through intermediaries, so echoing the # user-supplied value is a PII/secret-leak vector: a # mistyped API key or secret-shaped idempotency_key could # land in the broker's logs. The field path in # ``Error.field`` is all clients need to programmatically # locate the bad value in their own request. errors_list = exc.errors( include_input=False, include_context=False, include_url=False ) # Narrow discriminated-union failures to the variant # the user actually intended (Stability AI Emma P2: # 60-line dump → focused error). For non-union # failures the function is a no-op. # # Defensive: if the narrowing helper itself raises # (heuristic edge case, future pydantic format # change), keep the original error list rather than # 500'ing the wire path. The narrowed-error UX is a # nice-to-have; correctness is surfacing SOME error. try: errors_list = list(narrow_union_errors(errors_list)) except Exception: logger.warning( "narrow_union_errors raised on %s — passing through " "unfiltered errors. This is a bug in the narrowing " "heuristic, NOT in the validation itself.", method_name, exc_info=True, ) first: dict[str, Any] = dict(errors_list[0]) if errors_list else {} field_path = ".".join(str(loc) for loc in first.get("loc", ())) message = first.get("msg", "validation failed") suggestion = ( f"Invalid value for field {field_path!r}: {message}" if field_path else f"Request validation failed: {message}" ) raise ADCPTaskError( operation=method_name, errors=[ Error( code="INVALID_REQUEST", field=field_path or None, message=suggestion, details={"validation_errors": errors_list}, ) ], ) from exc result = await method(call_params, ctx) # Convert Pydantic models to JSON-safe dicts for MCP serialization if hasattr(result, "model_dump"): result = result.model_dump(mode="json", exclude_none=True) # ADCP requires echoing context from request to response — read # from the raw dict the transport sent, not from the validated # model (which won't carry the wire ``context`` field). if isinstance(result, dict): _normalize_response_envelope( method_name, result, raw_params, adcp_version=post_adapter_validator_version, ) inject_context(raw_params, result) # Run the seller's response enhancer AFTER ``inject_context`` # (so it sees the credential-stripped echo envelope and can't # re-introduce a credential) and BEFORE ``validate_response`` # (so any conformance-breaking mutation surfaces as a # VALIDATION_ERROR rather than shipping malformed). This single # site covers framework tools, custom tools # (``get_task_status`` / ``list_tasks``), and # ``get_adcp_capabilities`` on both MCP and A2A. The L3 error # envelope is enhanced on the dedicated error paths # (``build_mcp_error_result`` / ``_send_adcp_error``), so skip # it here to avoid a double pass. if "adcp_error" not in result: _apply_response_enhancer(response_enhancer, method_name, result, ctx) if response_mode is not None and response_mode != "off" and isinstance(result, dict): # Skip validation when the handler returned the AdCP L3 # error envelope (``{"adcp_error": {...}}``). That envelope # has its own shape enforced by the ``Error`` builder; the # per-tool response schema would false-positive on it and # convert a real protocol error into a fake VALIDATION_ERROR. if "adcp_error" not in result: outcome = validate_response( method_name, result, version=post_adapter_validator_version ) if not outcome.valid: summary = format_issues(outcome.issues) logger.warning( "Schema validation warning (response) for %s: %s", method_name, summary, ) if response_mode == "strict": payload = build_adcp_validation_error_payload( method_name, "response", outcome.issues ) raise ADCPTaskError( operation=method_name, errors=[Error(**payload)], ) # Legacy adapter response rewrite: when the buyer is on a legacy # wire shape and the adapter declares a ``normalize_response`` # callable, translate the current-shape response back so the # buyer sees the dict shape they expected. Runs *after* # validation (which validated the current shape) so a malformed # legacy rewrite doesn't mask handler bugs. if legacy_adapter is not None and legacy_adapter.normalize_response is not None: if isinstance(result, dict) and "adcp_error" not in result: try: result = legacy_adapter.normalize_response(result) except Exception as exc: raise ADCPTaskError( operation=method_name, errors=[ Error( code="INTERNAL_ERROR", message=( f"Legacy response normalizer for " f"{method_name!r} at AdCP " f"{wire_version} failed: " f"{type(exc).__name__}: {exc}" ), ) ], ) from exc if ( legacy_adapter is not None and isinstance(result, dict) and result.get("status") == "completed" ): result.pop("status") return result return call_toolCreate a tool caller function for an ADCP handler method.
Automatically injects context passthrough: if the request contains a
contextfield, it is echoed back in the response (ADCP requirement). Handlers no longer need to callinject_context()manually.Typed params (closes #214). When the handler method declares its
paramsparameter as a Pydantic model (e.g.params: GetProductsRequest), the dispatcher deserialises the raw dict into the model before calling the handler — giving authors IDE autocomplete, Pydantic validation at the boundary, and typed attribute access instead ofparams.get(…). Handlers still declaringparams: dict[str, Any]keep working unchanged. A PydanticValidationErrorsurfaces as a structuredINVALID_REQUESTAdCP error so callers see a spec-typed recovery classification rather than a raw stack trace.Schema-driven validation (issue #249). When
validationis supplied, the dispatcher validates incoming requests and outgoing responses against the bundled AdCP JSON schemas. Request failures raiseADCPTaskError(VALIDATION_ERROR)before the handler runs, so malformed payloads never hit business logic. Response failures either raiseVALIDATION_ERROR(strict) or log a warning (warn). Defaults to off on the server side — the client-side hooks already catch drift for SDK-built clients, and enabling server validation is a deliberate opt-in for authors who want dispatcher-level enforcement.Pre-validation hooks (issues #614, #859). When
pre_validation_hookis supplied, each hook is called with(tool_name, shallow_copy_of_args)and must return adictthat replaces the wire args before schema validation and Pydanticmodel_validaterun. Pass either one hook or an ordered sequence of hooks; sequences run left-to-right. The framework passes a shallow copy of the incoming params dict to each hook, so a hook may mutate its argument freely or return a brand-new dict — either style is safe. The original wire params are captured before the copy is made, so context echo always reflects what the buyer sent. Use this to apply spec-mandated defaults for pre-v3 buyers that omit required fields (e.g.buying_mode,format_idshape coercion,asset_typeinference). The hook runs on every call; keep it fast. Exceptions from the hook surface asINVALID_REQUEST— do not raise for missing-but-defaultable fields, only for structurally unusable args.Unknown-field policy (issue #858). When
validation=ValidationHookConfig(unknown_fields=...)is supplied, unsupported top-level tool arguments are handled after pre-validation hooks and legacy adapters, but before request schema validation and Pydantic coercion."reject"raisesINVALID_REQUEST,"strip"removes unsupported keys, and"ignore"preserves the current permissive behavior.Note
For the specific case of buyers omitting
account, see issue623 ("Typed dispatcher rejects valid request when
accountisomitted") — that will be the canonical spec-level fix for that field. Once #623 lands you can drop any
accountplaceholder hook entry.Args
handler- The ADCP handler instance
method_name- Name of the method to call
validation- Optional :class:
ValidationHookConfigwith per-side modes (strict/warn/off). Omitting it disables server-side schema validation entirely. pre_validation_hook- Optional callable or ordered sequence of
callables
(tool_name, args) -> argsinvoked on the raw wire dict before schema + Pydantic validation. See the Pre-validation hooks section above. default_unnegotiated_adcp_version- Release-precision version to use
when the buyer supplies no version envelope. MCP uses
"3.0"for legacy compatibility. A2A passesNoneso omitted version means the current SDK wire shape. response_enhancer- Optional server-wide :data:
ResponseEnhancerapplied to every successful response after context echo and before schema validation. See :data:ResponseEnhancerfor the two supported arities and the failure/idempotency semantics.
Returns
Async callable
call_tool(params, context=None). Thecontextparameter is optional — transports that can extract caller identity from their auth layer (A2A'sServerCallContext.user, custom FastMCP auth middleware, etc.) should pass a populated :class:ToolContextso the server middleware layer (idempotency per-principal scoping, audit logging) gets the real principal. When no context is supplied, a bare :class:ToolContextis used. def get_tools_for_handler(handler: ADCPHandler[Any] | type[ADCPHandler[Any]],
*,
advertise_all: bool = False) ‑> list[dict[str, typing.Any]]-
Expand source code
def get_tools_for_handler( handler: ADCPHandler[Any] | type[ADCPHandler[Any]], *, advertise_all: bool = False, ) -> list[dict[str, Any]]: """Return tool definitions the handler will actually answer. Walks the MRO to find the matching handler base class, so subclasses (e.g. ``MyGovernanceAgent(GovernanceHandler)``) get the correct tool set. ADCPHandler gets all tools. Unknown handlers get only protocol discovery (minimum privilege). By default, tools whose handler method is still the SDK's ``not_supported`` default (the subclass never overrode it) are filtered out — there's no point advertising a tool that answers every call with ``NOT_SUPPORTED``. This keeps ``tools/list`` small and protects agent clients from chasing non-functional tool surface. Always-advertised tools: - :data:`_PROTOCOL_TOOLS` (``get_adcp_capabilities``) — per-spec handshake requirement. - :data:`DISCOVERY_TOOLS` — auth-optional discovery tools the spec requires agents to expose. Escape hatch: pass ``advertise_all=True`` to restore the pre-#220 behavior and advertise every tool in the handler-type's allowed set regardless of override state. Useful for spec-compliance storyboard tests and for agents that deliberately want to expose a ``not_supported`` tool (e.g. to signal "we know about X but don't implement it yet"). Args: handler: The handler instance or class. advertise_all: When True, skip the override-based filter and advertise every tool allowed for the handler type. Returns: Filtered list of tool definitions. """ _ensure_pydantic_schemas_applied() cls = handler if isinstance(handler, type) else type(handler) instance = handler if not isinstance(handler, type) else None candidates: list[dict[str, Any]] = [] for base in cls.__mro__: if base.__name__ in _HANDLER_TOOLS: allowed = _HANDLER_TOOLS[base.__name__] | _PROTOCOL_TOOLS candidates = [tool for tool in ADCP_TOOL_DEFINITIONS if tool["name"] in allowed] break else: candidates = [tool for tool in ADCP_TOOL_DEFINITIONS if tool["name"] in _PROTOCOL_TOOLS] # Per-instance specialism filter (Emma cross-cutting P1). When the # handler instance exposes ``advertised_tools_for_instance``, intersect # the candidate universe with the per-instance set BEFORE the # override-detection filter. This trims tools whose Protocol family # the platform didn't claim (sales-only adopter no longer advertises # ``acquire_rights``, ``build_creative``, etc.). Falls back to the # class-level universe when: # # * The handler is being inspected by class (no instance) — class-level # advertisement preserves backwards compat for static introspection. # * The hook returns an empty set (adopter piloting a novel specialism # slug not in :data:`SPECIALISM_TO_ADVERTISED_TOOLS`); muting the # handler would be a worse foot-gun than over-advertising. if instance is not None and hasattr(instance, "advertised_tools_for_instance"): try: per_instance_set = instance.advertised_tools_for_instance() except Exception: # Defensive: never let an instance hook crash tools/list. per_instance_set = None if per_instance_set: always_on = _PROTOCOL_TOOLS | DISCOVERY_TOOLS candidates = [ tool for tool in candidates if tool["name"] in always_on or tool["name"] in per_instance_set ] if advertise_all: return candidates always_on = _PROTOCOL_TOOLS | DISCOVERY_TOOLS return [ tool for tool in candidates if tool["name"] in always_on or _is_method_overridden(cls, tool["name"]) ]Return tool definitions the handler will actually answer.
Walks the MRO to find the matching handler base class, so subclasses (e.g.
MyGovernanceAgent(GovernanceHandler)) get the correct tool set. ADCPHandler gets all tools. Unknown handlers get only protocol discovery (minimum privilege).By default, tools whose handler method is still the SDK's
not_supporteddefault (the subclass never overrode it) are filtered out — there's no point advertising a tool that answers every call withNOT_SUPPORTED. This keepstools/listsmall and protects agent clients from chasing non-functional tool surface.Always-advertised tools: - :data:
_PROTOCOL_TOOLS(get_adcp_capabilities) — per-spec handshake requirement. - :data:DISCOVERY_TOOLS— auth-optional discovery tools the spec requires agents to expose.Escape hatch: pass
advertise_all=Trueto restore the pre-#220 behavior and advertise every tool in the handler-type's allowed set regardless of override state. Useful for spec-compliance storyboard tests and for agents that deliberately want to expose anot_supportedtool (e.g. to signal "we know about X but don't implement it yet").Args
handler- The handler instance or class.
advertise_all- When True, skip the override-based filter and advertise every tool allowed for the handler type.
Returns
Filtered list of tool definitions.
def register_handler_tools(handler_name: str, tools: Iterable[str]) ‑> None-
Expand source code
def register_handler_tools(handler_name: str, tools: Iterable[str]) -> None: """Register a handler-class-name → tool-set mapping with the framework. Public seam. ``get_tools_for_handler`` reads ``_HANDLER_TOOLS`` to filter ``tools/list`` per handler subclass; without registration, an ``ADCPHandler`` subclass that introduces a new specialism would fall through to its parent's tool set (typically ``ADCPHandler``'s full-spec surface), over-advertising. Codegen targets like ``adcp.decisioning.handler.PlatformHandler`` register here at class definition time via ``ADCPHandler.__init_subclass__``; hand-written custom bases call this directly before ``serve()``. Idempotent on equal input — calling twice with the same tool set is a no-op so module re-imports / reload-friendly test harnesses don't break. Conflicts raise. Unknown tool names raise with a closest-match suggestion (typo recovery for adopters working from spec memory). :param handler_name: The class name of the handler subclass — typically ``cls.__name__`` from inside ``__init_subclass__``. :param tools: Iterable of AdCP tool names this handler answers (members of ``ADCP_TOOL_DEFINITIONS``). Order doesn't matter. :raises ValueError: when ``handler_name`` is already registered with a different tool set, or when any tool name is not in the AdCP spec surface. """ incoming = frozenset(tools) existing = _HANDLER_TOOLS.get(handler_name) if existing is not None: if frozenset(existing) == incoming: return raise ValueError( f"register_handler_tools({handler_name!r}, ...) called twice " f"with different tool sets. Existing: {sorted(existing)}; " f"incoming: {sorted(incoming)}. The framework can only hold " "one mapping per handler class — pick the canonical set." ) unknown = incoming - _ALL_TOOL_NAMES if unknown: suggestions: list[str] = [] for bad in sorted(unknown): close = difflib.get_close_matches(bad, _ALL_TOOL_NAMES, n=1) if close: suggestions.append(f"{bad!r} (did you mean {close[0]!r}?)") else: suggestions.append(repr(bad)) raise ValueError( f"register_handler_tools({handler_name!r}, ...): unknown tool " f"name(s) {', '.join(suggestions)}. Tool names must match the " "AdCP spec — see ``adcp.server.mcp_tools.ADCP_TOOL_DEFINITIONS``." ) _HANDLER_TOOLS[handler_name] = set(incoming)Register a handler-class-name → tool-set mapping with the framework.
Public seam.
get_tools_for_handler()reads_HANDLER_TOOLSto filtertools/listper handler subclass; without registration, anADCPHandlersubclass that introduces a new specialism would fall through to its parent's tool set (typicallyADCPHandler's full-spec surface), over-advertising. Codegen targets likePlatformHandlerregister here at class definition time viaADCPHandler.__init_subclass__; hand-written custom bases call this directly beforeserve(). Idempotent on equal input — calling twice with the same tool set is a no-op so module re-imports / reload-friendly test harnesses don't break. Conflicts raise. Unknown tool names raise with a closest-match suggestion (typo recovery for adopters working from spec memory). :param handler_name: The class name of the handler subclass — typicallycls.__name__from inside__init_subclass__. :param tools: Iterable of AdCP tool names this handler answers (members ofADCP_TOOL_DEFINITIONS). Order doesn't matter. :raises ValueError: whenhandler_nameis already registered with a different tool set, or when any tool name is not in the AdCP spec surface. def validate_discovery_set(tools: Iterable[str]) ‑> None-
Expand source code
def validate_discovery_set(tools: Iterable[str]) -> None: """Fail-closed validation for an auth-optional tool set. Downstream that extends :data:`DISCOVERY_TOOLS` (``DISCOVERY_TOOLS | {"my_public_tool"}``) risks accidentally including a mutation tool, which would silently unauthenticate writes over HTTP. This helper asserts every name in the set resolves to a known ADCP tool whose annotations declare ``readOnlyHint: True`` — it refuses to pass anything mutating, destructive, or unknown. Call this at server startup on the effective set your middleware uses:: from adcp.server import DISCOVERY_TOOLS, validate_discovery_set MY_DISCOVERY = DISCOVERY_TOOLS | {"list_public_formats"} validate_discovery_set(MY_DISCOVERY) # raises early if misconfigured :raises ValueError: if any name in ``tools`` is unknown or resolves to a non-read-only tool. """ by_name = {t["name"]: t for t in ADCP_TOOL_DEFINITIONS} unknown: list[str] = [] mutating: list[str] = [] for name in tools: tool = by_name.get(name) if tool is None: unknown.append(name) continue annotations = tool.get("annotations") or {} if not annotations.get("readOnlyHint"): mutating.append(name) problems: list[str] = [] if unknown: problems.append(f"unknown tool(s): {sorted(unknown)}") if mutating: problems.append( f"non-read-only tool(s) {sorted(mutating)} — adding these to the " "auth-optional set would silently unauthenticate mutations" ) if problems: raise ValueError("validate_discovery_set rejected the set: " + "; ".join(problems))Fail-closed validation for an auth-optional tool set.
Downstream that extends :data:
DISCOVERY_TOOLS(DISCOVERY_TOOLS | {"my_public_tool"}) risks accidentally including a mutation tool, which would silently unauthenticate writes over HTTP. This helper asserts every name in the set resolves to a known ADCP tool whose annotations declarereadOnlyHint: True— it refuses to pass anything mutating, destructive, or unknown.Call this at server startup on the effective set your middleware uses::
from adcp.server import DISCOVERY_TOOLS, validate_discovery_set MY_DISCOVERY = DISCOVERY_TOOLS | {"list_public_formats"} validate_discovery_set(MY_DISCOVERY) # raises early if misconfigured:raises ValueError: if any name in
toolsis unknown or resolves to a non-read-only tool.
Classes
class MCPToolSet (handler: ADCPHandler[Any],
*,
advertise_all: bool = False,
validation: ValidationHookConfig | None = None,
pre_validation_hooks: PreValidationHooks | None = None,
response_enhancer: ResponseEnhancer | None = None)-
Expand source code
class MCPToolSet: """Collection of MCP tools from an ADCP handler. Provides tool definitions and handlers for registering with an MCP server. """ def __init__( self, handler: ADCPHandler[Any], *, advertise_all: bool = False, validation: ValidationHookConfig | None = None, pre_validation_hooks: PreValidationHooks | None = None, response_enhancer: ResponseEnhancer | None = None, ): """Create tool set from handler. Args: handler: ADCP handler instance. advertise_all: When True, advertise every tool the handler type supports — even those whose method is still the SDK's ``not_supported`` default. See :func:`get_tools_for_handler` for the default behavior (override-filtered advertisement). validation: Opt-in schema validation config applied to every tool caller. See :func:`create_tool_caller`. pre_validation_hooks: Optional dict mapping tool name to a ``(tool_name, args) -> args`` callable or ordered sequence. Applied before schema + Pydantic validation. See :func:`create_tool_caller`. response_enhancer: Optional server-wide :data:`ResponseEnhancer` applied to every successful response. See :func:`create_tool_caller`. """ self.handler = handler self._filtered_definitions = get_tools_for_handler(handler, advertise_all=advertise_all) self._tools: dict[str, Callable[..., Any]] = {} # Create tool callers only for filtered tools for tool_def in self._filtered_definitions: name = tool_def["name"] hook = (pre_validation_hooks or {}).get(name) self._tools[name] = create_tool_caller( handler, name, validation=validation, pre_validation_hook=hook, response_enhancer=response_enhancer, ) @property def tool_definitions(self) -> list[dict[str, Any]]: """Get MCP tool definitions filtered by handler type.""" return list(self._filtered_definitions) async def call_tool(self, name: str, params: dict[str, Any]) -> Any: """Call a tool by name. Args: name: Tool name params: Tool parameters Returns: Tool result Raises: KeyError: If tool not found """ if name not in self._tools: raise KeyError(f"Unknown tool: {name}") return await self._tools[name](params) def get_tool_names(self) -> list[str]: """Get list of available tool names.""" return list(self._tools.keys())Collection of MCP tools from an ADCP handler.
Provides tool definitions and handlers for registering with an MCP server.
Create tool set from handler.
Args
handler- ADCP handler instance.
advertise_all- When True, advertise every tool the handler
type supports — even those whose method is still the
SDK's
not_supporteddefault. See :func:get_tools_for_handler()for the default behavior (override-filtered advertisement). validation- Opt-in schema validation config applied to every
tool caller. See :func:
create_tool_caller(). pre_validation_hooks- Optional dict mapping tool name to a
(tool_name, args) -> argscallable or ordered sequence. Applied before schema + Pydantic validation. See :func:create_tool_caller(). response_enhancer- Optional server-wide :data:
ResponseEnhancerapplied to every successful response. See :func:create_tool_caller().
Instance variables
prop tool_definitions : list[dict[str, Any]]-
Expand source code
@property def tool_definitions(self) -> list[dict[str, Any]]: """Get MCP tool definitions filtered by handler type.""" return list(self._filtered_definitions)Get MCP tool definitions filtered by handler type.
Methods
async def call_tool(self, name: str, params: dict[str, Any]) ‑> Any-
Expand source code
async def call_tool(self, name: str, params: dict[str, Any]) -> Any: """Call a tool by name. Args: name: Tool name params: Tool parameters Returns: Tool result Raises: KeyError: If tool not found """ if name not in self._tools: raise KeyError(f"Unknown tool: {name}") return await self._tools[name](params)Call a tool by name.
Args
name- Tool name
params- Tool parameters
Returns
Tool result
Raises
KeyError- If tool not found
def get_tool_names(self) ‑> list[str]-
Expand source code
def get_tool_names(self) -> list[str]: """Get list of available tool names.""" return list(self._tools.keys())Get list of available tool names.