Skip to content

feat(server): TypeVar-bound ADCPHandler for typed ToolContext subclasses (#223)#234

Merged
bokelley merged 2 commits intomainfrom
bokelley/typevar-adcphandler
Apr 20, 2026
Merged

feat(server): TypeVar-bound ADCPHandler for typed ToolContext subclasses (#223)#234
bokelley merged 2 commits intomainfrom
bokelley/typevar-adcphandler

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

Summary

Closes #223 (and roadmap PR-Q). Multi-tenant agents routinely subclass `ToolContext` to carry typed tenant/adapter/testing fields. Before this PR those fields needed a `cast` or `isinstance` at every handler method. Now:

```python
from dataclasses import dataclass
from adcp.server import ADCPHandler, ToolContext

@DataClass
class MyContext(ToolContext):
adapter: MyPlatformAdapter

class MyAgent(ADCPHandler[MyContext]):
async def get_products(self, params, context: MyContext | None = None):
if context is not None:
adapter = context.adapter # typed, no cast
```

  • New `TContext = TypeVar("TContext", bound="ToolContext")` exported from `adcp.server`.
  • `ADCPHandler` now `Generic[TContext]`; all 57 method signatures take `context: TContext | None`.
  • Protocol handlers (`BrandHandler`, `ComplianceHandler`, `ContentStandardsHandler`, `GovernanceHandler`, `SponsoredIntelligenceHandler`, `TmpHandler`) propagate the TypeVar so downstream can write `class MyBrand(BrandHandler[MyContext])`.
  • Internal SDK call sites use `ADCPHandler[Any]` where the SDK doesn't care about the subclass.

Backward compatibility

`class MyAgent(ADCPHandler)` without a TypeVar argument still works at runtime — Python allows unparameterised Generic inheritance. Every existing adopter keeps working without edits.

Test plan

  • `pytest tests/` — 1544 passed, 15 skipped (+9 from this PR)
  • `ruff check src/ tests/` — clean
  • `mypy src/adcp/` — 0 errors
  • New `tests/test_handler_typevar.py` covers:
    • unparameterised subclass still works (backward compat)
    • `ADCPHandler[MyContext]` / `BrandHandler[MyContext]` construct cleanly
    • handler methods receive the typed subclass at dispatch (`isinstance(context, MyContext)` is true at runtime)
    • A2A executor dispatches a typed handler without issue
    • `TContext.bound` is `ToolContext`
    • 57 method signatures preserve `(self, params, context)` positioning

Follow-ups (not in this PR)

  • PEP 696 TypeVar default (`TContext = TypeVar("TContext", bound=ToolContext, default=ToolContext)`) requires Python 3.13+. We support 3.10+, so no default — unparameterised `ADCPHandler` resolves to `ADCPHandler[Any]` in mypy. Revisit when the floor moves.
  • Protocol-class version of `SkillMiddleware` / `ContextFactory` for better IDE hover signatures — flagged by feat(server): per-skill middleware hook in ADCPAgentExecutor (#226) #233's review.

🤖 Generated with Claude Code

…ses (closes #223)

Roadmap PR-Q + expert-review followup from #219. Multi-tenant agents
routinely subclass ToolContext to carry typed tenant/adapter/testing
fields the base doesn't name — before this PR those fields required
casts at every handler method. Now ADCPHandler is Generic[TContext]
bound to ToolContext; downstream writes
``class MyAgent(ADCPHandler[MyContext])`` and every handler method
signature propagates the subclass type.

API
- New TContext = TypeVar("TContext", bound="ToolContext") exported
  from adcp.server. Docstring at the declaration site explains the
  pattern.
- ADCPHandler now inherits Generic[TContext]. All 57 method signatures
  in base.py rewritten to take context: TContext | None.
- Protocol handlers (BrandHandler, ComplianceHandler,
  ContentStandardsHandler, GovernanceHandler,
  SponsoredIntelligenceHandler, TmpHandler) propagate TContext via
  class X(ADCPHandler[TContext], Generic[TContext]) so downstream
  can write class MyBrand(BrandHandler[MyContext]).
- Internal SDK annotations (mcp_tools.py, serve.py, a2a_server.py,
  builder.py) use ADCPHandler[Any] where the SDK doesn't care about
  the TContext — the decorator-builder path and the transport
  executors don't thread a specific subclass.

Backward compat
- class MyAgent(ADCPHandler) without a TypeVar argument still works
  at runtime. Existing subclasses keep working without edits.
- No runtime behavior change. The TypeVar is purely type-system
  narrowing; handler dispatch paths are unchanged.

Tests — tests/test_handler_typevar.py (9 new, 1544 total)
- Unparameterised subclass still works (backward compat).
- Parameterised ADCPHandler[MyContext] constructs cleanly.
- Protocol handlers propagate the TypeVar (BrandHandler[MyContext]).
- Handler methods receive the subclass at dispatch time — the runtime
  isinstance(context, MyContext) is true.
- BrandHandler[MyContext] propagation tested end-to-end.
- TContext.__bound__ is ToolContext.
- A2A executor dispatches a typed handler without issue.
- Method signature structure preserved (self, params, context positions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- test_unparameterised_protocol_handler_still_works now covers
  ContentStandardsHandler, GovernanceHandler, and
  SponsoredIntelligenceHandler by dynamically building concrete
  subclasses that stub every abstract handle_<tool>. Proves the TypeVar
  refactor didn't accidentally add a new abstract on the base.

- test_typevar_is_bound_to_toolcontext now forces forward-ref resolution
  via typing.get_type_hints so a typo in the bound (e.g. "ToolContect")
  would fail the test. Previously the unresolved forward-ref string was
  accepted as proof enough.

- test_handler_method_signatures_accept_subclass_positionally renamed to
  test_handler_method_signatures_preserve_parameter_order — reflects
  what it actually checks.

- Documented ADCPHandler[Any] choice in mcp_tools.py and a2a_server.py
  module docstrings: these modules dispatch by tool name and never read
  typed context fields, so Any is correct and avoids cascading the
  TypeVar through plumbing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bokelley bokelley merged commit d737527 into main Apr 20, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TypeVar-bound ADCPHandler for typed ToolContext subclasses

1 participant