Add a pluggable server extension API with MCP Apps (#3003)
Co-authored-by: Max Isbey <224885523+maxisbey@users.noreply.github.com>
This commit is contained in:
parent
fe9723853d
commit
4b519782f1
11 changed files with 2598 additions and 24 deletions
0
docs_src/apps/__init__.py
Normal file
0
docs_src/apps/__init__.py
Normal file
3
docs_src/apps/report.html
Normal file
3
docs_src/apps/report.html
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<!doctype html>
|
||||
<title>Report</title>
|
||||
<p>Quarterly numbers render here.</p>
|
||||
38
docs_src/apps/tutorial001.py
Normal file
38
docs_src/apps/tutorial001.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
from mcp import Client
|
||||
from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID, Apps, client_supports_apps
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
from mcp.server.mcpserver.context import Context
|
||||
|
||||
CLOCK_HTML = """\
|
||||
<!doctype html>
|
||||
<title>Clock</title>
|
||||
<h1 id="now">...</h1>
|
||||
<script>
|
||||
window.addEventListener("message", (event) => {
|
||||
const text = event.data?.result?.content?.[0]?.text;
|
||||
if (text) document.getElementById("now").textContent = text;
|
||||
});
|
||||
</script>
|
||||
"""
|
||||
|
||||
apps = Apps()
|
||||
|
||||
|
||||
@apps.tool(resource_uri="ui://clock/app.html", description="The current time.")
|
||||
def get_time(ctx: Context) -> str:
|
||||
now = "2026-06-26T12:00:00Z"
|
||||
if not client_supports_apps(ctx):
|
||||
return f"The time is {now}."
|
||||
return now
|
||||
|
||||
|
||||
apps.add_html_resource("ui://clock/app.html", CLOCK_HTML, title="Clock")
|
||||
|
||||
mcp = MCPServer("clock", extensions=[apps])
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
async with Client(mcp, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as client:
|
||||
result = await client.call_tool("get_time", {})
|
||||
print(result.content)
|
||||
# [TextContent(text='2026-06-26T12:00:00Z')]
|
||||
25
docs_src/apps/tutorial002.py
Normal file
25
docs_src/apps/tutorial002.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
from mcp.server.apps import Apps, ResourceCsp, ResourcePermissions
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
|
||||
DASHBOARD_HTML = "<!doctype html><title>Dashboard</title><canvas id='chart'></canvas>"
|
||||
|
||||
apps = Apps()
|
||||
|
||||
|
||||
@apps.tool(resource_uri="ui://dashboard/app.html", visibility=["app"])
|
||||
def refresh_dashboard() -> str:
|
||||
"""Refresh the dashboard data."""
|
||||
return "refreshed"
|
||||
|
||||
|
||||
apps.add_html_resource(
|
||||
"ui://dashboard/app.html",
|
||||
DASHBOARD_HTML,
|
||||
title="Dashboard",
|
||||
csp=ResourceCsp(connect_domains=["https://api.example.com"]),
|
||||
permissions=ResourcePermissions(clipboard_write={}),
|
||||
domain="dashboard.example.com",
|
||||
prefers_border=True,
|
||||
)
|
||||
|
||||
mcp = MCPServer("dashboard", extensions=[apps])
|
||||
20
docs_src/apps/tutorial003.py
Normal file
20
docs_src/apps/tutorial003.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
from pathlib import Path
|
||||
|
||||
from mcp.server.apps import Apps
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
from mcp.server.mcpserver.resources import FileResource
|
||||
|
||||
REPORT_HTML = Path(__file__).parent / "report.html"
|
||||
|
||||
apps = Apps()
|
||||
|
||||
|
||||
@apps.tool(resource_uri="ui://report/app.html")
|
||||
def refresh_report() -> str:
|
||||
"""Refresh the report data."""
|
||||
return "report refreshed"
|
||||
|
||||
|
||||
apps.add_resource(FileResource(uri="ui://report/app.html", name="report", path=REPORT_HTML))
|
||||
|
||||
mcp = MCPServer("report", extensions=[apps])
|
||||
0
docs_src/extensions/__init__.py
Normal file
0
docs_src/extensions/__init__.py
Normal file
4
docs_src/extensions/tutorial001.py
Normal file
4
docs_src/extensions/tutorial001.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from mcp.server.apps import Apps
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
|
||||
mcp = MCPServer("demo", extensions=[Apps()])
|
||||
5
docs_src/extensions/tutorial002.py
Normal file
5
docs_src/extensions/tutorial002.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from mcp.server.extension import Extension
|
||||
|
||||
|
||||
class Stamps(Extension):
|
||||
identifier = "com.example/stamps"
|
||||
35
docs_src/extensions/tutorial003.py
Normal file
35
docs_src/extensions/tutorial003.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
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')]
|
||||
58
docs_src/extensions/tutorial004.py
Normal file
58
docs_src/extensions/tutorial004.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
from collections.abc import Sequence
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
import mcp_types as types
|
||||
from pydantic import Field
|
||||
|
||||
from mcp import Client
|
||||
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={EXTENSION_ID: {}}) as client:
|
||||
request = SearchRequest(params=SearchParams(query="mcp", limit=3))
|
||||
result = await client.session.send_request(cast("types.ClientRequest", request), SearchResult)
|
||||
print(result.items)
|
||||
# ['mcp-0', 'mcp-1', 'mcp-2']
|
||||
34
docs_src/extensions/tutorial005.py
Normal file
34
docs_src/extensions/tutorial005.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import logging
|
||||
from typing import Any
|
||||
|
||||
from mcp_types import CallToolRequestParams
|
||||
|
||||
from mcp.server.context import CallNext, HandlerResult, ServerRequestContext
|
||||
from mcp.server.extension import Extension
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
|
||||
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
|
||||
Loading…
Reference in a new issue