MCP Python SDK extension method collisions are configuration defects, not runtime edge cases. If two extensions claim the same method—or one claims a core MCP method—the server should reject that setup before it accepts a request. I prefer making method ownership an executable contract so registration order can never decide which handler wins.
The official MCP Python SDK extension documentation defines three useful safeguards: core methods cannot be registered as extension methods, duplicate extension methods are rejected during registration, and every binding must declare at least one supported protocol version.
An extension adds vendor-specific behavior to the same dispatch table used by the rest of the server. That makes method names part of the server's public contract.
Consider two independently configured extensions that both expose:
com.example/catalog.search
A last-write-wins registry would make the active handler depend on extension order. Reordering configuration could silently change request behavior without changing the client call.
The safer contract is one owner per method name. In the sample, a valid extension starts normally, while a second extension claiming the same method causes MCPServer construction to raise ValueError.
Core protocol methods have an even stronger boundary. A vendor extension must not replace methods such as tools/list. The SDK's MCP extension core method guard rejects that binding when MethodBinding is constructed.
The sample targets protocol version 2026-07-28, announced in the project's final MCP release post, and pins the stable mcp==2.1.1 package so the checks are reproducible.
MethodBinding
I start with a namespaced method and an explicit protocol-version set:
PROTOCOL_VERSION = "2026-07-28"
EXTENSION_ID = "com.example/catalog"
METHOD = "com.example/catalog.search"
def search_binding(method: str = METHOD) -> MethodBinding:
return MethodBinding(
method,
SearchParams,
search,
protocol_versions=frozenset({PROTOCOL_VERSION}),
)
The reverse-domain prefix keeps the vendor method separate from core MCP names. More importantly, protocol_versions states exactly where the binding is reachable.
That protocol version validation prevents a subtle configuration mistake. An empty set describes a method that cannot be used under any protocol version, so the SDK rejects it immediately:
def build_unreachable_binding() -> MethodBinding:
return MethodBinding(
METHOD,
SearchParams,
search,
protocol_versions=frozenset(),
)
The valid extension returns one binding:
class CatalogSearch(Extension):
identifier = EXTENSION_ID
def methods(self) -> Sequence[MethodBinding]:
return [search_binding()]
A second extension deliberately returns the same method name:
class ShadowSearch(Extension):
identifier = "com.example/catalog-shadow"
def methods(self) -> Sequence[MethodBinding]:
return [search_binding()]
Neither class is inherently invalid in isolation. The collision appears when both are registered with one server:
MCPServer(
"extension-contract",
extensions=[CatalogSearch(), ShadowSearch()],
)
This is the MethodBinding duplicate-method boundary I want to test: the server registry sees two owners and refuses to start.
Checking this boundary during construction keeps the failure close to the configuration that caused it. A deployment never reaches the point where the first unlucky request discovers an ambiguous handler. It also makes the regression test independent of extension ordering: swapping the two classes cannot turn the failure into success. In a larger server, I would keep these ownership tests beside the composition root where optional packages are assembled.
The runnable sample checks one valid path and three invalid configurations. Its valid case uses the SDK's in-memory client, so it does not open a port or require an external MCP host:
server = build_valid_server()
async with Client(
server,
extensions=[advertise(EXTENSION_ID)],
) as client:
request = SearchRequest(params=SearchParams(query="mcp"))
result = await client.session.send_request(
request,
SearchResult,
)
The handler returns deterministic values:
["mcp-0", "mcp-1"]
The positive request is as important as the rejection cases. It proves the namespaced method remains callable when it has one owner, the client advertises the extension identifier, and the typed result survives the same registry being guarded. Without that control case, a test could pass simply because every extension path was broken.
The remaining checks construct a duplicate server, attempt to bind tools/list, and create a binding with an empty version set. The verifier catches ValueError only to assert the boundary; production startup should let those errors stop the process.
Run the full validation with:
uv sync --all-groups
uv lock --check
uv run ruff format --check .
uv run ruff check .
uv run mypy extension_contract.py verify.py test_extension_contract.py
uv run python -m compileall -q extension_contract.py verify.py test_extension_contract.py
uv run python -m unittest -v
uv run python verify.py
uv run pip-audit
The deterministic verifier reports:
[PASS] unique vendor method starts normally
[PASS] typed request keeps the vendor method
[PASS] duplicate method fails during server construction
[PASS] core MCP method cannot be claimed
[PASS] empty protocol version set is rejected
5/5 checks passed
The merged changes and validation record are also available in the sample pull request.
This sample verifies construction and in-memory request dispatch. It does not test stdio, Streamable HTTP, authentication, extension result claims, or notification bindings.
It also pins exact exception-message fragments for SDK 2.1.1. If I were supporting several SDK releases, I would make the exception type and offending method the durable assertions, then keep message checks narrow enough to tolerate wording changes.
These checks are most useful when a server composes extensions from multiple packages or configuration sources. For a small server with no extensions, the extra contract tests may add little value. They also complement integration tests rather than replacing transport and authorization coverage.
How are you testing extension method ownership before your MCP server accepts traffic?
Happy building!