Skip to content

Commit 68b9c7e

Browse files
committed
Add the pluggable Extension API to MCPServer
Introduce `Extension`, a narrow base class (HTTPX `Transport`/`Auth` style) whose methods default so an extension overrides only what it needs: `settings()`, `tools()`, `resources()`, `methods()`, and `intercept_tool_call()`. `MCPServer` accepts `extensions=[...]` at construction and `add_extension()` later, applying a closed set of contributions (tool/resource/method bindings) and composing every extension's `tools/call` interceptor into one `ServerMiddleware`. The server never hands itself to an extension; the extension declares what it adds as data.
1 parent 2c7fc6e commit 68b9c7e

2 files changed

Lines changed: 424 additions & 0 deletions

File tree

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
"""Pluggable extension interface for `MCPServer` (SEP-2133).
2+
3+
An extension is a self-contained, opt-in bundle of MCP behaviour, identified by
4+
a reverse-DNS string (e.g. `io.modelcontextprotocol/ui`). It is passed at
5+
construction - `MCPServer(..., extensions=[Apps(), Tasks(store)])` - and the
6+
server applies a *closed* set of contribution kinds: tools, resources, new
7+
request methods, and one `tools/call` interceptor. The server never hands itself
8+
to an extension; the extension declares what it adds, and the server consumes it.
9+
10+
The shape follows the HTTPX `Transport`/`Auth` pattern: a narrow base class
11+
whose methods have sensible defaults, so an extension overrides only what it
12+
needs. A purely additive extension (Apps) overrides `tools`/`resources`; an
13+
interceptive one (Tasks) overrides `methods`/`intercept_tool_call`.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
from collections.abc import Awaitable, Callable, Sequence
19+
from dataclasses import dataclass, field
20+
from typing import Any
21+
22+
from mcp_types import CallToolRequestParams
23+
from pydantic import BaseModel
24+
25+
from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext
26+
from mcp.server.mcpserver.resources import Resource
27+
28+
RequestHandler = Callable[[ServerRequestContext[Any, Any], Any], Awaitable[HandlerResult]]
29+
30+
31+
@dataclass(frozen=True)
32+
class ToolBinding:
33+
"""A tool an extension contributes, plus the `_meta` to stamp on it."""
34+
35+
fn: Callable[..., Any]
36+
meta: dict[str, Any] | None = None
37+
kwargs: dict[str, Any] = field(default_factory=lambda: {})
38+
39+
40+
@dataclass(frozen=True)
41+
class ResourceBinding:
42+
"""A pre-built resource an extension contributes."""
43+
44+
resource: Resource
45+
46+
47+
@dataclass(frozen=True)
48+
class MethodBinding:
49+
"""A new request method an extension serves, e.g. `tasks/get`.
50+
51+
`params_type` validates incoming params before `handler` runs; it should
52+
subclass `RequestParams` so `_meta` parses uniformly.
53+
"""
54+
55+
method: str
56+
params_type: type[BaseModel]
57+
handler: RequestHandler
58+
59+
60+
class Extension:
61+
"""Base class for an opt-in MCP extension. Override only the methods you need.
62+
63+
Subclass and set `identifier`, then override the contribution methods that
64+
apply. Every method has a default, so a minimal extension overrides nothing
65+
but `identifier` and one of `tools`/`resources`/`methods`.
66+
"""
67+
68+
#: Reverse-DNS extension identifier, advertised under `ServerCapabilities.extensions`.
69+
identifier: str
70+
71+
def settings(self) -> dict[str, Any]:
72+
"""Per-extension settings advertised at `capabilities.extensions[identifier]`.
73+
74+
An empty dict (the default) advertises the extension with no settings.
75+
"""
76+
return {}
77+
78+
def tools(self) -> Sequence[ToolBinding]:
79+
"""Tools this extension contributes (additive)."""
80+
return ()
81+
82+
def resources(self) -> Sequence[ResourceBinding]:
83+
"""Resources this extension contributes (additive)."""
84+
return ()
85+
86+
def methods(self) -> Sequence[MethodBinding]:
87+
"""New request methods this extension serves (additive)."""
88+
return ()
89+
90+
async def intercept_tool_call(
91+
self,
92+
params: CallToolRequestParams,
93+
ctx: ServerRequestContext[Any, Any],
94+
call_next: CallNext,
95+
) -> HandlerResult:
96+
"""Wrap `tools/call`. Default: pass through unchanged.
97+
98+
Override to short-circuit (return a result without calling `call_next`)
99+
or to observe the call. `params` is the validated `tools/call` params;
100+
`call_next(ctx)` runs the rest of the chain and the real handler.
101+
"""
102+
return await call_next(ctx)
103+
104+
105+
def compose_tool_call_interceptor(extensions: Sequence[Extension]) -> ServerMiddleware[Any]:
106+
"""Fold every extension's `intercept_tool_call` into one `ServerMiddleware`.
107+
108+
The returned middleware nests the interceptors (first extension outermost)
109+
and is a no-op for any method other than `tools/call`. It validates the
110+
`tools/call` params once and threads them to each interceptor.
111+
"""
112+
113+
async def middleware(ctx: ServerRequestContext[Any, Any], call_next: CallNext) -> HandlerResult:
114+
if ctx.method != "tools/call":
115+
return await call_next(ctx)
116+
params = CallToolRequestParams.model_validate({} if ctx.params is None else ctx.params, by_name=False)
117+
118+
chain = call_next
119+
for extension in reversed(extensions):
120+
chain = _bind_interceptor(extension, params, chain)
121+
return await chain(ctx)
122+
123+
return middleware
124+
125+
126+
def _bind_interceptor(extension: Extension, params: CallToolRequestParams, call_next: CallNext) -> CallNext:
127+
async def call(ctx: ServerRequestContext[Any, Any]) -> HandlerResult:
128+
return await extension.intercept_tool_call(params, ctx, call_next)
129+
130+
return call

0 commit comments

Comments
 (0)