Extensions
Traduction automatique
Cette page a été traduite automatiquement à partir de la documentation en anglais, et la page en anglais fait foi. Si quelque chose vous semble incorrect, la page Traductions explique comment le signaler.
Une extension est un ensemble de comportements MCP, activable sur demande, regroupé derrière un seul identifiant.
Côté serveur, elle peut apporter des outils (tools), des ressources et de nouvelles méthodes de requête, et elle peut envelopper tools/call. Côté client, elle peut revendiquer des formes de résultat tools/call supplémentaires et observer des notifications propres à un éditeur. Chaque côté s’annonce sous son propre capabilities.extensions, et rien ne change pour quiconque ne l’a pas demandé. C’est le contrat (SEP-2133), et il a une règle d’or : les extensions sont désactivées par défaut.
Utiliser une extension
Passez des instances à la construction :
from mcp.server.apps import Apps
from mcp.server.mcpserver import MCPServer
mcp = MCPServer("demo", extensions=[Apps()])
C’est fait. Le serveur annonce désormais io.modelcontextprotocol/ui sous capabilities.extensions et sert tout ce que l’extension apporte.
Apps est l’extension de référence intégrée, et elle a sa propre page : MCP Apps.
Note
Les extensions sont figées à la construction. Il n’existe pas de add_extension à appeler plus tard : la table des capacités d’un serveur ne devrait pas changer pendant que des clients y sont connectés.
La table des capacités transite par server/discover, qui est un chemin 2026-07-28. Une poignée de main (handshake) initialize historique n’a aucun endroit où la placer, donc un client historique ne voit tout simplement pas l’extension. Concevez en conséquence : une extension enrichit un serveur, elle ne doit pas être la seule manière de le rendre utilisable.
Écrire la vôtre
Dérivez Extension et ne redéfinissez que ce dont vous avez besoin. Chaque méthode a une valeur par défaut.
L’identifiant
from mcp.server.extension import Extension
class Stamps(Extension):
identifier = "com.example/stamps"
L’identifiant est une chaîne vendor-prefix/name qui suit la grammaire des clés _meta de la spécification : des libellés séparés par des points (chacun commence par une lettre et se termine par une lettre ou un chiffre), une barre oblique, puis le nom. Il est validé au moment où la classe est définie, de sorte qu’une faute de frappe n’attend pas le démarrage d’un serveur :
TypeError: Stamps.identifier must be a `vendor-prefix/name` string
(reverse-DNS prefix required), got 'stamps'
Utilisez comme préfixe un domaine que vous contrôlez. io.modelcontextprotocol/* est réservé aux extensions spécifiées par le projet MCP lui-même.
Apporter des outils
La plus petite extension utile, c’est un outil et une table de paramètres :
from collections.abc import Sequence
from typing import Any
from mcp import Client
from mcp.server.extension import Extension, ToolBinding
from mcp.server.mcpserver import MCPServer
def stamp(text: str) -> str:
"""Stamp a message with the office seal."""
return f"[stamped] {text}"
class Stamps(Extension):
"""A purely additive extension: one tool, one capability entry."""
identifier = "com.example/stamps"
def settings(self) -> dict[str, Any]:
return {"sealed": True}
def tools(self) -> Sequence[ToolBinding]:
return [ToolBinding(fn=stamp)]
mcp = MCPServer("post-office", extensions=[Stamps()])
async def main() -> None:
async with Client(mcp) as client:
print(client.server_capabilities.extensions)
# {'com.example/stamps': {'sealed': True}}
result = await client.call_tool("stamp", {"text": "hello"})
print(result.content)
# [TextContent(text='[stamped] hello')]
tools()renvoie desToolBinding. Le serveur enregistre chacun exactement comme si vous aviez appelémcp.add_tool(...)vous-même : même génération de schéma, même injection deContext, tout à l’identique.settings()est la valeur annoncée souscapabilities.extensions["com.example/stamps"]. Renvoyez{}(la valeur par défaut) pour annoncer l’extension sans paramètres.- L’extension ne reçoit jamais le serveur. Elle déclare ses contributions sous forme de données ;
MCPServerles consomme. Il n’y a pas deself.serverà modifier.
Et main() en est la preuve, un client en mémoire branché directement sur mcp :
from collections.abc import Sequence
from typing import Any
from mcp import Client
from mcp.server.extension import Extension, ToolBinding
from mcp.server.mcpserver import MCPServer
def stamp(text: str) -> str:
"""Stamp a message with the office seal."""
return f"[stamped] {text}"
class Stamps(Extension):
"""A purely additive extension: one tool, one capability entry."""
identifier = "com.example/stamps"
def settings(self) -> dict[str, Any]:
return {"sealed": True}
def tools(self) -> Sequence[ToolBinding]:
return [ToolBinding(fn=stamp)]
mcp = MCPServer("post-office", extensions=[Stamps()])
async def main() -> None:
async with Client(mcp) as client:
print(client.server_capabilities.extensions)
# {'com.example/stamps': {'sealed': True}}
result = await client.call_tool("stamp", {"text": "hello"})
print(result.content)
# [TextContent(text='[stamped] hello')]
Servir vos propres méthodes
Une extension peut enregistrer de nouvelles méthodes de requête : ses propres verbes, servis à côté de ceux de la spécification :
from collections.abc import Sequence
from typing import Any, Literal
from pydantic import Field
import mcp.types as types
from mcp import Client
from mcp.client import advertise
from mcp.server.context import ServerRequestContext
from mcp.server.extension import Extension, MethodBinding
from mcp.server.mcpserver import MCPServer, require_client_extension
EXTENSION_ID = "com.example/search"
class SearchParams(types.RequestParams):
query: str
limit: int = Field(default=10, ge=1, le=100)
class SearchResult(types.Result):
items: list[str]
class SearchRequest(types.Request[SearchParams, Literal["com.example/search"]]):
method: Literal["com.example/search"] = "com.example/search"
params: SearchParams
async def search(ctx: ServerRequestContext[Any, Any], params: SearchParams) -> SearchResult:
require_client_extension(ctx, EXTENSION_ID)
return SearchResult(items=[f"{params.query}-{n}" for n in range(params.limit)])
class Search(Extension):
"""An extension that serves its own request method."""
identifier = EXTENSION_ID
def methods(self) -> Sequence[MethodBinding]:
return [
MethodBinding(
"com.example/search",
SearchParams,
search,
protocol_versions=frozenset({"2026-07-28"}),
)
]
mcp = MCPServer("catalog", extensions=[Search()])
async def main() -> None:
async with Client(mcp, extensions=[advertise(EXTENSION_ID)]) as client:
request = SearchRequest(params=SearchParams(query="mcp", limit=3))
result = await client.session.send_request(request, SearchResult)
print(result.items)
# ['mcp-0', 'mcp-1', 'mcp-2']
SearchParamsdérive deRequestParams, si bien que l’enveloppe_metade 2026 est analysée de façon uniforme et que votre gestionnaire (handler) reçoit des paramètres validés, jamais un dict brut. Bornez ce que le client contrôle :Field(ge=1, le=100)rejette unlimitabsurde avant que votre code n’alloue quoi que ce soit pour lui.require_client_extension(ctx, EXTENSION_ID)est le garde-fou : un client qui n’a pas déclaré l’extension reçoit l’erreur-32021(capacité client obligatoire manquante), avec la charge utilerequiredCapabilitieslisible par machine que la spécification demande.protocol_versions=frozenset({"2026-07-28"})épingle la méthode à une seule version de la liaison. Dans toute autre version, le client reçoitMETHOD_NOT_FOUND, exactement comme si la méthode n’y existait pas. Pour ce client, elle n’existe pas.
Les méthodes sont strictement additives. Le SDK le fait respecter à la construction, pas à l’exécution :
- Un
MethodBindingpour une méthode définie par la spécification (tools/list,completion/complete…) lève uneValueErrorlors de la construction du binding. Les verbes de base appartiennent au serveur. - Deux extensions qui lient la même méthode lèvent une exception quand la seconde s’enregistre. Laisser la dernière écriture l’emporter, c’est ainsi que des plugins se corrompent mutuellement ; nous ne faisons pas cela.
- Un ensemble
protocol_versionsvide lève aussi une exception : une méthode qui ne peut jamais être servie est un bogue, pas une configuration.
Le côté client
Le main() du même fichier raconte toute l’histoire côté client, ses deux moitiés :
from collections.abc import Sequence
from typing import Any, Literal
from pydantic import Field
import mcp.types as types
from mcp import Client
from mcp.client import advertise
from mcp.server.context import ServerRequestContext
from mcp.server.extension import Extension, MethodBinding
from mcp.server.mcpserver import MCPServer, require_client_extension
EXTENSION_ID = "com.example/search"
class SearchParams(types.RequestParams):
query: str
limit: int = Field(default=10, ge=1, le=100)
class SearchResult(types.Result):
items: list[str]
class SearchRequest(types.Request[SearchParams, Literal["com.example/search"]]):
method: Literal["com.example/search"] = "com.example/search"
params: SearchParams
async def search(ctx: ServerRequestContext[Any, Any], params: SearchParams) -> SearchResult:
require_client_extension(ctx, EXTENSION_ID)
return SearchResult(items=[f"{params.query}-{n}" for n in range(params.limit)])
class Search(Extension):
"""An extension that serves its own request method."""
identifier = EXTENSION_ID
def methods(self) -> Sequence[MethodBinding]:
return [
MethodBinding(
"com.example/search",
SearchParams,
search,
protocol_versions=frozenset({"2026-07-28"}),
)
]
mcp = MCPServer("catalog", extensions=[Search()])
async def main() -> None:
async with Client(mcp, extensions=[advertise(EXTENSION_ID)]) as client:
request = SearchRequest(params=SearchParams(query="mcp", limit=3))
result = await client.session.send_request(request, SearchResult)
print(result.items)
# ['mcp-0', 'mcp-1', 'mcp-2']
Client(..., extensions=[advertise(EXTENSION_ID)])déclare l’extension. Les déclarations deviennentClientCapabilities.extensions: sur une connexion 2026-07-28, la table voyage dans l’enveloppe_metade chaque requête, donc le serveur la voit sur chaque requête ; sur une connexion historique, elle transite par la poignée de maininitialize. Le code serveur ne s’en soucie pas :require_client_extension(ctx, ...)etctx.session.check_client_capability(...)lisent la bonne source dans les deux cas.- Les méthodes propres à un éditeur descendent d’un niveau, vers
client.session.send_request(...);Clientn’acquiert de méthodes de premier rang que pour les verbes de la spécification.send_requestaccepte n’importe quelle sous-classe deRequest, donc la requête de l’éditeur passe telle quelle.
Intercepter tools/call
Le seul hook d’interception. Redéfinissez intercept_tool_call pour observer, court-circuiter ou opposer un veto à un appel d’outil :
import logging
from typing import Any
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
from mcp.server.extension import Extension
from mcp.server.mcpserver import MCPServer
from mcp.types import CallToolRequestParams
logger = logging.getLogger(__name__)
class AuditLog(Extension):
"""Observe every tools/call without touching its result."""
identifier = "com.example/audit"
async def intercept_tool_call(
self,
params: CallToolRequestParams,
ctx: ServerRequestContext[Any, Any],
call_next: CallNext,
) -> HandlerResult:
logger.info("tool %r called", params.name)
return await call_next(ctx)
mcp = MCPServer("audited", extensions=[AuditLog()])
@mcp.tool()
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
paramsest leCallToolRequestParamsvalidé : vous obtenezparams.nameetparams.argumentssans toucher au JSON brut. C’est aussi lui qui décide quel appel d’outil s’exécute : passer un contexte réécrit àcall_nextchange ce que le gestionnaire observe surctx, pas l’invocation de l’outil. La réécriture de requêtes au niveau de la liaison relève du Middleware.call_next(ctx)exécute le reste de la chaîne et renvoie le résultat du gestionnaire. Renvoyez-le tel quel (observer), renvoyez autre chose (remplacer) ou levez uneMCPError(refuser). Ce que vous renvoyez est sérialisé comme n’importe quel résultat de gestionnaire, y compris l’estampille d’identitéserverInfode la génération 2026, si bien qu’un intercepteur qui court-circuite ne produit jamais de réponse anonyme ou hors schéma.- Avec plusieurs extensions, les intercepteurs s’imbriquent dans l’ordre d’enregistrement : la première extension de
extensions=[...]est la plus externe. - L’implémentation par défaut laisse passer sans rien faire, et un serveur dont les extensions ne redéfinissent jamais ce hook conserve le gestionnaire
tools/callnu, intact. Vous ne payez pas pour ce que vous n’utilisez pas.
Le hook enveloppe tools/call et rien d’autre. Pour ce qui concerne chaque message, utilisez le Middleware. Il est fait pour cela.
Utiliser une extension client
Une extension client, c’est le même contrat vu du côté consommateur : un ensemble de comportements côté client derrière un seul identifiant. Passez des instances à Client(extensions=[...]) et appelez les outils normalement :
from collections.abc import Sequence
from typing import Any, Literal
import mcp.types as types
from mcp import Client
from mcp.client import ClaimContext, ClientExtension, ResultClaim
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
from mcp.server.extension import Extension
from mcp.server.mcpserver import MCPServer, require_client_extension
EXTENSION_ID = "com.example/receipts"
class ReceiptResult(types.Result):
"""The claimed result shape; `result_type` pins the wire tag."""
result_type: Literal["receipt"] = "receipt"
receipt_token: str
class ReceiptIssuer(Extension):
"""Server half: answers `buy` with a receipt instead of a final result."""
identifier = EXTENSION_ID
async def intercept_tool_call(
self,
params: types.CallToolRequestParams,
ctx: ServerRequestContext[Any, Any],
call_next: CallNext,
) -> HandlerResult:
if params.name != "buy":
return await call_next(ctx)
require_client_extension(ctx, EXTENSION_ID)
return {"resultType": "receipt", "receiptToken": "r-117"}
class Receipts(ClientExtension):
"""Client half: claims the `receipt` shape and supplies the code that finishes it."""
identifier = EXTENSION_ID
def claims(self) -> Sequence[ResultClaim[Any]]:
return [ResultClaim(result_type="receipt", model=ReceiptResult, resolve=self._redeem)]
async def _redeem(self, claimed: ReceiptResult, ctx: ClaimContext) -> types.CallToolResult:
return await ctx.session.call_tool("redeem", {"token": claimed.receipt_token})
mcp = MCPServer("shop", extensions=[ReceiptIssuer()])
@mcp.tool()
def buy(item: str) -> types.CallToolResult:
"""Buy an item."""
raise NotImplementedError # ReceiptIssuer answers `buy` before the tool runs
@mcp.tool()
def redeem(token: str) -> str:
"""Exchange a receipt token for the goods."""
return f"goods for {token}"
async def main() -> None:
async with Client(mcp, extensions=[Receipts()]) as client:
result = await client.call_tool("buy", {"item": "lamp"})
print(result.content)
# [TextContent(text='goods for r-117')]
call_tool("buy", ...) renvoie un simple CallToolResult, comme tout autre appel. Ce que l’extension a changé : le serveur peut désormais répondre à buy par une forme de résultat receipt au lieu d’un résultat final, et Receipts la termine (ici en échangeant le reçu via un appel de suivi) avant que call_tool ne renvoie. Rien ne bouge au point d’appel.
Retirez l’extension et rien de tout cela n’existe : le garde-fou du serveur refuse un client qui ne l’a pas déclarée (erreur -32021), et une forme revendiquée venant d’un serveur qui saute le garde-fou échoue à la validation, exactement comme la spécification l’exige pour un resultType non reconnu. Désactivé par défaut, aux deux bouts de la liaison.
Pour annoncer un identifiant sans aucun comportement côté client (le serveur filtre sur la capacité, le client ne fait rien, comme dans le client de recherche ci-dessus), utilisez advertise() :
from mcp.client import advertise
client = Client(mcp, extensions=[advertise("com.example/search")])
Écrire une extension client
Dérivez ClientExtension et ne redéfinissez que ce dont vous avez besoin. Trois types de contributions, chacun avec une valeur par défaut : settings(), claims() et notifications().
from collections.abc import Sequence
from typing import Any, Literal
import mcp.types as types
from mcp import Client
from mcp.client import ClaimContext, ClientExtension, ResultClaim
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
from mcp.server.extension import Extension
from mcp.server.mcpserver import MCPServer, require_client_extension
EXTENSION_ID = "com.example/receipts"
class ReceiptResult(types.Result):
"""The claimed result shape; `result_type` pins the wire tag."""
result_type: Literal["receipt"] = "receipt"
receipt_token: str
class ReceiptIssuer(Extension):
"""Server half: answers `buy` with a receipt instead of a final result."""
identifier = EXTENSION_ID
async def intercept_tool_call(
self,
params: types.CallToolRequestParams,
ctx: ServerRequestContext[Any, Any],
call_next: CallNext,
) -> HandlerResult:
if params.name != "buy":
return await call_next(ctx)
require_client_extension(ctx, EXTENSION_ID)
return {"resultType": "receipt", "receiptToken": "r-117"}
class Receipts(ClientExtension):
"""Client half: claims the `receipt` shape and supplies the code that finishes it."""
identifier = EXTENSION_ID
def claims(self) -> Sequence[ResultClaim[Any]]:
return [ResultClaim(result_type="receipt", model=ReceiptResult, resolve=self._redeem)]
async def _redeem(self, claimed: ReceiptResult, ctx: ClaimContext) -> types.CallToolResult:
return await ctx.session.call_tool("redeem", {"token": claimed.receipt_token})
mcp = MCPServer("shop", extensions=[ReceiptIssuer()])
@mcp.tool()
def buy(item: str) -> types.CallToolResult:
"""Buy an item."""
raise NotImplementedError # ReceiptIssuer answers `buy` before the tool runs
@mcp.tool()
def redeem(token: str) -> str:
"""Exchange a receipt token for the goods."""
return f"goods for {token}"
async def main() -> None:
async with Client(mcp, extensions=[Receipts()]) as client:
result = await client.call_tool("buy", {"item": "lamp"})
print(result.content)
# [TextContent(text='goods for r-117')]
- L’identifiant suit la même grammaire que celui du serveur, validé au moment où la classe est définie.
claims()renvoie desResultClaim: une étiquette de liaison, le modèle qui l’analyse et le résolveur qui la termine. Le modèle doit épingler l’étiquette avecresult_type: Literal["receipt"]et ne doit pas dériver des types de résultat de base du verbe ; les deux sont vérifiés à la construction du claim. Les champs d’éditeur commereceipt_tokenvoyagent tels quels sur la liaison : une forme substituée parvient au client à l’identique.- Le résolveur reçoit le modèle analysé et un
ClaimContext;ctx.sessionest le même point d’accès public queclient.session, donc les appels de suivi sont des appels de session ordinaires. Il renvoie leCallToolResultnormal du verbe. settings()est la valeur annoncée sousClientCapabilities.extensions[identifier], lue une fois à la construction deClient.
notifications() déclare les notifications serveur d’éditeur à observer :
def notifications(self) -> Sequence[NotificationBinding[Any]]:
return [NotificationBinding(method="notifications/receipts", params_type=ReceiptEvent, handler=self.on_receipt)]
Le gestionnaire reçoit des paramètres validés un par un, dans l’ordre de distribution. Il observe ; il ne peut ni opposer de veto ni répondre.
Deux règles discrètes. Les claims ne sont actifs que sur les connexions 2026-07-28, et l’annonce des capacités les suit : sur une connexion historique, les claims se dissolvent et l’identifiant disparaît de l’annonce avec eux, si bien que le client n’annonce jamais une extension dont il rejetterait les formes. Et lorsque vous voulez la forme revendiquée elle-même plutôt que le résolveur, appelez client.session.call_tool(..., allow_claimed=True) ; sans ce drapeau, une forme revendiquée qui atteint un appelant au niveau session lève UnexpectedClaimedResult.
Verbes d’extension
Les méthodes de requête propres à une extension n’ont besoin d’aucun enregistrement côté client. Un type de requête d’éditeur dérive de mcp.types.Request et passe par client.session.send_request, comme dans Servir vos propres méthodes. Un ajout : lorsqu’une clé des paramètres doit transiter par l’en-tête Mcp-Name (des spécifications d’extension comme tasks l’exigent pour leurs verbes), le type de requête déclare name_param :
from collections.abc import Sequence
from typing import Any, Literal
import mcp.types as types
from mcp import Client
from mcp.client import advertise
from mcp.server.context import ServerRequestContext
from mcp.server.extension import Extension, MethodBinding
from mcp.server.mcpserver import MCPServer
EXTENSION_ID = "com.example/jobs"
class JobParams(types.RequestParams):
job_id: str
class JobStatus(types.Result):
status: str
class JobStatusRequest(types.Request[JobParams, Literal["com.example/jobs.status"]]):
method: Literal["com.example/jobs.status"] = "com.example/jobs.status"
params: JobParams
name_param = "jobId" # params["jobId"] rides the Mcp-Name header
async def job_status(ctx: ServerRequestContext[Any, Any], params: JobParams) -> JobStatus:
return JobStatus(status=f"{params.job_id} is running")
class Jobs(Extension):
"""An extension whose verb names its subject, so the header can route on it."""
identifier = EXTENSION_ID
def methods(self) -> Sequence[MethodBinding]:
return [MethodBinding("com.example/jobs.status", JobParams, job_status)]
mcp = MCPServer("worker", extensions=[Jobs()])
async def main() -> None:
async with Client(mcp, extensions=[advertise(EXTENSION_ID)]) as client:
request = JobStatusRequest(params=JobParams(job_id="job-7"))
result = await client.session.send_request(request, JobStatus)
print(result.status)
# job-7 is running
La session reflète params["jobId"] dans Mcp-Name sur chaque chemin d’envoi, et une valeur manquante échoue bruyamment au lieu d’omettre silencieusement un en-tête obligatoire.
Ce qu’une extension ne peut pas faire
La surface de contribution est fermée à dessein. Côté serveur : paramètres, outils, ressources, méthodes, un intercepteur tools/call. Côté client : paramètres, claims de résultat, bindings de notification. Une extension ne peut pas :
- Atteindre l’hôte. Elle déclare des données ; elle ne détient aucune référence au serveur ni au client.
- Remplacer le comportement de base. Les méthodes de la spécification et les étiquettes de résultat de base sont rejetées à la construction (
initializeest purement et simplement réservé par le runner) ; un binding de notification masqué par le vocabulaire de base se tait avec un avertissement à la place. - S’enregistrer tardivement. Une fois que
MCPServer(...)ouClient(...)a renvoyé, l’ensemble des extensions est ce qu’il est.
Si vous vous battez contre ces murs, vous n’écrivez pas une extension. Vous écrivez un fork. Les murs sont la fonctionnalité : un utilisateur qui lit extensions=[Apps(), Stamps()] sait tout ce que ces deux-là ont pu toucher.