modelcontextprotocol / modelcontextprotocol/experimental-ext-interceptors

Python SDK: support `phase="both"` as decorator sugar

Open
#37 0 comments 0 reactions 1 assignee View on GitHub

@olaservo is already working on this.

Since Aug 13, 2026.

enhancement
Dominant language
C#
Stars
23
Forks
14
Avg merge
1d 17h
Merged PRs (30d)
1

Description

Follow-up to #30, from @jeongukjae's review thread on types.py about the both phase.

Context

The SEP is explicit that both is not a wire value (docs/sep.md, Hooks section):

Hooks define which Lifecycle Events trigger an interceptor's invocation. The hooks array in the interceptor definition contains one or more entries, each declaring a set of Lifecycle Events (events) and a phase (phase: "request" or "response") where this interceptor will be invoked. Interceptors that run on both phases use two entries — one per phase.

The C# SDK follows this: InterceptorPhase.Both is documented as valid only at the attribute/SDK convenience layer, and the reflection layer expands it into two InterceptorHook entries before anything reaches the wire.

The Python SDK currently has no equivalent convenience — Phase = Literal["request", "response"] is used both as the wire type and as the decorator parameter, so an interceptor that runs on both phases must pass an explicit two-entry hooks=[...].

Proposal

Add both as decorator-level sugar only, matching the C# model:

  • Introduce a separate literal for the convenience layer (e.g. HookPhase = Literal["request", "response", "both"]) and accept it as the phase= parameter on Interceptors.validator(), .mutator(), and .sink().
  • Expand it inside build_hooks() in interceptor.py into two Hook entries, one per phase.
  • Leave the wire type Phase = Literal["request", "response"] unchanged, so Hook, InterceptorResult, and the invoke/execute params keep rejecting both.
  • Add a test asserting that phase="both" produces exactly two hook entries and that Hook(phase="both") still fails validation.

This is purely additive and has no wire impact.

Non-goal

Accepting both on the wire. Hook matching in chain.py should keep comparing against request/response only — the expansion happens at registration time, not at dispatch time.

Related: cross-SDK divergence

Worth tracking separately, but it's the reason this came up. The Go SDK takes the dispatch-time approach rather than the expansion approach: PhaseBoth is a value of InterceptionPhase, which is the type of the JSON-serialized Hook.Phase field, and matchesHooks in chain/chain.go treats PhaseBoth as matching any target phase. That means a Go interceptor declaring both serializes "phase": "both" in its interceptors/list response, which the SEP disallows and which the Python and C# SDKs would reject when parsing. Filing a separate issue for that.

cc @PederHP @Degiorgio @jeongukjae

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.