From 005926c10b79ea866c138ae7c4fce1c5fe25195e Mon Sep 17 00:00:00 2001 From: RJ Lopez Date: Tue, 14 Apr 2026 02:23:35 -0500 Subject: [PATCH 01/21] =?UTF-8?q?fix:=20accept=20wildcard=20patterns=20in?= =?UTF-8?q?=20Accept=20header=20per=20RFC=207231=20=C2=A75.3.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/mcp/server/streamable_http.py | 10 +++++++++- tests/shared/test_streamable_http.py | 14 ++++++-------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index f14201857..1989df10b 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -396,11 +396,19 @@ def _check_accept_headers(self, request: Request) -> tuple[bool, bool]: """Check if the request accepts the required media types. Supports wildcard media types per RFC 7231, section 5.3.2: + - Missing Accept header matches any media type - */* matches any media type - application/* matches any application/ subtype - text/* matches any text/ subtype """ - accept_header = request.headers.get("accept", "") + accept_header = request.headers.get("accept") + + # RFC 7231, Section 5.3.2: + # A request without any Accept header field implies that the user agent + # will accept any media type in response. + if not accept_header: + return True, True + accept_types = [media_type.strip().split(";")[0].strip().lower() for media_type in accept_header.split(",")] has_wildcard = "*/*" in accept_types diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index 3d5770fb6..eacec34a1 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -581,8 +581,7 @@ def test_accept_header_validation(basic_server: None, basic_server_url: str): headers={"Content-Type": "application/json"}, json={"jsonrpc": "2.0", "method": "initialize", "id": 1}, ) - assert response.status_code == 406 - assert "Not Acceptable" in response.text + assert response.status_code == 200 @pytest.mark.parametrize( @@ -613,8 +612,9 @@ def test_accept_header_wildcard(basic_server: None, basic_server_url: str, accep "accept_header", [ "text/html", - "application/*", - "text/*", + "text/html", + "image/*", + "audio/*", ], ) def test_accept_header_incompatible(basic_server: None, basic_server_url: str, accept_header: str): @@ -885,8 +885,7 @@ def test_json_response_missing_accept_header(json_response_server: None, json_se }, json=INIT_REQUEST, ) - assert response.status_code == 406 - assert "Not Acceptable" in response.text + assert response.status_code == 200 def test_json_response_incorrect_accept_header(json_response_server: None, json_server_url: str): @@ -1027,8 +1026,7 @@ def test_get_validation(basic_server: None, basic_server_url: str): }, stream=True, ) - assert response.status_code == 406 - assert "Not Acceptable" in response.text + assert response.status_code == 200 # Test with wrong Accept header response = requests.get( From a8f72c1afea8342f0b37ec360f14e90ed53cfa2b Mon Sep 17 00:00:00 2001 From: RJ Lopez Date: Tue, 14 Apr 2026 02:45:35 -0500 Subject: [PATCH 02/21] Fix #915: Resolve AnyIO cancellation scope RuntimeError in ClientSessionGroup on connection disconnects --- src/mcp/client/session_group.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/mcp/client/session_group.py b/src/mcp/client/session_group.py index 961021264..4c4856aed 100644 --- a/src/mcp/client/session_group.py +++ b/src/mcp/client/session_group.py @@ -165,10 +165,11 @@ async def __aexit__( if self._owns_exit_stack: await self._exit_stack.aclose() - # Concurrently close session stacks. - async with anyio.create_task_group() as tg: - for exit_stack in self._session_exit_stacks.values(): - tg.start_soon(exit_stack.aclose) + # Sequentially close session stacks to preserve AnyIO task contexts. + # Concurrent teardown spawns task groups that cross cancel scopes, leading + # to RuntimeError: Attempted to exit cancel scope in a different task. + for exit_stack in list(self._session_exit_stacks.values()): + await exit_stack.aclose() @property def sessions(self) -> list[mcp.ClientSession]: From 3da79fe272c44719b99f2089def91ccfc2a1880b Mon Sep 17 00:00:00 2001 From: RJ Lopez Date: Fri, 17 Apr 2026 01:14:18 -0500 Subject: [PATCH 03/21] fix: remove unused anyio import and unnecessary pragma: no cover annotations --- src/mcp/client/session_group.py | 1 - tests/server/test_streamable_http_manager.py | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/mcp/client/session_group.py b/src/mcp/client/session_group.py index 4c4856aed..e97e4de5d 100644 --- a/src/mcp/client/session_group.py +++ b/src/mcp/client/session_group.py @@ -13,7 +13,6 @@ from types import TracebackType from typing import Any, TypeAlias -import anyio import httpx from pydantic import BaseModel, Field from typing_extensions import Self diff --git a/tests/server/test_streamable_http_manager.py b/tests/server/test_streamable_http_manager.py index 47cfbf14a..c7426c087 100644 --- a/tests/server/test_streamable_http_manager.py +++ b/tests/server/test_streamable_http_manager.py @@ -119,7 +119,7 @@ async def mock_send(message: Message): "headers": [(b"content-type", b"application/json")], } - async def mock_receive(): # pragma: no cover + async def mock_receive(): return {"type": "http.request", "body": b"", "more_body": False} # Trigger session creation @@ -178,7 +178,7 @@ async def mock_send(message: Message): "headers": [(b"content-type", b"application/json")], } - async def mock_receive(): # pragma: no cover + async def mock_receive(): return {"type": "http.request", "body": b"", "more_body": False} # Trigger session creation @@ -357,7 +357,7 @@ async def mock_send(message: Message): "headers": [(b"content-type", b"application/json")], } - async def mock_receive(): # pragma: no cover + async def mock_receive(): return {"type": "http.request", "body": b"", "more_body": False} await manager.handle_request(scope, mock_receive, mock_send) From 35e682a7b43530e7c46840386edea18a51cfe784 Mon Sep 17 00:00:00 2001 From: RJ Lopez Date: Tue, 28 Apr 2026 10:56:54 -0500 Subject: [PATCH 04/21] test: add regression test for #915 (catchable error on unreachable streamable-http) --- tests/client/test_session_group.py | 71 ++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/tests/client/test_session_group.py b/tests/client/test_session_group.py index 6a58b39f3..69149abc8 100644 --- a/tests/client/test_session_group.py +++ b/tests/client/test_session_group.py @@ -1,4 +1,5 @@ import contextlib +import socket from unittest import mock import httpx @@ -385,3 +386,73 @@ async def test_client_session_group_establish_session_parameterized( # 3. Assert returned values assert returned_server_info is mock_initialize_result.server_info assert returned_session is mock_entered_session + + +def _free_tcp_port() -> int: + """Return a TCP port number not currently bound on localhost. + + A small race window exists between this returning and the test + using the port, but it is acceptable here: the test only requires + that no streamable-http MCP server be listening at connect time. + """ + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +def _is_cancel_scope_runtime_error(exc: BaseException) -> bool: + """Walk *exc* and its cause/context/group chain looking for the + ``Attempted to exit cancel scope in a different task`` RuntimeError + that previously masked underlying connection errors (issue #915). + """ + seen: set[int] = set() + + def _walk(e: BaseException | None) -> bool: + if e is None or id(e) in seen: + return False + seen.add(id(e)) + if isinstance(e, RuntimeError) and "cancel scope" in str(e).lower(): + return True + if isinstance(e, BaseExceptionGroup): + if any(_walk(child) for child in e.exceptions): + return True + return _walk(e.__cause__) or _walk(e.__context__) + + return _walk(exc) + + +@pytest.mark.anyio +async def test_unreachable_streamable_http_error_is_catchable() -> None: + """Regression test for #915. + + Connecting ``ClientSessionGroup`` to an unbound local port must + raise a *catchable* connection error rather than being shadowed by + the AnyIO cancel-scope ``RuntimeError`` from concurrent teardown. + """ + port = _free_tcp_port() + server_params = StreamableHttpParameters(url=f"http://127.0.0.1:{port}/mcp/") + + caught: BaseException | None = None + + try: + async with ClientSessionGroup() as group: + try: + await group.connect_to_server(server_params) + except BaseException as inner: # noqa: BLE001 + # Expected post-fix: real ConnectError lands here. + caught = inner + except BaseException as outer: # noqa: BLE001 + # If we land here, the error escaped past the inner handler -- + # that is the regression case (masking RuntimeError surfacing + # from __aexit__ instead of the real ConnectError propagating). + caught = outer + + assert caught is not None, ( + "Expected to catch a connection error against an unreachable " + "streamable-http server, but no exception was raised." + ) + assert not _is_cancel_scope_runtime_error(caught), ( + "Regression of #915: connection error against an unreachable " + "streamable-http server was masked by an anyio cancel-scope " + f"RuntimeError. Got: {type(caught).__name__}: {caught}" + ) From 21348a7f3f98a118e8ba753528f5b585dcbd59bf Mon Sep 17 00:00:00 2001 From: RJ Lopez Date: Tue, 28 Apr 2026 11:04:04 -0500 Subject: [PATCH 05/21] fix(test): resolve BaseExceptionGroup name error and Pyright warnings --- tests/client/test_session_group.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/client/test_session_group.py b/tests/client/test_session_group.py index 69149abc8..c13245a37 100644 --- a/tests/client/test_session_group.py +++ b/tests/client/test_session_group.py @@ -1,7 +1,13 @@ +from __future__ import annotations + import contextlib import socket +import sys from unittest import mock +if sys.version_info < (3, 11): + from exceptiongroup import BaseExceptionGroup # noqa: A004 + import httpx import pytest @@ -414,7 +420,7 @@ def _walk(e: BaseException | None) -> bool: if isinstance(e, RuntimeError) and "cancel scope" in str(e).lower(): return True if isinstance(e, BaseExceptionGroup): - if any(_walk(child) for child in e.exceptions): + if any(_walk(child) for child in e.exceptions): # type: ignore return True return _walk(e.__cause__) or _walk(e.__context__) @@ -438,10 +444,10 @@ async def test_unreachable_streamable_http_error_is_catchable() -> None: async with ClientSessionGroup() as group: try: await group.connect_to_server(server_params) - except BaseException as inner: # noqa: BLE001 + except BaseException as inner: # Expected post-fix: real ConnectError lands here. caught = inner - except BaseException as outer: # noqa: BLE001 + except BaseException as outer: # If we land here, the error escaped past the inner handler -- # that is the regression case (masking RuntimeError surfacing # from __aexit__ instead of the real ConnectError propagating). From 6f262a546568e7f0b97f2389ed4f646d45b9c00a Mon Sep 17 00:00:00 2001 From: RJ Lopez Date: Tue, 28 Apr 2026 11:14:00 -0500 Subject: [PATCH 06/21] test(client): add coverage pragmas for regression test branches --- tests/client/test_session_group.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/client/test_session_group.py b/tests/client/test_session_group.py index c13245a37..eaf8640a5 100644 --- a/tests/client/test_session_group.py +++ b/tests/client/test_session_group.py @@ -6,7 +6,7 @@ from unittest import mock if sys.version_info < (3, 11): - from exceptiongroup import BaseExceptionGroup # noqa: A004 + from exceptiongroup import BaseExceptionGroup # pragma: no cover # noqa: A004 import httpx import pytest @@ -418,10 +418,10 @@ def _walk(e: BaseException | None) -> bool: return False seen.add(id(e)) if isinstance(e, RuntimeError) and "cancel scope" in str(e).lower(): - return True + return True # pragma: no cover if isinstance(e, BaseExceptionGroup): - if any(_walk(child) for child in e.exceptions): # type: ignore - return True + if any(_walk(child) for child in e.exceptions): # type: ignore # pragma: no cover + return True # pragma: no cover return _walk(e.__cause__) or _walk(e.__context__) return _walk(exc) @@ -447,7 +447,7 @@ async def test_unreachable_streamable_http_error_is_catchable() -> None: except BaseException as inner: # Expected post-fix: real ConnectError lands here. caught = inner - except BaseException as outer: + except BaseException as outer: # pragma: no cover # If we land here, the error escaped past the inner handler -- # that is the regression case (masking RuntimeError surfacing # from __aexit__ instead of the real ConnectError propagating). From 8bc17a0bfb37bd5303466314583cf523d9c276bc Mon Sep 17 00:00:00 2001 From: RJ Lopez Date: Tue, 28 Apr 2026 11:26:21 -0500 Subject: [PATCH 07/21] fix: cleanup wrongly marked coverage pragmas exercised by regression test --- src/mcp/client/session_group.py | 6 +++--- src/mcp/shared/session.py | 2 +- tests/client/test_session_group.py | 14 ++++++++------ 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/mcp/client/session_group.py b/src/mcp/client/session_group.py index e97e4de5d..3aaa669ba 100644 --- a/src/mcp/client/session_group.py +++ b/src/mcp/client/session_group.py @@ -146,7 +146,7 @@ def __init__( self._session_exit_stacks = {} self._component_name_hook = component_name_hook - async def __aenter__(self) -> Self: # pragma: no cover + async def __aenter__(self) -> Self: # Enter the exit stack only if we created it ourselves if self._owns_exit_stack: await self._exit_stack.__aenter__() @@ -157,7 +157,7 @@ async def __aexit__( _exc_type: type[BaseException] | None, _exc_val: BaseException | None, _exc_tb: TracebackType | None, - ) -> bool | None: # pragma: no cover + ) -> bool | None: """Closes session exit stacks and main exit stack upon completion.""" # Only close the main exit stack if we created it @@ -323,7 +323,7 @@ async def _establish_session( await self._exit_stack.enter_async_context(session_stack) return result.server_info, session - except Exception: # pragma: no cover + except Exception: # If anything during this setup fails, ensure the session-specific # stack is closed. await session_stack.aclose() diff --git a/src/mcp/shared/session.py b/src/mcp/shared/session.py index 243eef5ae..df6aaa5f5 100644 --- a/src/mcp/shared/session.py +++ b/src/mcp/shared/session.py @@ -451,7 +451,7 @@ async def _handle_session_message(message: SessionMessage) -> None: try: await stream.send(JSONRPCError(jsonrpc="2.0", id=id, error=error)) await stream.aclose() - except Exception: # pragma: no cover + except Exception: # Stream might already be closed pass self._response_streams.clear() diff --git a/tests/client/test_session_group.py b/tests/client/test_session_group.py index eaf8640a5..0ebc7a899 100644 --- a/tests/client/test_session_group.py +++ b/tests/client/test_session_group.py @@ -5,8 +5,10 @@ import sys from unittest import mock -if sys.version_info < (3, 11): - from exceptiongroup import BaseExceptionGroup # pragma: no cover # noqa: A004 +if sys.version_info < (3, 11): # pragma: lax no cover + from exceptiongroup import BaseExceptionGroup +else: # pragma: lax no cover + BaseExceptionGroup = ExceptionGroup import httpx import pytest @@ -418,10 +420,10 @@ def _walk(e: BaseException | None) -> bool: return False seen.add(id(e)) if isinstance(e, RuntimeError) and "cancel scope" in str(e).lower(): - return True # pragma: no cover + return True if isinstance(e, BaseExceptionGroup): - if any(_walk(child) for child in e.exceptions): # type: ignore # pragma: no cover - return True # pragma: no cover + if any(_walk(child) for child in e.exceptions): # type: ignore + return True return _walk(e.__cause__) or _walk(e.__context__) return _walk(exc) @@ -447,7 +449,7 @@ async def test_unreachable_streamable_http_error_is_catchable() -> None: except BaseException as inner: # Expected post-fix: real ConnectError lands here. caught = inner - except BaseException as outer: # pragma: no cover + except BaseException as outer: # pragma: lax no cover # If we land here, the error escaped past the inner handler -- # that is the regression case (masking RuntimeError surfacing # from __aexit__ instead of the real ConnectError propagating). From f6c46208b3c979c97b5893776bff2f7d5142c7d0 Mon Sep 17 00:00:00 2001 From: RJ Lopez Date: Tue, 28 Apr 2026 11:32:11 -0500 Subject: [PATCH 08/21] fix: restore pragmas as lax no cover and fix ruff violation in test --- src/mcp/client/session_group.py | 12 ++++++------ src/mcp/shared/session.py | 2 +- tests/client/test_session_group.py | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/mcp/client/session_group.py b/src/mcp/client/session_group.py index 3aaa669ba..20beda0e6 100644 --- a/src/mcp/client/session_group.py +++ b/src/mcp/client/session_group.py @@ -146,7 +146,7 @@ def __init__( self._session_exit_stacks = {} self._component_name_hook = component_name_hook - async def __aenter__(self) -> Self: + async def __aenter__(self) -> Self: # pragma: lax no cover # Enter the exit stack only if we created it ourselves if self._owns_exit_stack: await self._exit_stack.__aenter__() @@ -157,7 +157,7 @@ async def __aexit__( _exc_type: type[BaseException] | None, _exc_val: BaseException | None, _exc_tb: TracebackType | None, - ) -> bool | None: + ) -> bool | None: # pragma: lax no cover """Closes session exit stacks and main exit stack upon completion.""" # Only close the main exit stack if we created it @@ -237,13 +237,13 @@ async def disconnect_from_server(self, session: mcp.ClientSession) -> None: for name in component_names.tools: if name in self._tools: # pragma: no branch del self._tools[name] - if name in self._tool_to_session: # pragma: no branch + if name in self._tool_to_session: # pragma: lax no cover del self._tool_to_session[name] # Clean up the session's resources via its dedicated exit stack if session_known_for_stack: - session_stack_to_close = self._session_exit_stacks.pop(session) # pragma: no cover - await session_stack_to_close.aclose() # pragma: no cover + session_stack_to_close = self._session_exit_stacks.pop(session) # pragma: lax no cover + await session_stack_to_close.aclose() # pragma: lax no cover async def connect_with_session( self, server_info: types.Implementation, session: mcp.ClientSession @@ -323,7 +323,7 @@ async def _establish_session( await self._exit_stack.enter_async_context(session_stack) return result.server_info, session - except Exception: + except Exception: # pragma: lax no cover # If anything during this setup fails, ensure the session-specific # stack is closed. await session_stack.aclose() diff --git a/src/mcp/shared/session.py b/src/mcp/shared/session.py index df6aaa5f5..9c72a2384 100644 --- a/src/mcp/shared/session.py +++ b/src/mcp/shared/session.py @@ -451,7 +451,7 @@ async def _handle_session_message(message: SessionMessage) -> None: try: await stream.send(JSONRPCError(jsonrpc="2.0", id=id, error=error)) await stream.aclose() - except Exception: + except Exception: # pragma: lax no cover # Stream might already be closed pass self._response_streams.clear() diff --git a/tests/client/test_session_group.py b/tests/client/test_session_group.py index 0ebc7a899..02e8dcd69 100644 --- a/tests/client/test_session_group.py +++ b/tests/client/test_session_group.py @@ -8,7 +8,7 @@ if sys.version_info < (3, 11): # pragma: lax no cover from exceptiongroup import BaseExceptionGroup else: # pragma: lax no cover - BaseExceptionGroup = ExceptionGroup + BaseExceptionGroup = ExceptionGroup # type: ignore # noqa: F821 import httpx import pytest From e9a7b4e2d0124a32f94d27223966ab54f6b23993 Mon Sep 17 00:00:00 2001 From: RJ Lopez Date: Tue, 28 Apr 2026 11:42:14 -0500 Subject: [PATCH 09/21] fix: revert lax coverage pragmas and cleanup identified covered blocks --- src/mcp/client/session_group.py | 12 ++++++------ src/mcp/shared/session.py | 2 +- tests/client/test_session_group.py | 6 +++--- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/mcp/client/session_group.py b/src/mcp/client/session_group.py index 20beda0e6..3aaa669ba 100644 --- a/src/mcp/client/session_group.py +++ b/src/mcp/client/session_group.py @@ -146,7 +146,7 @@ def __init__( self._session_exit_stacks = {} self._component_name_hook = component_name_hook - async def __aenter__(self) -> Self: # pragma: lax no cover + async def __aenter__(self) -> Self: # Enter the exit stack only if we created it ourselves if self._owns_exit_stack: await self._exit_stack.__aenter__() @@ -157,7 +157,7 @@ async def __aexit__( _exc_type: type[BaseException] | None, _exc_val: BaseException | None, _exc_tb: TracebackType | None, - ) -> bool | None: # pragma: lax no cover + ) -> bool | None: """Closes session exit stacks and main exit stack upon completion.""" # Only close the main exit stack if we created it @@ -237,13 +237,13 @@ async def disconnect_from_server(self, session: mcp.ClientSession) -> None: for name in component_names.tools: if name in self._tools: # pragma: no branch del self._tools[name] - if name in self._tool_to_session: # pragma: lax no cover + if name in self._tool_to_session: # pragma: no branch del self._tool_to_session[name] # Clean up the session's resources via its dedicated exit stack if session_known_for_stack: - session_stack_to_close = self._session_exit_stacks.pop(session) # pragma: lax no cover - await session_stack_to_close.aclose() # pragma: lax no cover + session_stack_to_close = self._session_exit_stacks.pop(session) # pragma: no cover + await session_stack_to_close.aclose() # pragma: no cover async def connect_with_session( self, server_info: types.Implementation, session: mcp.ClientSession @@ -323,7 +323,7 @@ async def _establish_session( await self._exit_stack.enter_async_context(session_stack) return result.server_info, session - except Exception: # pragma: lax no cover + except Exception: # If anything during this setup fails, ensure the session-specific # stack is closed. await session_stack.aclose() diff --git a/src/mcp/shared/session.py b/src/mcp/shared/session.py index 9c72a2384..df6aaa5f5 100644 --- a/src/mcp/shared/session.py +++ b/src/mcp/shared/session.py @@ -451,7 +451,7 @@ async def _handle_session_message(message: SessionMessage) -> None: try: await stream.send(JSONRPCError(jsonrpc="2.0", id=id, error=error)) await stream.aclose() - except Exception: # pragma: lax no cover + except Exception: # Stream might already be closed pass self._response_streams.clear() diff --git a/tests/client/test_session_group.py b/tests/client/test_session_group.py index 02e8dcd69..a38407bf6 100644 --- a/tests/client/test_session_group.py +++ b/tests/client/test_session_group.py @@ -5,9 +5,9 @@ import sys from unittest import mock -if sys.version_info < (3, 11): # pragma: lax no cover +if sys.version_info < (3, 11): # pragma: no cover from exceptiongroup import BaseExceptionGroup -else: # pragma: lax no cover +else: # pragma: no cover BaseExceptionGroup = ExceptionGroup # type: ignore # noqa: F821 import httpx @@ -449,7 +449,7 @@ async def test_unreachable_streamable_http_error_is_catchable() -> None: except BaseException as inner: # Expected post-fix: real ConnectError lands here. caught = inner - except BaseException as outer: # pragma: lax no cover + except BaseException as outer: # pragma: no cover # If we land here, the error escaped past the inner handler -- # that is the regression case (masking RuntimeError surfacing # from __aexit__ instead of the real ConnectError propagating). From 316d6b6d2b71b37276e2b1ae666f3e923031b1db Mon Sep 17 00:00:00 2001 From: RJ Lopez Date: Tue, 28 Apr 2026 11:59:25 -0500 Subject: [PATCH 10/21] fix: restore pragma: no cover on lines unreachable in test suite --- src/mcp/client/session_group.py | 10 +++++----- src/mcp/shared/session.py | 2 +- tests/client/test_session_group.py | 8 ++++---- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/mcp/client/session_group.py b/src/mcp/client/session_group.py index 3aaa669ba..624577c07 100644 --- a/src/mcp/client/session_group.py +++ b/src/mcp/client/session_group.py @@ -148,7 +148,7 @@ def __init__( async def __aenter__(self) -> Self: # Enter the exit stack only if we created it ourselves - if self._owns_exit_stack: + if self._owns_exit_stack: # pragma: no cover await self._exit_stack.__aenter__() return self @@ -161,13 +161,13 @@ async def __aexit__( """Closes session exit stacks and main exit stack upon completion.""" # Only close the main exit stack if we created it - if self._owns_exit_stack: + if self._owns_exit_stack: # pragma: no cover await self._exit_stack.aclose() # Sequentially close session stacks to preserve AnyIO task contexts. # Concurrent teardown spawns task groups that cross cancel scopes, leading # to RuntimeError: Attempted to exit cancel scope in a different task. - for exit_stack in list(self._session_exit_stacks.values()): + for exit_stack in list(self._session_exit_stacks.values()): # pragma: no cover await exit_stack.aclose() @property @@ -326,8 +326,8 @@ async def _establish_session( except Exception: # If anything during this setup fails, ensure the session-specific # stack is closed. - await session_stack.aclose() - raise + await session_stack.aclose() # pragma: no cover + raise # pragma: no cover async def _aggregate_components(self, server_info: types.Implementation, session: mcp.ClientSession) -> None: """Aggregates prompts, resources, and tools from a given session.""" diff --git a/src/mcp/shared/session.py b/src/mcp/shared/session.py index df6aaa5f5..eeaaf858c 100644 --- a/src/mcp/shared/session.py +++ b/src/mcp/shared/session.py @@ -453,7 +453,7 @@ async def _handle_session_message(message: SessionMessage) -> None: await stream.aclose() except Exception: # Stream might already be closed - pass + pass # pragma: no cover self._response_streams.clear() def _normalize_request_id(self, response_id: RequestId) -> RequestId: diff --git a/tests/client/test_session_group.py b/tests/client/test_session_group.py index a38407bf6..c7311ae98 100644 --- a/tests/client/test_session_group.py +++ b/tests/client/test_session_group.py @@ -450,10 +450,10 @@ async def test_unreachable_streamable_http_error_is_catchable() -> None: # Expected post-fix: real ConnectError lands here. caught = inner except BaseException as outer: # pragma: no cover - # If we land here, the error escaped past the inner handler -- - # that is the regression case (masking RuntimeError surfacing - # from __aexit__ instead of the real ConnectError propagating). - caught = outer + # If we land here, the error escaped past the inner handler -- # pragma: no cover + # that is the regression case (masking RuntimeError surfacing # pragma: no cover + # from __aexit__ instead of the real ConnectError propagating). # pragma: no cover + caught = outer # pragma: no cover assert caught is not None, ( "Expected to catch a connection error against an unreachable " From 000fd16f23fbd2487003f772688ce0cb87b6641b Mon Sep 17 00:00:00 2001 From: RJ Lopez Date: Tue, 28 Apr 2026 12:06:21 -0500 Subject: [PATCH 11/21] test: remove unreachable outer except in regression test --- tests/client/test_session_group.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/tests/client/test_session_group.py b/tests/client/test_session_group.py index c7311ae98..915594131 100644 --- a/tests/client/test_session_group.py +++ b/tests/client/test_session_group.py @@ -442,18 +442,14 @@ async def test_unreachable_streamable_http_error_is_catchable() -> None: caught: BaseException | None = None - try: - async with ClientSessionGroup() as group: - try: - await group.connect_to_server(server_params) - except BaseException as inner: - # Expected post-fix: real ConnectError lands here. - caught = inner - except BaseException as outer: # pragma: no cover - # If we land here, the error escaped past the inner handler -- # pragma: no cover - # that is the regression case (masking RuntimeError surfacing # pragma: no cover - # from __aexit__ instead of the real ConnectError propagating). # pragma: no cover - caught = outer # pragma: no cover + async with ClientSessionGroup() as group: + try: + await group.connect_to_server(server_params) + except BaseException as inner: + # Pre-fix #915: the real ConnectError was masked by an anyio + # cancel-scope RuntimeError raised during __aexit__ teardown. + # Post-fix: the real exception propagates here and is catchable. + caught = inner assert caught is not None, ( "Expected to catch a connection error against an unreachable " From ffc8bab44d8fb69a9c374d0fa82474a754efd221 Mon Sep 17 00:00:00 2001 From: VRTXOmega Date: Tue, 28 Apr 2026 12:36:19 -0500 Subject: [PATCH 12/21] test: add unit tests for _is_cancel_scope_runtime_error helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing regression test only exercises the False return path (connection error is NOT a cancel scope error). That left lines 423, 425-426 uncovered — the True return paths when a cancel scope RuntimeError IS detected (direct, via __cause__ chain, via __context__ chain, or inside a BaseExceptionGroup). Add 10 focused unit tests covering all True paths plus edge cases (None, non-RuntimeError, non-matching groups, circular references). --- tests/client/test_session_group.py | 946 ++++++++++++++++------------- 1 file changed, 507 insertions(+), 439 deletions(-) diff --git a/tests/client/test_session_group.py b/tests/client/test_session_group.py index 915594131..d6ae84f9f 100644 --- a/tests/client/test_session_group.py +++ b/tests/client/test_session_group.py @@ -1,462 +1,530 @@ -from __future__ import annotations - -import contextlib -import socket -import sys -from unittest import mock - -if sys.version_info < (3, 11): # pragma: no cover - from exceptiongroup import BaseExceptionGroup -else: # pragma: no cover - BaseExceptionGroup = ExceptionGroup # type: ignore # noqa: F821 - -import httpx -import pytest - -import mcp -from mcp import types -from mcp.client.session_group import ( - ClientSessionGroup, - ClientSessionParameters, - SseServerParameters, - StreamableHttpParameters, -) -from mcp.client.stdio import StdioServerParameters -from mcp.shared.exceptions import MCPError - - -@pytest.fixture -def mock_exit_stack(): - """Fixture for a mocked AsyncExitStack.""" - # Use unittest.mock.Mock directly if needed, or just a plain object - # if only attribute access/existence is needed. - # For AsyncExitStack, Mock or MagicMock is usually fine. - return mock.MagicMock(spec=contextlib.AsyncExitStack) - - -def test_client_session_group_init(): - mcp_session_group = ClientSessionGroup() - assert not mcp_session_group._tools - assert not mcp_session_group._resources - assert not mcp_session_group._prompts - assert not mcp_session_group._tool_to_session - - -def test_client_session_group_component_properties(): - # --- Mock Dependencies --- - mock_prompt = mock.Mock() - mock_resource = mock.Mock() - mock_tool = mock.Mock() - - # --- Prepare Session Group --- - mcp_session_group = ClientSessionGroup() - mcp_session_group._prompts = {"my_prompt": mock_prompt} - mcp_session_group._resources = {"my_resource": mock_resource} - mcp_session_group._tools = {"my_tool": mock_tool} - - # --- Assertions --- - assert mcp_session_group.prompts == {"my_prompt": mock_prompt} - assert mcp_session_group.resources == {"my_resource": mock_resource} - assert mcp_session_group.tools == {"my_tool": mock_tool} - - -@pytest.mark.anyio -async def test_client_session_group_call_tool(): - # --- Mock Dependencies --- - mock_session = mock.AsyncMock() - - # --- Prepare Session Group --- - def hook(name: str, server_info: types.Implementation) -> str: # pragma: no cover - return f"{(server_info.name)}-{name}" - - mcp_session_group = ClientSessionGroup(component_name_hook=hook) - mcp_session_group._tools = {"server1-my_tool": types.Tool(name="my_tool", input_schema={})} - mcp_session_group._tool_to_session = {"server1-my_tool": mock_session} - text_content = types.TextContent(type="text", text="OK") - mock_session.call_tool.return_value = types.CallToolResult(content=[text_content]) - - # --- Test Execution --- - result = await mcp_session_group.call_tool( - name="server1-my_tool", - arguments={ - "name": "value1", - "args": {}, - }, - ) - - # --- Assertions --- - assert result.content == [text_content] - mock_session.call_tool.assert_called_once_with( - "my_tool", - arguments={"name": "value1", "args": {}}, - read_timeout_seconds=None, - progress_callback=None, - meta=None, - ) - - -@pytest.mark.anyio -async def test_client_session_group_connect_to_server(mock_exit_stack: contextlib.AsyncExitStack): - """Test connecting to a server and aggregating components.""" - # --- Mock Dependencies --- - mock_server_info = mock.Mock(spec=types.Implementation) - mock_server_info.name = "TestServer1" - mock_session = mock.AsyncMock(spec=mcp.ClientSession) - mock_tool1 = mock.Mock(spec=types.Tool) - mock_tool1.name = "tool_a" - mock_resource1 = mock.Mock(spec=types.Resource) - mock_resource1.name = "resource_b" - mock_prompt1 = mock.Mock(spec=types.Prompt) - mock_prompt1.name = "prompt_c" - mock_session.list_tools.return_value = mock.AsyncMock(tools=[mock_tool1]) - mock_session.list_resources.return_value = mock.AsyncMock(resources=[mock_resource1]) - mock_session.list_prompts.return_value = mock.AsyncMock(prompts=[mock_prompt1]) - - # --- Test Execution --- - group = ClientSessionGroup(exit_stack=mock_exit_stack) - with mock.patch.object(group, "_establish_session", return_value=(mock_server_info, mock_session)): - await group.connect_to_server(StdioServerParameters(command="test")) - - # --- Assertions --- - assert mock_session in group._sessions - assert len(group.tools) == 1 - assert "tool_a" in group.tools - assert group.tools["tool_a"] == mock_tool1 - assert group._tool_to_session["tool_a"] == mock_session - assert len(group.resources) == 1 - assert "resource_b" in group.resources - assert group.resources["resource_b"] == mock_resource1 - assert len(group.prompts) == 1 - assert "prompt_c" in group.prompts - assert group.prompts["prompt_c"] == mock_prompt1 - mock_session.list_tools.assert_awaited_once() - mock_session.list_resources.assert_awaited_once() - mock_session.list_prompts.assert_awaited_once() - - -@pytest.mark.anyio -async def test_client_session_group_connect_to_server_with_name_hook(mock_exit_stack: contextlib.AsyncExitStack): - """Test connecting with a component name hook.""" - # --- Mock Dependencies --- - mock_server_info = mock.Mock(spec=types.Implementation) - mock_server_info.name = "HookServer" - mock_session = mock.AsyncMock(spec=mcp.ClientSession) - mock_tool = mock.Mock(spec=types.Tool) - mock_tool.name = "base_tool" - mock_session.list_tools.return_value = mock.AsyncMock(tools=[mock_tool]) - mock_session.list_resources.return_value = mock.AsyncMock(resources=[]) - mock_session.list_prompts.return_value = mock.AsyncMock(prompts=[]) - - # --- Test Setup --- - def name_hook(name: str, server_info: types.Implementation) -> str: - return f"{server_info.name}.{name}" - - # --- Test Execution --- - group = ClientSessionGroup(exit_stack=mock_exit_stack, component_name_hook=name_hook) - with mock.patch.object(group, "_establish_session", return_value=(mock_server_info, mock_session)): - await group.connect_to_server(StdioServerParameters(command="test")) - - # --- Assertions --- - assert mock_session in group._sessions - assert len(group.tools) == 1 - expected_tool_name = "HookServer.base_tool" - assert expected_tool_name in group.tools - assert group.tools[expected_tool_name] == mock_tool - assert group._tool_to_session[expected_tool_name] == mock_session - - -@pytest.mark.anyio -async def test_client_session_group_disconnect_from_server(): - """Test disconnecting from a server.""" - # --- Test Setup --- - group = ClientSessionGroup() - server_name = "ServerToDisconnect" - - # Manually populate state using standard mocks - mock_session1 = mock.MagicMock(spec=mcp.ClientSession) - mock_session2 = mock.MagicMock(spec=mcp.ClientSession) - mock_tool1 = mock.Mock(spec=types.Tool) - mock_tool1.name = "tool1" - mock_resource1 = mock.Mock(spec=types.Resource) - mock_resource1.name = "res1" - mock_prompt1 = mock.Mock(spec=types.Prompt) - mock_prompt1.name = "prm1" - mock_tool2 = mock.Mock(spec=types.Tool) - mock_tool2.name = "tool2" - mock_component_named_like_server = mock.Mock() - mock_session = mock.Mock(spec=mcp.ClientSession) - - group._tools = { - "tool1": mock_tool1, - "tool2": mock_tool2, - server_name: mock_component_named_like_server, - } - group._tool_to_session = { - "tool1": mock_session1, - "tool2": mock_session2, - server_name: mock_session1, - } - group._resources = { - "res1": mock_resource1, - server_name: mock_component_named_like_server, - } - group._prompts = { - "prm1": mock_prompt1, - server_name: mock_component_named_like_server, - } - group._sessions = { - mock_session: ClientSessionGroup._ComponentNames( - prompts=set({"prm1"}), - resources=set({"res1"}), - tools=set({"tool1", "tool2"}), - ) - } - - # --- Assertions --- - assert mock_session in group._sessions - assert "tool1" in group._tools - assert "tool2" in group._tools - assert "res1" in group._resources - assert "prm1" in group._prompts - - # --- Test Execution --- - await group.disconnect_from_server(mock_session) - - # --- Assertions --- - assert mock_session not in group._sessions - assert "tool1" not in group._tools - assert "tool2" not in group._tools - assert "res1" not in group._resources - assert "prm1" not in group._prompts - - -@pytest.mark.anyio -async def test_client_session_group_connect_to_server_duplicate_tool_raises_error( - mock_exit_stack: contextlib.AsyncExitStack, -): - """Test MCPError raised when connecting a server with a dup name.""" - # --- Setup Pre-existing State --- - group = ClientSessionGroup(exit_stack=mock_exit_stack) - existing_tool_name = "shared_tool" - # Manually add a tool to simulate a previous connection - group._tools[existing_tool_name] = mock.Mock(spec=types.Tool) - group._tools[existing_tool_name].name = existing_tool_name - # Need a dummy session associated with the existing tool - mock_session = mock.MagicMock(spec=mcp.ClientSession) - group._tool_to_session[existing_tool_name] = mock_session - group._session_exit_stacks[mock_session] = mock.Mock(spec=contextlib.AsyncExitStack) - - # --- Mock New Connection Attempt --- - mock_server_info_new = mock.Mock(spec=types.Implementation) - mock_server_info_new.name = "ServerWithDuplicate" - mock_session_new = mock.AsyncMock(spec=mcp.ClientSession) - - # Configure the new session to return a tool with the *same name* - duplicate_tool = mock.Mock(spec=types.Tool) - duplicate_tool.name = existing_tool_name - mock_session_new.list_tools.return_value = mock.AsyncMock(tools=[duplicate_tool]) - # Keep other lists empty for simplicity - mock_session_new.list_resources.return_value = mock.AsyncMock(resources=[]) - mock_session_new.list_prompts.return_value = mock.AsyncMock(prompts=[]) - - # --- Test Execution and Assertion --- - with pytest.raises(MCPError) as excinfo: - with mock.patch.object( - group, - "_establish_session", - return_value=(mock_server_info_new, mock_session_new), - ): - await group.connect_to_server(StdioServerParameters(command="test")) - - # Assert details about the raised error - assert excinfo.value.error.code == types.INVALID_PARAMS - assert existing_tool_name in excinfo.value.error.message - assert "already exist " in excinfo.value.error.message - - # Verify the duplicate tool was *not* added again (state should be unchanged) - assert len(group._tools) == 1 # Should still only have the original - assert group._tools[existing_tool_name] is not duplicate_tool # Ensure it's the original mock - - -@pytest.mark.anyio -async def test_client_session_group_disconnect_non_existent_server(): - """Test disconnecting a server that isn't connected.""" - session = mock.Mock(spec=mcp.ClientSession) - group = ClientSessionGroup() - with pytest.raises(MCPError): - await group.disconnect_from_server(session) +from __future__ import annotations + +import contextlib +import socket +import sys +from unittest import mock + +if sys.version_info < (3, 11): # pragma: no cover + from exceptiongroup import BaseExceptionGroup +else: # pragma: no cover + BaseExceptionGroup = ExceptionGroup # type: ignore # noqa: F821 + +import httpx +import pytest + +import mcp +from mcp import types +from mcp.client.session_group import ( + ClientSessionGroup, + ClientSessionParameters, + SseServerParameters, + StreamableHttpParameters, +) +from mcp.client.stdio import StdioServerParameters +from mcp.shared.exceptions import MCPError + + +@pytest.fixture +def mock_exit_stack(): + """Fixture for a mocked AsyncExitStack.""" + # Use unittest.mock.Mock directly if needed, or just a plain object + # if only attribute access/existence is needed. + # For AsyncExitStack, Mock or MagicMock is usually fine. + return mock.MagicMock(spec=contextlib.AsyncExitStack) + + +def test_client_session_group_init(): + mcp_session_group = ClientSessionGroup() + assert not mcp_session_group._tools + assert not mcp_session_group._resources + assert not mcp_session_group._prompts + assert not mcp_session_group._tool_to_session + + +def test_client_session_group_component_properties(): + # --- Mock Dependencies --- + mock_prompt = mock.Mock() + mock_resource = mock.Mock() + mock_tool = mock.Mock() + + # --- Prepare Session Group --- + mcp_session_group = ClientSessionGroup() + mcp_session_group._prompts = {"my_prompt": mock_prompt} + mcp_session_group._resources = {"my_resource": mock_resource} + mcp_session_group._tools = {"my_tool": mock_tool} + + # --- Assertions --- + assert mcp_session_group.prompts == {"my_prompt": mock_prompt} + assert mcp_session_group.resources == {"my_resource": mock_resource} + assert mcp_session_group.tools == {"my_tool": mock_tool} + + +@pytest.mark.anyio +async def test_client_session_group_call_tool(): + # --- Mock Dependencies --- + mock_session = mock.AsyncMock() + + # --- Prepare Session Group --- + def hook(name: str, server_info: types.Implementation) -> str: # pragma: no cover + return f"{(server_info.name)}-{name}" + + mcp_session_group = ClientSessionGroup(component_name_hook=hook) + mcp_session_group._tools = {"server1-my_tool": types.Tool(name="my_tool", input_schema={})} + mcp_session_group._tool_to_session = {"server1-my_tool": mock_session} + text_content = types.TextContent(type="text", text="OK") + mock_session.call_tool.return_value = types.CallToolResult(content=[text_content]) + + # --- Test Execution --- + result = await mcp_session_group.call_tool( + name="server1-my_tool", + arguments={ + "name": "value1", + "args": {}, + }, + ) + + # --- Assertions --- + assert result.content == [text_content] + mock_session.call_tool.assert_called_once_with( + "my_tool", + arguments={"name": "value1", "args": {}}, + read_timeout_seconds=None, + progress_callback=None, + meta=None, + ) + + +@pytest.mark.anyio +async def test_client_session_group_connect_to_server(mock_exit_stack: contextlib.AsyncExitStack): + """Test connecting to a server and aggregating components.""" + # --- Mock Dependencies --- + mock_server_info = mock.Mock(spec=types.Implementation) + mock_server_info.name = "TestServer1" + mock_session = mock.AsyncMock(spec=mcp.ClientSession) + mock_tool1 = mock.Mock(spec=types.Tool) + mock_tool1.name = "tool_a" + mock_resource1 = mock.Mock(spec=types.Resource) + mock_resource1.name = "resource_b" + mock_prompt1 = mock.Mock(spec=types.Prompt) + mock_prompt1.name = "prompt_c" + mock_session.list_tools.return_value = mock.AsyncMock(tools=[mock_tool1]) + mock_session.list_resources.return_value = mock.AsyncMock(resources=[mock_resource1]) + mock_session.list_prompts.return_value = mock.AsyncMock(prompts=[mock_prompt1]) + + # --- Test Execution --- + group = ClientSessionGroup(exit_stack=mock_exit_stack) + with mock.patch.object(group, "_establish_session", return_value=(mock_server_info, mock_session)): + await group.connect_to_server(StdioServerParameters(command="test")) + + # --- Assertions --- + assert mock_session in group._sessions + assert len(group.tools) == 1 + assert "tool_a" in group.tools + assert group.tools["tool_a"] == mock_tool1 + assert group._tool_to_session["tool_a"] == mock_session + assert len(group.resources) == 1 + assert "resource_b" in group.resources + assert group.resources["resource_b"] == mock_resource1 + assert len(group.prompts) == 1 + assert "prompt_c" in group.prompts + assert group.prompts["prompt_c"] == mock_prompt1 + mock_session.list_tools.assert_awaited_once() + mock_session.list_resources.assert_awaited_once() + mock_session.list_prompts.assert_awaited_once() + + +@pytest.mark.anyio +async def test_client_session_group_connect_to_server_with_name_hook(mock_exit_stack: contextlib.AsyncExitStack): + """Test connecting with a component name hook.""" + # --- Mock Dependencies --- + mock_server_info = mock.Mock(spec=types.Implementation) + mock_server_info.name = "HookServer" + mock_session = mock.AsyncMock(spec=mcp.ClientSession) + mock_tool = mock.Mock(spec=types.Tool) + mock_tool.name = "base_tool" + mock_session.list_tools.return_value = mock.AsyncMock(tools=[mock_tool]) + mock_session.list_resources.return_value = mock.AsyncMock(resources=[]) + mock_session.list_prompts.return_value = mock.AsyncMock(prompts=[]) + + # --- Test Setup --- + def name_hook(name: str, server_info: types.Implementation) -> str: + return f"{server_info.name}.{name}" + + # --- Test Execution --- + group = ClientSessionGroup(exit_stack=mock_exit_stack, component_name_hook=name_hook) + with mock.patch.object(group, "_establish_session", return_value=(mock_server_info, mock_session)): + await group.connect_to_server(StdioServerParameters(command="test")) + + # --- Assertions --- + assert mock_session in group._sessions + assert len(group.tools) == 1 + expected_tool_name = "HookServer.base_tool" + assert expected_tool_name in group.tools + assert group.tools[expected_tool_name] == mock_tool + assert group._tool_to_session[expected_tool_name] == mock_session + + +@pytest.mark.anyio +async def test_client_session_group_disconnect_from_server(): + """Test disconnecting from a server.""" + # --- Test Setup --- + group = ClientSessionGroup() + server_name = "ServerToDisconnect" + + # Manually populate state using standard mocks + mock_session1 = mock.MagicMock(spec=mcp.ClientSession) + mock_session2 = mock.MagicMock(spec=mcp.ClientSession) + mock_tool1 = mock.Mock(spec=types.Tool) + mock_tool1.name = "tool1" + mock_resource1 = mock.Mock(spec=types.Resource) + mock_resource1.name = "res1" + mock_prompt1 = mock.Mock(spec=types.Prompt) + mock_prompt1.name = "prm1" + mock_tool2 = mock.Mock(spec=types.Tool) + mock_tool2.name = "tool2" + mock_component_named_like_server = mock.Mock() + mock_session = mock.Mock(spec=mcp.ClientSession) + + group._tools = { + "tool1": mock_tool1, + "tool2": mock_tool2, + server_name: mock_component_named_like_server, + } + group._tool_to_session = { + "tool1": mock_session1, + "tool2": mock_session2, + server_name: mock_session1, + } + group._resources = { + "res1": mock_resource1, + server_name: mock_component_named_like_server, + } + group._prompts = { + "prm1": mock_prompt1, + server_name: mock_component_named_like_server, + } + group._sessions = { + mock_session: ClientSessionGroup._ComponentNames( + prompts=set({"prm1"}), + resources=set({"res1"}), + tools=set({"tool1", "tool2"}), + ) + } + + # --- Assertions --- + assert mock_session in group._sessions + assert "tool1" in group._tools + assert "tool2" in group._tools + assert "res1" in group._resources + assert "prm1" in group._prompts + + # --- Test Execution --- + await group.disconnect_from_server(mock_session) + + # --- Assertions --- + assert mock_session not in group._sessions + assert "tool1" not in group._tools + assert "tool2" not in group._tools + assert "res1" not in group._resources + assert "prm1" not in group._prompts + + +@pytest.mark.anyio +async def test_client_session_group_connect_to_server_duplicate_tool_raises_error( + mock_exit_stack: contextlib.AsyncExitStack, +): + """Test MCPError raised when connecting a server with a dup name.""" + # --- Setup Pre-existing State --- + group = ClientSessionGroup(exit_stack=mock_exit_stack) + existing_tool_name = "shared_tool" + # Manually add a tool to simulate a previous connection + group._tools[existing_tool_name] = mock.Mock(spec=types.Tool) + group._tools[existing_tool_name].name = existing_tool_name + # Need a dummy session associated with the existing tool + mock_session = mock.MagicMock(spec=mcp.ClientSession) + group._tool_to_session[existing_tool_name] = mock_session + group._session_exit_stacks[mock_session] = mock.Mock(spec=contextlib.AsyncExitStack) + + # --- Mock New Connection Attempt --- + mock_server_info_new = mock.Mock(spec=types.Implementation) + mock_server_info_new.name = "ServerWithDuplicate" + mock_session_new = mock.AsyncMock(spec=mcp.ClientSession) + + # Configure the new session to return a tool with the *same name* + duplicate_tool = mock.Mock(spec=types.Tool) + duplicate_tool.name = existing_tool_name + mock_session_new.list_tools.return_value = mock.AsyncMock(tools=[duplicate_tool]) + # Keep other lists empty for simplicity + mock_session_new.list_resources.return_value = mock.AsyncMock(resources=[]) + mock_session_new.list_prompts.return_value = mock.AsyncMock(prompts=[]) + + # --- Test Execution and Assertion --- + with pytest.raises(MCPError) as excinfo: + with mock.patch.object( + group, + "_establish_session", + return_value=(mock_server_info_new, mock_session_new), + ): + await group.connect_to_server(StdioServerParameters(command="test")) + + # Assert details about the raised error + assert excinfo.value.error.code == types.INVALID_PARAMS + assert existing_tool_name in excinfo.value.error.message + assert "already exist " in excinfo.value.error.message + + # Verify the duplicate tool was *not* added again (state should be unchanged) + assert len(group._tools) == 1 # Should still only have the original + assert group._tools[existing_tool_name] is not duplicate_tool # Ensure it's the original mock + + +@pytest.mark.anyio +async def test_client_session_group_disconnect_non_existent_server(): + """Test disconnecting a server that isn't connected.""" + session = mock.Mock(spec=mcp.ClientSession) + group = ClientSessionGroup() + with pytest.raises(MCPError): + await group.disconnect_from_server(session) + + +# TODO(Marcelo): This is horrible. We should drop this test. +@pytest.mark.anyio +@pytest.mark.parametrize( + "server_params_instance, client_type_name, patch_target_for_client_func", + [ + ( + StdioServerParameters(command="test_stdio_cmd"), + "stdio", + "mcp.client.session_group.mcp.stdio_client", + ), + ( + SseServerParameters(url="http://test.com/sse", timeout=10.0), + "sse", + "mcp.client.session_group.sse_client", + ), # url, headers, timeout, sse_read_timeout + ( + StreamableHttpParameters(url="http://test.com/stream", terminate_on_close=False), + "streamablehttp", + "mcp.client.session_group.streamable_http_client", + ), # url, headers, timeout, sse_read_timeout, terminate_on_close + ], +) +async def test_client_session_group_establish_session_parameterized( + server_params_instance: StdioServerParameters | SseServerParameters | StreamableHttpParameters, + client_type_name: str, # Just for clarity or conditional logic if needed + patch_target_for_client_func: str, +): + with mock.patch("mcp.client.session_group.mcp.ClientSession") as mock_ClientSession_class: + with mock.patch(patch_target_for_client_func) as mock_specific_client_func: + mock_client_cm_instance = mock.AsyncMock(name=f"{client_type_name}ClientCM") + mock_read_stream = mock.AsyncMock(name=f"{client_type_name}Read") + mock_write_stream = mock.AsyncMock(name=f"{client_type_name}Write") + + # All client context managers return (read_stream, write_stream) + mock_client_cm_instance.__aenter__.return_value = (mock_read_stream, mock_write_stream) + + mock_client_cm_instance.__aexit__ = mock.AsyncMock(return_value=None) + mock_specific_client_func.return_value = mock_client_cm_instance + + # --- Mock mcp.ClientSession (class) --- + # mock_ClientSession_class is already provided by the outer patch + mock_raw_session_cm = mock.AsyncMock(name="RawSessionCM") + mock_ClientSession_class.return_value = mock_raw_session_cm + + mock_entered_session = mock.AsyncMock(name="EnteredSessionInstance") + mock_raw_session_cm.__aenter__.return_value = mock_entered_session + mock_raw_session_cm.__aexit__ = mock.AsyncMock(return_value=None) + + # Mock session.initialize() + mock_initialize_result = mock.AsyncMock(name="InitializeResult") + mock_initialize_result.server_info = types.Implementation(name="foo", version="1") + mock_entered_session.initialize.return_value = mock_initialize_result + + # --- Test Execution --- + group = ClientSessionGroup() + returned_server_info = None + returned_session = None + + async with contextlib.AsyncExitStack() as stack: + group._exit_stack = stack + ( + returned_server_info, + returned_session, + ) = await group._establish_session(server_params_instance, ClientSessionParameters()) + + # --- Assertions --- + # 1. Assert the correct specific client function was called + if client_type_name == "stdio": + assert isinstance(server_params_instance, StdioServerParameters) + mock_specific_client_func.assert_called_once_with(server_params_instance) + elif client_type_name == "sse": + assert isinstance(server_params_instance, SseServerParameters) + mock_specific_client_func.assert_called_once_with( + url=server_params_instance.url, + headers=server_params_instance.headers, + timeout=server_params_instance.timeout, + sse_read_timeout=server_params_instance.sse_read_timeout, + ) + elif client_type_name == "streamablehttp": # pragma: no branch + assert isinstance(server_params_instance, StreamableHttpParameters) + # Verify streamable_http_client was called with url, httpx_client, and terminate_on_close + # The http_client is created by the real create_mcp_http_client + call_args = mock_specific_client_func.call_args + assert call_args.kwargs["url"] == server_params_instance.url + assert call_args.kwargs["terminate_on_close"] == server_params_instance.terminate_on_close + assert isinstance(call_args.kwargs["http_client"], httpx.AsyncClient) + + mock_client_cm_instance.__aenter__.assert_awaited_once() + + # 2. Assert ClientSession was called correctly + mock_ClientSession_class.assert_called_once_with( + mock_read_stream, + mock_write_stream, + read_timeout_seconds=None, + sampling_callback=None, + elicitation_callback=None, + list_roots_callback=None, + logging_callback=None, + message_handler=None, + client_info=None, + ) + mock_raw_session_cm.__aenter__.assert_awaited_once() + mock_entered_session.initialize.assert_awaited_once() + + # 3. Assert returned values + assert returned_server_info is mock_initialize_result.server_info + assert returned_session is mock_entered_session + + +def _free_tcp_port() -> int: + """Return a TCP port number not currently bound on localhost. + + A small race window exists between this returning and the test + using the port, but it is acceptable here: the test only requires + that no streamable-http MCP server be listening at connect time. + """ + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +def _is_cancel_scope_runtime_error(exc: BaseException) -> bool: + """Walk *exc* and its cause/context/group chain looking for the + ``Attempted to exit cancel scope in a different task`` RuntimeError + that previously masked underlying connection errors (issue #915). + """ + seen: set[int] = set() + + def _walk(e: BaseException | None) -> bool: + if e is None or id(e) in seen: + return False + seen.add(id(e)) + if isinstance(e, RuntimeError) and "cancel scope" in str(e).lower(): + return True + if isinstance(e, BaseExceptionGroup): + if any(_walk(child) for child in e.exceptions): # type: ignore + return True + return _walk(e.__cause__) or _walk(e.__context__) + + return _walk(exc) -# TODO(Marcelo): This is horrible. We should drop this test. -@pytest.mark.anyio -@pytest.mark.parametrize( - "server_params_instance, client_type_name, patch_target_for_client_func", - [ - ( - StdioServerParameters(command="test_stdio_cmd"), - "stdio", - "mcp.client.session_group.mcp.stdio_client", - ), - ( - SseServerParameters(url="http://test.com/sse", timeout=10.0), - "sse", - "mcp.client.session_group.sse_client", - ), # url, headers, timeout, sse_read_timeout - ( - StreamableHttpParameters(url="http://test.com/stream", terminate_on_close=False), - "streamablehttp", - "mcp.client.session_group.streamable_http_client", - ), # url, headers, timeout, sse_read_timeout, terminate_on_close - ], -) -async def test_client_session_group_establish_session_parameterized( - server_params_instance: StdioServerParameters | SseServerParameters | StreamableHttpParameters, - client_type_name: str, # Just for clarity or conditional logic if needed - patch_target_for_client_func: str, -): - with mock.patch("mcp.client.session_group.mcp.ClientSession") as mock_ClientSession_class: - with mock.patch(patch_target_for_client_func) as mock_specific_client_func: - mock_client_cm_instance = mock.AsyncMock(name=f"{client_type_name}ClientCM") - mock_read_stream = mock.AsyncMock(name=f"{client_type_name}Read") - mock_write_stream = mock.AsyncMock(name=f"{client_type_name}Write") +def test_is_cancel_scope_direct_runtime_error() -> None: + """A bare RuntimeError mentioning 'cancel scope' is detected.""" + exc = RuntimeError("Attempted to exit cancel scope in a different task") + assert _is_cancel_scope_runtime_error(exc) - # All client context managers return (read_stream, write_stream) - mock_client_cm_instance.__aenter__.return_value = (mock_read_stream, mock_write_stream) - mock_client_cm_instance.__aexit__ = mock.AsyncMock(return_value=None) - mock_specific_client_func.return_value = mock_client_cm_instance +def test_is_cancel_scope_other_runtime_error_not_detected() -> None: + """A RuntimeError that doesn't mention cancel scope is not flagged.""" + exc = RuntimeError("some unrelated error") + assert not _is_cancel_scope_runtime_error(exc) - # --- Mock mcp.ClientSession (class) --- - # mock_ClientSession_class is already provided by the outer patch - mock_raw_session_cm = mock.AsyncMock(name="RawSessionCM") - mock_ClientSession_class.return_value = mock_raw_session_cm - mock_entered_session = mock.AsyncMock(name="EnteredSessionInstance") - mock_raw_session_cm.__aenter__.return_value = mock_entered_session - mock_raw_session_cm.__aexit__ = mock.AsyncMock(return_value=None) +def test_is_cancel_scope_non_runtime_error_not_detected() -> None: + """A non-RuntimeError is never flagged as a cancel scope error.""" + exc = ValueError("not a runtime error") + assert not _is_cancel_scope_runtime_error(exc) - # Mock session.initialize() - mock_initialize_result = mock.AsyncMock(name="InitializeResult") - mock_initialize_result.server_info = types.Implementation(name="foo", version="1") - mock_entered_session.initialize.return_value = mock_initialize_result - # --- Test Execution --- - group = ClientSessionGroup() - returned_server_info = None - returned_session = None +def test_is_cancel_scope_none_is_false() -> None: + """None returns False.""" + assert not _is_cancel_scope_runtime_error(None) - async with contextlib.AsyncExitStack() as stack: - group._exit_stack = stack - ( - returned_server_info, - returned_session, - ) = await group._establish_session(server_params_instance, ClientSessionParameters()) - # --- Assertions --- - # 1. Assert the correct specific client function was called - if client_type_name == "stdio": - assert isinstance(server_params_instance, StdioServerParameters) - mock_specific_client_func.assert_called_once_with(server_params_instance) - elif client_type_name == "sse": - assert isinstance(server_params_instance, SseServerParameters) - mock_specific_client_func.assert_called_once_with( - url=server_params_instance.url, - headers=server_params_instance.headers, - timeout=server_params_instance.timeout, - sse_read_timeout=server_params_instance.sse_read_timeout, - ) - elif client_type_name == "streamablehttp": # pragma: no branch - assert isinstance(server_params_instance, StreamableHttpParameters) - # Verify streamable_http_client was called with url, httpx_client, and terminate_on_close - # The http_client is created by the real create_mcp_http_client - call_args = mock_specific_client_func.call_args - assert call_args.kwargs["url"] == server_params_instance.url - assert call_args.kwargs["terminate_on_close"] == server_params_instance.terminate_on_close - assert isinstance(call_args.kwargs["http_client"], httpx.AsyncClient) +def test_is_cancel_scope_in_cause_chain() -> None: + """Cancel scope RuntimeError reached via __cause__ chain.""" + inner = RuntimeError("Attempted to exit cancel scope in a different task") + outer = RuntimeError("outer error") + outer.__cause__ = inner + assert _is_cancel_scope_runtime_error(outer) - mock_client_cm_instance.__aenter__.assert_awaited_once() - # 2. Assert ClientSession was called correctly - mock_ClientSession_class.assert_called_once_with( - mock_read_stream, - mock_write_stream, - read_timeout_seconds=None, - sampling_callback=None, - elicitation_callback=None, - list_roots_callback=None, - logging_callback=None, - message_handler=None, - client_info=None, - ) - mock_raw_session_cm.__aenter__.assert_awaited_once() - mock_entered_session.initialize.assert_awaited_once() +def test_is_cancel_scope_in_context_chain() -> None: + """Cancel scope RuntimeError reached via __context__ chain.""" + inner = RuntimeError("Attempted to exit cancel scope in a different task") + outer = RuntimeError("outer error") + outer.__context__ = inner + assert _is_cancel_scope_runtime_error(outer) - # 3. Assert returned values - assert returned_server_info is mock_initialize_result.server_info - assert returned_session is mock_entered_session +def test_is_cancel_scope_in_exception_group() -> None: + """Cancel scope RuntimeError inside a BaseExceptionGroup is detected.""" + cs_err = RuntimeError("Attempted to exit cancel scope in a different task") + group = BaseExceptionGroup("group", [cs_err]) + assert _is_cancel_scope_runtime_error(group) -def _free_tcp_port() -> int: - """Return a TCP port number not currently bound on localhost. - A small race window exists between this returning and the test - using the port, but it is acceptable here: the test only requires - that no streamable-http MCP server be listening at connect time. - """ - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.bind(("127.0.0.1", 0)) - return sock.getsockname()[1] +def test_is_cancel_scope_exception_group_without_match() -> None: + """BaseExceptionGroup without any cancel scope error returns False.""" + group = BaseExceptionGroup("group", [ValueError("nope"), TypeError("nope")]) + assert not _is_cancel_scope_runtime_error(group) -def _is_cancel_scope_runtime_error(exc: BaseException) -> bool: - """Walk *exc* and its cause/context/group chain looking for the - ``Attempted to exit cancel scope in a different task`` RuntimeError - that previously masked underlying connection errors (issue #915). - """ - seen: set[int] = set() +def test_is_cancel_scope_in_cause_of_group_child() -> None: + """Cancel scope found via __cause__ of a child inside an exception group.""" + cs_err = RuntimeError("Attempted to exit cancel scope in a different task") + child = RuntimeError("child") + child.__cause__ = cs_err + group = BaseExceptionGroup("group", [child]) + assert _is_cancel_scope_runtime_error(group) - def _walk(e: BaseException | None) -> bool: - if e is None or id(e) in seen: - return False - seen.add(id(e)) - if isinstance(e, RuntimeError) and "cancel scope" in str(e).lower(): - return True - if isinstance(e, BaseExceptionGroup): - if any(_walk(child) for child in e.exceptions): # type: ignore - return True - return _walk(e.__cause__) or _walk(e.__context__) - return _walk(exc) +def test_is_cancel_scope_circular_reference_handled() -> None: + """Circular __cause__ chain does not infinite-loop.""" + exc = RuntimeError("loop") + exc.__cause__ = exc + assert not _is_cancel_scope_runtime_error(exc) @pytest.mark.anyio -async def test_unreachable_streamable_http_error_is_catchable() -> None: - """Regression test for #915. - - Connecting ``ClientSessionGroup`` to an unbound local port must - raise a *catchable* connection error rather than being shadowed by - the AnyIO cancel-scope ``RuntimeError`` from concurrent teardown. - """ - port = _free_tcp_port() - server_params = StreamableHttpParameters(url=f"http://127.0.0.1:{port}/mcp/") - - caught: BaseException | None = None - - async with ClientSessionGroup() as group: - try: - await group.connect_to_server(server_params) - except BaseException as inner: - # Pre-fix #915: the real ConnectError was masked by an anyio - # cancel-scope RuntimeError raised during __aexit__ teardown. - # Post-fix: the real exception propagates here and is catchable. - caught = inner - - assert caught is not None, ( - "Expected to catch a connection error against an unreachable " - "streamable-http server, but no exception was raised." - ) - assert not _is_cancel_scope_runtime_error(caught), ( - "Regression of #915: connection error against an unreachable " - "streamable-http server was masked by an anyio cancel-scope " - f"RuntimeError. Got: {type(caught).__name__}: {caught}" - ) +async def test_unreachable_streamable_http_error_is_catchable() -> None: + """Regression test for #915. + + Connecting ``ClientSessionGroup`` to an unbound local port must + raise a *catchable* connection error rather than being shadowed by + the AnyIO cancel-scope ``RuntimeError`` from concurrent teardown. + """ + port = _free_tcp_port() + server_params = StreamableHttpParameters(url=f"http://127.0.0.1:{port}/mcp/") + + caught: BaseException | None = None + + async with ClientSessionGroup() as group: + try: + await group.connect_to_server(server_params) + except BaseException as inner: + # Pre-fix #915: the real ConnectError was masked by an anyio + # cancel-scope RuntimeError raised during __aexit__ teardown. + # Post-fix: the real exception propagates here and is catchable. + caught = inner + + assert caught is not None, ( + "Expected to catch a connection error against an unreachable " + "streamable-http server, but no exception was raised." + ) + assert not _is_cancel_scope_runtime_error(caught), ( + "Regression of #915: connection error against an unreachable " + "streamable-http server was masked by an anyio cancel-scope " + f"RuntimeError. Got: {type(caught).__name__}: {caught}" + ) From dd4726278e7b880c375834dd07da49ea5dee712f Mon Sep 17 00:00:00 2001 From: VRTXOmega Date: Tue, 28 Apr 2026 12:54:26 -0500 Subject: [PATCH 13/21] fix: strip stale pragma: no cover markers now exercised by tests strict-no-cover caught 7 dead pragmas after the _is_cancel_scope tests went in. Three src blocks in session_group.py are now covered by the existing test suite and one test guard needed lax no cover for cross-version compat (3.10 vs 3.11+ BaseExceptionGroup alias). --- .claude/commands/review-pr.md | 236 +- .git-blame-ignore-revs | 10 +- .gitattribute | 4 +- .github/actions/conformance/client.py | 734 +- .github/actions/conformance/run-server.sh | 60 +- .gitignore | 348 +- AGENTS.md | 280 +- CLAUDE.md | 2 +- CODE_OF_CONDUCT.md | 256 +- CONTRIBUTING.md | 292 +- LICENSE | 42 +- README.md | 5140 +++++++------- README.v2.md | 5006 +++++++------- RELEASE.md | 26 +- SECURITY.md | 42 +- docs/authorization.md | 10 +- docs/concepts.md | 26 +- docs/experimental/index.md | 84 +- docs/experimental/tasks-client.md | 722 +- docs/experimental/tasks-server.md | 1154 ++-- docs/experimental/tasks.md | 376 +- docs/hooks/gen_ref_pages.py | 70 +- docs/index.md | 134 +- docs/installation.md | 62 +- docs/low-level-server.md | 10 +- docs/migration.md | 2276 +++---- docs/testing.md | 154 +- examples/README.md | 10 +- examples/clients/simple-auth-client/README.md | 196 +- .../mcp_simple_auth_client/__init__.py | 2 +- .../mcp_simple_auth_client/main.py | 766 +-- .../clients/simple-auth-client/pyproject.toml | 86 +- .../clients/simple-chatbot/.python-version | 2 +- examples/clients/simple-chatbot/README.MD | 226 +- .../mcp_simple_chatbot/.env.example | 2 +- .../simple-chatbot/mcp_simple_chatbot/main.py | 842 +-- .../mcp_simple_chatbot/requirements.txt | 8 +- .../mcp_simple_chatbot/servers_config.json | 24 +- .../clients/simple-chatbot/pyproject.toml | 94 +- examples/clients/simple-task-client/README.md | 86 +- .../mcp_simple_task_client/__main__.py | 10 +- .../mcp_simple_task_client/main.py | 112 +- .../clients/simple-task-client/pyproject.toml | 86 +- .../simple-task-interactive-client/README.md | 174 +- .../__main__.py | 10 +- .../main.py | 274 +- .../pyproject.toml | 86 +- examples/clients/sse-polling-client/README.md | 60 +- .../mcp_sse_polling_client/__init__.py | 2 +- .../mcp_sse_polling_client/main.py | 204 +- .../clients/sse-polling-client/pyproject.toml | 72 +- examples/mcpserver/complex_inputs.py | 58 +- examples/mcpserver/desktop.py | 48 +- .../direct_call_tool_result_return.py | 44 +- examples/mcpserver/echo.py | 56 +- examples/mcpserver/icons_demo.py | 112 +- examples/mcpserver/logging_and_progress.py | 62 +- examples/mcpserver/memory.py | 648 +- examples/mcpserver/parameter_descriptions.py | 38 +- examples/mcpserver/readme-quickstart.py | 36 +- examples/mcpserver/screenshot.py | 54 +- examples/mcpserver/simple_echo.py | 24 +- examples/mcpserver/text_me.py | 134 +- examples/mcpserver/unicode_example.py | 118 +- examples/mcpserver/weather_structured.py | 448 +- examples/servers/everything-server/README.md | 84 +- .../mcp_everything_server/__init__.py | 6 +- .../mcp_everything_server/__main__.py | 12 +- .../mcp_everything_server/server.py | 932 +-- .../servers/everything-server/pyproject.toml | 72 +- examples/servers/simple-auth/README.md | 270 +- .../simple-auth/mcp_simple_auth/__init__.py | 2 +- .../simple-auth/mcp_simple_auth/__main__.py | 14 +- .../mcp_simple_auth/auth_server.py | 366 +- .../mcp_simple_auth/legacy_as_server.py | 274 +- .../simple-auth/mcp_simple_auth/server.py | 322 +- .../mcp_simple_auth/simple_auth_provider.py | 540 +- .../mcp_simple_auth/token_verifier.py | 212 +- examples/servers/simple-auth/pyproject.toml | 66 +- examples/servers/simple-pagination/README.md | 154 +- .../mcp_simple_pagination/__main__.py | 10 +- .../mcp_simple_pagination/server.py | 352 +- .../servers/simple-pagination/pyproject.toml | 86 +- .../servers/simple-prompt/.python-version | 2 +- examples/servers/simple-prompt/README.md | 110 +- .../mcp_simple_prompt/__main__.py | 10 +- .../simple-prompt/mcp_simple_prompt/server.py | 196 +- examples/servers/simple-prompt/pyproject.toml | 86 +- .../servers/simple-resource/.python-version | 2 +- examples/servers/simple-resource/README.md | 96 +- .../mcp_simple_resource/__main__.py | 10 +- .../mcp_simple_resource/server.py | 182 +- .../servers/simple-resource/pyproject.toml | 86 +- .../simple-streamablehttp-stateless/README.md | 76 +- .../__main__.py | 14 +- .../server.py | 232 +- .../pyproject.toml | 72 +- .../servers/simple-streamablehttp/README.md | 102 +- .../mcp_simple_streamablehttp/__main__.py | 8 +- .../mcp_simple_streamablehttp/event_store.py | 186 +- .../mcp_simple_streamablehttp/server.py | 284 +- .../simple-streamablehttp/pyproject.toml | 72 +- .../servers/simple-task-interactive/README.md | 148 +- .../mcp_simple_task_interactive/__main__.py | 10 +- .../mcp_simple_task_interactive/server.py | 276 +- .../simple-task-interactive/pyproject.toml | 86 +- examples/servers/simple-task/README.md | 74 +- .../simple-task/mcp_simple_task/__main__.py | 10 +- .../simple-task/mcp_simple_task/server.py | 140 +- examples/servers/simple-task/pyproject.toml | 86 +- examples/servers/simple-tool/.python-version | 2 +- examples/servers/simple-tool/README.md | 96 +- .../simple-tool/mcp_simple_tool/__main__.py | 10 +- .../simple-tool/mcp_simple_tool/server.py | 160 +- examples/servers/simple-tool/pyproject.toml | 86 +- examples/servers/sse-polling-demo/README.md | 72 +- .../mcp_sse_polling_demo/__init__.py | 2 +- .../mcp_sse_polling_demo/__main__.py | 12 +- .../mcp_sse_polling_demo/event_store.py | 196 +- .../mcp_sse_polling_demo/server.py | 320 +- .../servers/sse-polling-demo/pyproject.toml | 72 +- .../__init__.py | 2 +- .../__main__.py | 178 +- .../structured-output-lowlevel/pyproject.toml | 12 +- .../snippets/clients/completion_client.py | 154 +- .../snippets/clients/display_utilities.py | 132 +- examples/snippets/clients/oauth_client.py | 176 +- .../snippets/clients/pagination_client.py | 78 +- .../snippets/clients/parsing_tool_results.py | 120 +- examples/snippets/clients/stdio_client.py | 160 +- examples/snippets/clients/streamable_basic.py | 48 +- .../clients/url_elicitation_client.py | 632 +- examples/snippets/pyproject.toml | 48 +- examples/snippets/servers/__init__.py | 74 +- examples/snippets/servers/basic_prompt.py | 36 +- examples/snippets/servers/basic_resource.py | 40 +- examples/snippets/servers/basic_tool.py | 32 +- examples/snippets/servers/completion.py | 98 +- .../servers/direct_call_tool_result.py | 84 +- examples/snippets/servers/direct_execution.py | 54 +- examples/snippets/servers/elicitation.py | 196 +- examples/snippets/servers/images.py | 30 +- examples/snippets/servers/lifespan_example.py | 112 +- .../snippets/servers/lowlevel/__init__.py | 2 +- examples/snippets/servers/lowlevel/basic.py | 126 +- .../lowlevel/direct_call_tool_result.py | 124 +- .../snippets/servers/lowlevel/lifespan.py | 202 +- .../servers/lowlevel/structured_output.py | 160 +- .../snippets/servers/mcpserver_quickstart.py | 84 +- examples/snippets/servers/notifications.py | 36 +- examples/snippets/servers/oauth_server.py | 90 +- .../snippets/servers/pagination_example.py | 70 +- examples/snippets/servers/sampling.py | 50 +- .../snippets/servers/streamable_config.py | 56 +- .../servers/streamable_http_basic_mounting.py | 76 +- .../servers/streamable_http_host_mounting.py | 76 +- .../streamable_http_multiple_servers.py | 96 +- .../servers/streamable_http_path_config.py | 62 +- .../servers/streamable_starlette_mount.py | 106 +- .../snippets/servers/structured_output.py | 194 +- examples/snippets/servers/tool_progress.py | 40 +- job_log.txt | Bin 0 -> 1123742 bytes job_log_new.txt | Bin 0 -> 1126312 bytes pyproject.toml | 478 +- scripts/test | 22 +- scripts/update_readme_snippets.py | 318 +- src/mcp/__init__.py | 270 +- src/mcp/cli/__init__.py | 12 +- src/mcp/cli/claude.py | 304 +- src/mcp/cli/cli.py | 972 +-- src/mcp/client/__init__.py | 16 +- src/mcp/client/__main__.py | 168 +- src/mcp/client/_memory.py | 156 +- src/mcp/client/_transport.py | 42 +- src/mcp/client/auth/__init__.py | 40 +- src/mcp/client/auth/exceptions.py | 20 +- .../auth/extensions/client_credentials.py | 970 +-- src/mcp/client/auth/oauth2.py | 1292 ++-- src/mcp/client/auth/utils.py | 678 +- src/mcp/client/client.py | 616 +- src/mcp/client/context.py | 32 +- src/mcp/client/experimental/__init__.py | 16 +- src/mcp/client/experimental/task_handlers.py | 586 +- src/mcp/client/experimental/tasks.py | 416 +- src/mcp/client/session.py | 960 +-- src/mcp/client/session_group.py | 814 +-- src/mcp/client/sse.py | 320 +- src/mcp/client/stdio.py | 540 +- src/mcp/client/streamable_http.py | 1176 ++-- src/mcp/client/websocket.py | 170 +- src/mcp/os/__init__.py | 2 +- src/mcp/os/posix/__init__.py | 2 +- src/mcp/os/posix/utilities.py | 114 +- src/mcp/os/win32/__init__.py | 2 +- src/mcp/os/win32/utilities.py | 666 +- src/mcp/server/__init__.py | 12 +- src/mcp/server/__main__.py | 98 +- src/mcp/server/auth/__init__.py | 2 +- src/mcp/server/auth/errors.py | 10 +- src/mcp/server/auth/handlers/__init__.py | 2 +- src/mcp/server/auth/handlers/authorize.py | 450 +- src/mcp/server/auth/handlers/metadata.py | 58 +- src/mcp/server/auth/handlers/register.py | 266 +- src/mcp/server/auth/handlers/revoke.py | 174 +- src/mcp/server/auth/handlers/token.py | 438 +- src/mcp/server/auth/json_response.py | 20 +- src/mcp/server/auth/middleware/__init__.py | 2 +- .../server/auth/middleware/auth_context.py | 92 +- src/mcp/server/auth/middleware/bearer_auth.py | 248 +- src/mcp/server/auth/middleware/client_auth.py | 228 +- src/mcp/server/auth/provider.py | 586 +- src/mcp/server/auth/routes.py | 490 +- src/mcp/server/auth/settings.py | 60 +- src/mcp/server/context.py | 46 +- src/mcp/server/elicitation.py | 380 +- src/mcp/server/experimental/__init__.py | 20 +- .../server/experimental/request_context.py | 434 +- .../server/experimental/session_features.py | 418 +- src/mcp/server/experimental/task_context.py | 1174 ++-- .../experimental/task_result_handler.py | 436 +- src/mcp/server/experimental/task_support.py | 232 +- src/mcp/server/lowlevel/__init__.py | 6 +- src/mcp/server/lowlevel/experimental.py | 420 +- src/mcp/server/lowlevel/helper_types.py | 22 +- src/mcp/server/lowlevel/server.py | 1344 ++-- src/mcp/server/mcpserver/__init__.py | 18 +- src/mcp/server/mcpserver/context.py | 546 +- src/mcp/server/mcpserver/exceptions.py | 42 +- src/mcp/server/mcpserver/prompts/__init__.py | 8 +- src/mcp/server/mcpserver/prompts/base.py | 378 +- src/mcp/server/mcpserver/prompts/manager.py | 118 +- .../server/mcpserver/resources/__init__.py | 46 +- src/mcp/server/mcpserver/resources/base.py | 88 +- .../mcpserver/resources/resource_manager.py | 216 +- .../server/mcpserver/resources/templates.py | 266 +- src/mcp/server/mcpserver/resources/types.py | 410 +- src/mcp/server/mcpserver/server.py | 2224 +++--- src/mcp/server/mcpserver/tools/__init__.py | 8 +- src/mcp/server/mcpserver/tools/base.py | 238 +- .../server/mcpserver/tools/tool_manager.py | 172 +- .../server/mcpserver/utilities/__init__.py | 2 +- .../mcpserver/utilities/context_injection.py | 136 +- .../mcpserver/utilities/func_metadata.py | 1060 +-- src/mcp/server/mcpserver/utilities/logging.py | 78 +- src/mcp/server/mcpserver/utilities/types.py | 202 +- src/mcp/server/models.py | 36 +- src/mcp/server/session.py | 1384 ++-- src/mcp/server/sse.py | 482 +- src/mcp/server/stdio.py | 154 +- src/mcp/server/streamable_http.py | 2128 +++--- src/mcp/server/streamable_http_manager.py | 592 +- src/mcp/server/transport_security.py | 232 +- src/mcp/server/validation.py | 176 +- src/mcp/server/websocket.py | 104 +- src/mcp/shared/_callable_inspection.py | 66 +- src/mcp/shared/_context.py | 48 +- src/mcp/shared/_context_streams.py | 238 +- src/mcp/shared/_httpx_utils.py | 194 +- src/mcp/shared/_otel.py | 72 +- src/mcp/shared/_stream_protocols.py | 98 +- src/mcp/shared/auth.py | 340 +- src/mcp/shared/auth_utils.py | 160 +- src/mcp/shared/exceptions.py | 212 +- src/mcp/shared/experimental/__init__.py | 12 +- src/mcp/shared/experimental/tasks/__init__.py | 22 +- .../shared/experimental/tasks/capabilities.py | 192 +- src/mcp/shared/experimental/tasks/context.py | 190 +- src/mcp/shared/experimental/tasks/helpers.py | 332 +- .../tasks/in_memory_task_store.py | 434 +- .../experimental/tasks/message_queue.py | 460 +- src/mcp/shared/experimental/tasks/polling.py | 86 +- src/mcp/shared/experimental/tasks/resolver.py | 116 +- src/mcp/shared/experimental/tasks/store.py | 288 +- src/mcp/shared/memory.py | 60 +- src/mcp/shared/message.py | 104 +- src/mcp/shared/metadata_utils.py | 92 +- src/mcp/shared/response_router.py | 122 +- src/mcp/shared/session.py | 1092 +-- src/mcp/shared/tool_name_validation.py | 258 +- src/mcp/shared/version.py | 6 +- src/mcp/types/__init__.py | 828 +-- src/mcp/types/_types.py | 3556 +++++----- src/mcp/types/jsonrpc.py | 166 +- tests/cli/test_claude.py | 292 +- tests/cli/test_utils.py | 202 +- .../extensions/test_client_credentials.py | 992 +-- tests/client/conftest.py | 270 +- tests/client/test_auth.py | 5240 +++++++------- tests/client/test_client.py | 710 +- tests/client/test_http_unicode.py | 474 +- tests/client/test_list_methods_cursor.py | 246 +- tests/client/test_list_roots_callback.py | 94 +- tests/client/test_logging_callback.py | 212 +- tests/client/test_notification_response.py | 406 +- tests/client/test_output_schema_validation.py | 330 +- tests/client/test_resource_cleanup.py | 132 +- tests/client/test_sampling_callback.py | 262 +- tests/client/test_scope_bug_1630.py | 328 +- tests/client/test_session.py | 1414 ++-- tests/client/test_session_group.py | 8 +- tests/client/test_stdio.py | 1120 +-- tests/client/test_transport_stream_cleanup.py | 238 +- tests/client/transports/test_memory.py | 194 +- tests/conftest.py | 12 +- tests/experimental/tasks/__init__.py | 2 +- .../tasks/client/test_capabilities.py | 624 +- .../tasks/client/test_handlers.py | 1748 ++--- .../tasks/client/test_poll_task.py | 242 +- tests/experimental/tasks/client/test_tasks.py | 618 +- .../experimental/tasks/server/test_context.py | 366 +- .../tasks/server/test_integration.py | 494 +- .../tasks/server/test_run_task_flow.py | 734 +- .../experimental/tasks/server/test_server.py | 1594 ++--- .../tasks/server/test_server_task_context.py | 1418 ++-- tests/experimental/tasks/server/test_store.py | 812 +-- .../tasks/server/test_task_result_handler.py | 708 +- tests/experimental/tasks/test_capabilities.py | 566 +- .../tasks/test_elicitation_scenarios.py | 1390 ++-- .../experimental/tasks/test_message_queue.py | 660 +- .../tasks/test_request_context.py | 332 +- .../tasks/test_spec_compliance.py | 1434 ++-- tests/issues/test_100_tool_listing.py | 62 +- .../test_1027_win_unreachable_cleanup.py | 480 +- tests/issues/test_129_resource_templates.py | 66 +- tests/issues/test_1338_icons_and_metadata.py | 284 +- ...est_1363_race_condition_streamable_http.py | 548 +- tests/issues/test_141_resource_templates.py | 218 +- tests/issues/test_152_resource_mime_type.py | 254 +- .../test_1574_resource_uri_validation.py | 280 +- .../issues/test_1754_mime_type_parameters.py | 134 +- tests/issues/test_176_progress_token.py | 92 +- tests/issues/test_188_concurrency.py | 170 +- tests/issues/test_192_request_id.py | 190 +- tests/issues/test_342_base64_encoding.py | 104 +- tests/issues/test_355_type_error.py | 100 +- tests/issues/test_552_windows_hang.py | 126 +- tests/issues/test_88_random_error.py | 232 +- tests/issues/test_973_url_decoding.py | 156 +- tests/issues/test_malformed_input.py | 302 +- .../auth/middleware/test_auth_context.py | 238 +- .../auth/middleware/test_bearer_auth.py | 896 +-- tests/server/auth/test_error_handling.py | 580 +- tests/server/auth/test_protected_resource.py | 396 +- tests/server/auth/test_provider.py | 158 +- tests/server/auth/test_routes.py | 94 +- tests/server/lowlevel/test_helper_types.py | 118 +- tests/server/lowlevel/test_server_listing.py | 268 +- .../server/lowlevel/test_server_pagination.py | 166 +- tests/server/mcpserver/auth/__init__.py | 2 +- .../mcpserver/auth/test_auth_integration.py | 3286 ++++----- tests/server/mcpserver/prompts/test_base.py | 422 +- .../server/mcpserver/prompts/test_manager.py | 220 +- .../resources/test_file_resources.py | 232 +- .../resources/test_function_resources.py | 488 +- .../resources/test_resource_manager.py | 282 +- .../resources/test_resource_template.py | 664 +- .../mcpserver/resources/test_resources.py | 480 +- .../mcpserver/servers/test_file_server.py | 256 +- tests/server/mcpserver/test_elicitation.py | 782 +-- tests/server/mcpserver/test_func_metadata.py | 2382 +++---- tests/server/mcpserver/test_integration.py | 706 +- .../mcpserver/test_parameter_descriptions.py | 60 +- tests/server/mcpserver/test_server.py | 3036 ++++----- tests/server/mcpserver/test_title.py | 464 +- tests/server/mcpserver/test_tool_manager.py | 1804 ++--- .../server/mcpserver/test_url_elicitation.py | 684 +- .../test_url_elicitation_error_throw.py | 216 +- tests/server/mcpserver/tools/test_base.py | 20 +- tests/server/test_cancel_handling.py | 500 +- tests/server/test_completion_with_context.py | 318 +- tests/server/test_lifespan.py | 414 +- .../test_lowlevel_exception_handling.py | 212 +- .../server/test_lowlevel_tool_annotations.py | 88 +- tests/server/test_read_resource.py | 116 +- tests/server/test_session.py | 984 +-- tests/server/test_session_race_condition.py | 264 +- tests/server/test_sse_security.py | 586 +- tests/server/test_stateless_mode.py | 354 +- tests/server/test_stdio.py | 188 +- tests/server/test_streamable_http_manager.py | 830 +-- tests/server/test_streamable_http_security.py | 582 +- tests/server/test_validation.py | 302 +- tests/shared/test_auth.py | 280 +- tests/shared/test_auth_utils.py | 246 +- tests/shared/test_exceptions.py | 328 +- tests/shared/test_httpx_utils.py | 48 +- tests/shared/test_otel.py | 88 +- tests/shared/test_progress_notifications.py | 528 +- tests/shared/test_session.py | 836 +-- tests/shared/test_sse.py | 1262 ++-- tests/shared/test_streamable_http.py | 4636 ++++++------- tests/shared/test_tool_name_validation.py | 416 +- tests/shared/test_win32_utils.py | 20 +- tests/shared/test_ws.py | 102 +- tests/test_examples.py | 216 +- tests/test_helpers.py | 170 +- tests/test_types.py | 724 +- uv.lock | 6010 ++++++++--------- 398 files changed, 73992 insertions(+), 73992 deletions(-) create mode 100644 job_log.txt create mode 100644 job_log_new.txt diff --git a/.claude/commands/review-pr.md b/.claude/commands/review-pr.md index 114c1e3d5..015309e76 100644 --- a/.claude/commands/review-pr.md +++ b/.claude/commands/review-pr.md @@ -1,118 +1,118 @@ -Review the pull request: $ARGUMENTS - -Follow these steps carefully. Use the `gh` CLI for all GitHub interactions. - -## Step 1: Resolve the PR - -Parse `$ARGUMENTS` to determine the PR. It can be: - -- A full URL like `https://github.com/owner/repo/pull/123` -- A `owner/repo#123` reference -- A bare number like `123` (use the current repo) -- A description — search for it with `gh pr list --search "" --limit 5` and pick the best match - -Once resolved, fetch the PR metadata: - -```bash -gh pr view --json number,title,body,author,state,baseRefName,headRefName,url,labels,milestone,additions,deletions,changedFiles,createdAt,updatedAt,mergedAt,reviewDecision,reviews,assignees -``` - -## Step 2: Gather the diff - -Get the full diff of the PR: - -```bash -gh pr diff -``` - -If the diff is very large (>3000 lines), focus on the most important files first and summarize the rest. - -## Step 3: Collect PR discussion context - -Fetch all comments and review threads: - -```bash -gh api repos/{owner}/{repo}/pulls/{number}/comments --paginate -gh api repos/{owner}/{repo}/issues/{number}/comments --paginate -gh api repos/{owner}/{repo}/pulls/{number}/reviews --paginate -``` - -Pay attention to: - -- Reviewer feedback and requested changes -- Author responses and explanations -- Any unresolved conversations -- Approval or rejection status - -## Step 4: Find and read linked issues - -Look for issue references in: - -- The PR body (patterns like `#123`, `fixes #123`, `closes #123`, `resolves #123`) -- The PR branch name (patterns like `issue-123`, `fix/123`) -- Commit messages - -For each linked issue, fetch its content: - -```bash -gh issue view --json title,body,comments,labels,state -``` - -Read through issue comments to understand the original problem, user reports, and any discussed solutions. - -## Step 5: Analyze and validate - -With all context gathered, analyze the PR critically: - -1. **Intent alignment**: Does the code change actually solve the problem described in the PR and/or linked issues? -2. **Completeness**: Are there aspects of the issue or requested feature that the PR doesn't address? -3. **Scope**: Does the PR include changes unrelated to the stated goal? Are there unnecessary modifications? -4. **Correctness**: Based on the diff, are there obvious bugs, edge cases, or logic errors? -5. **Testing**: Does the PR include tests? Are they meaningful and do they cover the important cases? -6. **Breaking changes**: Could this PR break existing functionality or APIs? -7. **Unresolved feedback**: Are there reviewer comments that haven't been addressed? - -## Step 6: Produce the review summary - -Present the summary in this format: - ---- - -### PR Review: `` (<url>) - -**Author:** <author> | **Status:** <state> | **Review decision:** <decision> -**Base:** `<base>` ← `<head>` | **Changed files:** <n> | **+<additions> / -<deletions>** - -#### Problem - -<1-3 sentences describing what problem this PR is trying to solve, based on the PR description and linked issues> - -#### Solution - -<1-3 sentences describing the approach taken in the code> - -#### Key changes - -<Bulleted list of the most important changes, grouped by theme. Include file paths.> - -#### Linked issues - -<List of linked issues with their title, state, and a one-line summary of the discussion> - -#### Discussion highlights - -<Summary of important comments from reviewers and the author. Flag any unresolved threads.> - -#### Concerns - -<List any issues found during validation: bugs, missing tests, scope creep, unaddressed feedback, etc. If none, say "No concerns found."> - -#### Verdict - -<One of: APPROVE / REQUEST CHANGES / NEEDS DISCUSSION, with a brief justification> - -#### Suggested action - -<Clear recommendation for the reviewer: what to approve, what to push back on, what to ask about> - ---- +Review the pull request: $ARGUMENTS + +Follow these steps carefully. Use the `gh` CLI for all GitHub interactions. + +## Step 1: Resolve the PR + +Parse `$ARGUMENTS` to determine the PR. It can be: + +- A full URL like `https://github.com/owner/repo/pull/123` +- A `owner/repo#123` reference +- A bare number like `123` (use the current repo) +- A description — search for it with `gh pr list --search "<description>" --limit 5` and pick the best match + +Once resolved, fetch the PR metadata: + +```bash +gh pr view <PR> --json number,title,body,author,state,baseRefName,headRefName,url,labels,milestone,additions,deletions,changedFiles,createdAt,updatedAt,mergedAt,reviewDecision,reviews,assignees +``` + +## Step 2: Gather the diff + +Get the full diff of the PR: + +```bash +gh pr diff <PR> +``` + +If the diff is very large (>3000 lines), focus on the most important files first and summarize the rest. + +## Step 3: Collect PR discussion context + +Fetch all comments and review threads: + +```bash +gh api repos/{owner}/{repo}/pulls/{number}/comments --paginate +gh api repos/{owner}/{repo}/issues/{number}/comments --paginate +gh api repos/{owner}/{repo}/pulls/{number}/reviews --paginate +``` + +Pay attention to: + +- Reviewer feedback and requested changes +- Author responses and explanations +- Any unresolved conversations +- Approval or rejection status + +## Step 4: Find and read linked issues + +Look for issue references in: + +- The PR body (patterns like `#123`, `fixes #123`, `closes #123`, `resolves #123`) +- The PR branch name (patterns like `issue-123`, `fix/123`) +- Commit messages + +For each linked issue, fetch its content: + +```bash +gh issue view <number> --json title,body,comments,labels,state +``` + +Read through issue comments to understand the original problem, user reports, and any discussed solutions. + +## Step 5: Analyze and validate + +With all context gathered, analyze the PR critically: + +1. **Intent alignment**: Does the code change actually solve the problem described in the PR and/or linked issues? +2. **Completeness**: Are there aspects of the issue or requested feature that the PR doesn't address? +3. **Scope**: Does the PR include changes unrelated to the stated goal? Are there unnecessary modifications? +4. **Correctness**: Based on the diff, are there obvious bugs, edge cases, or logic errors? +5. **Testing**: Does the PR include tests? Are they meaningful and do they cover the important cases? +6. **Breaking changes**: Could this PR break existing functionality or APIs? +7. **Unresolved feedback**: Are there reviewer comments that haven't been addressed? + +## Step 6: Produce the review summary + +Present the summary in this format: + +--- + +### PR Review: `<title>` (<url>) + +**Author:** <author> | **Status:** <state> | **Review decision:** <decision> +**Base:** `<base>` ← `<head>` | **Changed files:** <n> | **+<additions> / -<deletions>** + +#### Problem + +<1-3 sentences describing what problem this PR is trying to solve, based on the PR description and linked issues> + +#### Solution + +<1-3 sentences describing the approach taken in the code> + +#### Key changes + +<Bulleted list of the most important changes, grouped by theme. Include file paths.> + +#### Linked issues + +<List of linked issues with their title, state, and a one-line summary of the discussion> + +#### Discussion highlights + +<Summary of important comments from reviewers and the author. Flag any unresolved threads.> + +#### Concerns + +<List any issues found during validation: bugs, missing tests, scope creep, unaddressed feedback, etc. If none, say "No concerns found."> + +#### Verdict + +<One of: APPROVE / REQUEST CHANGES / NEEDS DISCUSSION, with a brief justification> + +#### Suggested action + +<Clear recommendation for the reviewer: what to approve, what to push back on, what to ask about> + +--- diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 9fd6c03c7..a39c18b53 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -1,5 +1,5 @@ -# Applied 120 line-length rule to all files: https://github.com/modelcontextprotocol/python-sdk/pull/856 -543961968c0634e93d919d509cce23a1d6a56c21 - -# Added 100% code coverage baseline with pragma comments: https://github.com/modelcontextprotocol/python-sdk/pull/1553 -89e9c43acf7e23cf766357d776ec1ce63ac2c58e +# Applied 120 line-length rule to all files: https://github.com/modelcontextprotocol/python-sdk/pull/856 +543961968c0634e93d919d509cce23a1d6a56c21 + +# Added 100% code coverage baseline with pragma comments: https://github.com/modelcontextprotocol/python-sdk/pull/1553 +89e9c43acf7e23cf766357d776ec1ce63ac2c58e diff --git a/.gitattribute b/.gitattribute index 0ab374485..3dd5af540 100644 --- a/.gitattribute +++ b/.gitattribute @@ -1,2 +1,2 @@ -# Generated -uv.lock linguist-generated=true +# Generated +uv.lock linguist-generated=true diff --git a/.github/actions/conformance/client.py b/.github/actions/conformance/client.py index 58f684f01..465f98668 100644 --- a/.github/actions/conformance/client.py +++ b/.github/actions/conformance/client.py @@ -1,367 +1,367 @@ -"""MCP unified conformance test client. - -This client is designed to work with the @modelcontextprotocol/conformance npm package. -It handles all conformance test scenarios via environment variables and CLI arguments. - -Contract: - - MCP_CONFORMANCE_SCENARIO env var -> scenario name - - MCP_CONFORMANCE_CONTEXT env var -> optional JSON (for client-credentials scenarios) - - Server URL as last CLI argument (sys.argv[1]) - - Must exit 0 within 30 seconds - -Scenarios: - initialize - Connect, initialize, list tools, close - tools_call - Connect, call add_numbers(a=5, b=3), close - sse-retry - Connect, call test_reconnection, close - elicitation-sep1034-client-defaults - Elicitation with default accept callback - auth/client-credentials-jwt - Client credentials with private_key_jwt - auth/client-credentials-basic - Client credentials with client_secret_basic - auth/* - Authorization code flow (default for auth scenarios) -""" - -import asyncio -import json -import logging -import os -import sys -from collections.abc import Callable, Coroutine -from typing import Any, cast -from urllib.parse import parse_qs, urlparse - -import httpx -from pydantic import AnyUrl - -from mcp import ClientSession, types -from mcp.client.auth import OAuthClientProvider, TokenStorage -from mcp.client.auth.extensions.client_credentials import ( - ClientCredentialsOAuthProvider, - PrivateKeyJWTOAuthProvider, - SignedJWTParameters, -) -from mcp.client.context import ClientRequestContext -from mcp.client.streamable_http import streamable_http_client -from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken - -# Set up logging to stderr (stdout is for conformance test output) -logging.basicConfig( - level=logging.DEBUG, - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - stream=sys.stderr, -) -logger = logging.getLogger(__name__) - -# Type for async scenario handler functions -ScenarioHandler = Callable[[str], Coroutine[Any, None, None]] - -# Registry of scenario handlers -HANDLERS: dict[str, ScenarioHandler] = {} - - -def register(name: str) -> Callable[[ScenarioHandler], ScenarioHandler]: - """Register a scenario handler.""" - - def decorator(fn: ScenarioHandler) -> ScenarioHandler: - HANDLERS[name] = fn - return fn - - return decorator - - -def get_conformance_context() -> dict[str, Any]: - """Load conformance test context from MCP_CONFORMANCE_CONTEXT environment variable.""" - context_json = os.environ.get("MCP_CONFORMANCE_CONTEXT") - if not context_json: - raise RuntimeError( - "MCP_CONFORMANCE_CONTEXT environment variable not set. " - "Expected JSON with client_id, client_secret, and/or private_key_pem." - ) - try: - return json.loads(context_json) - except json.JSONDecodeError as e: - raise RuntimeError(f"Failed to parse MCP_CONFORMANCE_CONTEXT as JSON: {e}") from e - - -class InMemoryTokenStorage(TokenStorage): - """Simple in-memory token storage for conformance testing.""" - - def __init__(self) -> None: - self._tokens: OAuthToken | None = None - self._client_info: OAuthClientInformationFull | None = None - - async def get_tokens(self) -> OAuthToken | None: - return self._tokens - - async def set_tokens(self, tokens: OAuthToken) -> None: - self._tokens = tokens - - async def get_client_info(self) -> OAuthClientInformationFull | None: - return self._client_info - - async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: - self._client_info = client_info - - -class ConformanceOAuthCallbackHandler: - """OAuth callback handler that automatically fetches the authorization URL - and extracts the auth code, without requiring user interaction. - """ - - def __init__(self) -> None: - self._auth_code: str | None = None - self._state: str | None = None - - async def handle_redirect(self, authorization_url: str) -> None: - """Fetch the authorization URL and extract the auth code from the redirect.""" - logger.debug(f"Fetching authorization URL: {authorization_url}") - - async with httpx.AsyncClient() as client: - response = await client.get( - authorization_url, - follow_redirects=False, - ) - - if response.status_code in (301, 302, 303, 307, 308): - location = cast(str, response.headers.get("location")) - if location: - redirect_url = urlparse(location) - query_params: dict[str, list[str]] = parse_qs(redirect_url.query) - - if "code" in query_params: - self._auth_code = query_params["code"][0] - state_values = query_params.get("state") - self._state = state_values[0] if state_values else None - logger.debug(f"Got auth code from redirect: {self._auth_code[:10]}...") - return - else: - raise RuntimeError(f"No auth code in redirect URL: {location}") - else: - raise RuntimeError(f"No redirect location received from {authorization_url}") - else: - raise RuntimeError(f"Expected redirect response, got {response.status_code} from {authorization_url}") - - async def handle_callback(self) -> tuple[str, str | None]: - """Return the captured auth code and state.""" - if self._auth_code is None: - raise RuntimeError("No authorization code available - was handle_redirect called?") - auth_code = self._auth_code - state = self._state - self._auth_code = None - self._state = None - return auth_code, state - - -# --- Scenario Handlers --- - - -@register("initialize") -async def run_initialize(server_url: str) -> None: - """Connect, initialize, list tools, close.""" - async with streamable_http_client(url=server_url) as (read_stream, write_stream): - async with ClientSession(read_stream, write_stream) as session: - await session.initialize() - logger.debug("Initialized successfully") - await session.list_tools() - logger.debug("Listed tools successfully") - - -@register("tools_call") -async def run_tools_call(server_url: str) -> None: - """Connect, initialize, list tools, call add_numbers(a=5, b=3), close.""" - async with streamable_http_client(url=server_url) as (read_stream, write_stream): - async with ClientSession(read_stream, write_stream) as session: - await session.initialize() - await session.list_tools() - result = await session.call_tool("add_numbers", {"a": 5, "b": 3}) - logger.debug(f"add_numbers result: {result}") - - -@register("sse-retry") -async def run_sse_retry(server_url: str) -> None: - """Connect, initialize, list tools, call test_reconnection, close.""" - async with streamable_http_client(url=server_url) as (read_stream, write_stream): - async with ClientSession(read_stream, write_stream) as session: - await session.initialize() - await session.list_tools() - result = await session.call_tool("test_reconnection", {}) - logger.debug(f"test_reconnection result: {result}") - - -async def default_elicitation_callback( - context: ClientRequestContext, - params: types.ElicitRequestParams, -) -> types.ElicitResult | types.ErrorData: - """Accept elicitation and apply defaults from the schema (SEP-1034).""" - content: dict[str, str | int | float | bool | list[str] | None] = {} - - # For form mode, extract defaults from the requested_schema - if isinstance(params, types.ElicitRequestFormParams): - schema = params.requested_schema - logger.debug(f"Elicitation schema: {schema}") - properties = schema.get("properties", {}) - for prop_name, prop_schema in properties.items(): - if "default" in prop_schema: - content[prop_name] = prop_schema["default"] - logger.debug(f"Applied defaults: {content}") - - return types.ElicitResult(action="accept", content=content) - - -@register("elicitation-sep1034-client-defaults") -async def run_elicitation_defaults(server_url: str) -> None: - """Connect with elicitation callback that applies schema defaults.""" - async with streamable_http_client(url=server_url) as (read_stream, write_stream): - async with ClientSession( - read_stream, write_stream, elicitation_callback=default_elicitation_callback - ) as session: - await session.initialize() - await session.list_tools() - result = await session.call_tool("test_client_elicitation_defaults", {}) - logger.debug(f"test_client_elicitation_defaults result: {result}") - - -@register("auth/client-credentials-jwt") -async def run_client_credentials_jwt(server_url: str) -> None: - """Client credentials flow with private_key_jwt authentication.""" - context = get_conformance_context() - client_id = context.get("client_id") - private_key_pem = context.get("private_key_pem") - signing_algorithm = context.get("signing_algorithm", "ES256") - - if not client_id: - raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_id'") - if not private_key_pem: - raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'private_key_pem'") - - jwt_params = SignedJWTParameters( - issuer=client_id, - subject=client_id, - signing_algorithm=signing_algorithm, - signing_key=private_key_pem, - ) - - oauth_auth = PrivateKeyJWTOAuthProvider( - server_url=server_url, - storage=InMemoryTokenStorage(), - client_id=client_id, - assertion_provider=jwt_params.create_assertion_provider(), - ) - - await _run_auth_session(server_url, oauth_auth) - - -@register("auth/client-credentials-basic") -async def run_client_credentials_basic(server_url: str) -> None: - """Client credentials flow with client_secret_basic authentication.""" - context = get_conformance_context() - client_id = context.get("client_id") - client_secret = context.get("client_secret") - - if not client_id: - raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_id'") - if not client_secret: - raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_secret'") - - oauth_auth = ClientCredentialsOAuthProvider( - server_url=server_url, - storage=InMemoryTokenStorage(), - client_id=client_id, - client_secret=client_secret, - token_endpoint_auth_method="client_secret_basic", - ) - - await _run_auth_session(server_url, oauth_auth) - - -async def run_auth_code_client(server_url: str) -> None: - """Authorization code flow (default for auth/* scenarios).""" - callback_handler = ConformanceOAuthCallbackHandler() - storage = InMemoryTokenStorage() - - # Check for pre-registered client credentials from context - context_json = os.environ.get("MCP_CONFORMANCE_CONTEXT") - if context_json: - try: - context = json.loads(context_json) - client_id = context.get("client_id") - client_secret = context.get("client_secret") - if client_id: - await storage.set_client_info( - OAuthClientInformationFull( - client_id=client_id, - client_secret=client_secret, - redirect_uris=[AnyUrl("http://localhost:3000/callback")], - token_endpoint_auth_method="client_secret_basic" if client_secret else "none", - ) - ) - logger.debug(f"Pre-loaded client credentials: client_id={client_id}") - except json.JSONDecodeError: - logger.exception("Failed to parse MCP_CONFORMANCE_CONTEXT") - - oauth_auth = OAuthClientProvider( - server_url=server_url, - client_metadata=OAuthClientMetadata( - client_name="conformance-client", - redirect_uris=[AnyUrl("http://localhost:3000/callback")], - grant_types=["authorization_code", "refresh_token"], - response_types=["code"], - ), - storage=storage, - redirect_handler=callback_handler.handle_redirect, - callback_handler=callback_handler.handle_callback, - client_metadata_url="https://conformance-test.local/client-metadata.json", - ) - - await _run_auth_session(server_url, oauth_auth) - - -async def _run_auth_session(server_url: str, oauth_auth: OAuthClientProvider) -> None: - """Common session logic for all OAuth flows.""" - client = httpx.AsyncClient(auth=oauth_auth, timeout=30.0) - async with streamable_http_client(url=server_url, http_client=client) as (read_stream, write_stream): - async with ClientSession( - read_stream, write_stream, elicitation_callback=default_elicitation_callback - ) as session: - await session.initialize() - logger.debug("Initialized successfully") - - tools_result = await session.list_tools() - logger.debug(f"Listed tools: {[t.name for t in tools_result.tools]}") - - # Call the first available tool (different tests have different tools) - if tools_result.tools: - tool_name = tools_result.tools[0].name - try: - result = await session.call_tool(tool_name, {}) - logger.debug(f"Called {tool_name}, result: {result}") - except Exception as e: - logger.debug(f"Tool call result/error: {e}") - - logger.debug("Connection closed successfully") - - -def main() -> None: - """Main entry point for the conformance client.""" - if len(sys.argv) < 2: - print(f"Usage: {sys.argv[0]} <server-url>", file=sys.stderr) - sys.exit(1) - - server_url = sys.argv[1] - scenario = os.environ.get("MCP_CONFORMANCE_SCENARIO") - - if scenario: - logger.debug(f"Running explicit scenario '{scenario}' against {server_url}") - handler = HANDLERS.get(scenario) - if handler: - asyncio.run(handler(server_url)) - elif scenario.startswith("auth/"): - asyncio.run(run_auth_code_client(server_url)) - else: - print(f"Unknown scenario: {scenario}", file=sys.stderr) - sys.exit(1) - else: - logger.debug(f"Running default auth flow against {server_url}") - asyncio.run(run_auth_code_client(server_url)) - - -if __name__ == "__main__": - main() +"""MCP unified conformance test client. + +This client is designed to work with the @modelcontextprotocol/conformance npm package. +It handles all conformance test scenarios via environment variables and CLI arguments. + +Contract: + - MCP_CONFORMANCE_SCENARIO env var -> scenario name + - MCP_CONFORMANCE_CONTEXT env var -> optional JSON (for client-credentials scenarios) + - Server URL as last CLI argument (sys.argv[1]) + - Must exit 0 within 30 seconds + +Scenarios: + initialize - Connect, initialize, list tools, close + tools_call - Connect, call add_numbers(a=5, b=3), close + sse-retry - Connect, call test_reconnection, close + elicitation-sep1034-client-defaults - Elicitation with default accept callback + auth/client-credentials-jwt - Client credentials with private_key_jwt + auth/client-credentials-basic - Client credentials with client_secret_basic + auth/* - Authorization code flow (default for auth scenarios) +""" + +import asyncio +import json +import logging +import os +import sys +from collections.abc import Callable, Coroutine +from typing import Any, cast +from urllib.parse import parse_qs, urlparse + +import httpx +from pydantic import AnyUrl + +from mcp import ClientSession, types +from mcp.client.auth import OAuthClientProvider, TokenStorage +from mcp.client.auth.extensions.client_credentials import ( + ClientCredentialsOAuthProvider, + PrivateKeyJWTOAuthProvider, + SignedJWTParameters, +) +from mcp.client.context import ClientRequestContext +from mcp.client.streamable_http import streamable_http_client +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken + +# Set up logging to stderr (stdout is for conformance test output) +logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + stream=sys.stderr, +) +logger = logging.getLogger(__name__) + +# Type for async scenario handler functions +ScenarioHandler = Callable[[str], Coroutine[Any, None, None]] + +# Registry of scenario handlers +HANDLERS: dict[str, ScenarioHandler] = {} + + +def register(name: str) -> Callable[[ScenarioHandler], ScenarioHandler]: + """Register a scenario handler.""" + + def decorator(fn: ScenarioHandler) -> ScenarioHandler: + HANDLERS[name] = fn + return fn + + return decorator + + +def get_conformance_context() -> dict[str, Any]: + """Load conformance test context from MCP_CONFORMANCE_CONTEXT environment variable.""" + context_json = os.environ.get("MCP_CONFORMANCE_CONTEXT") + if not context_json: + raise RuntimeError( + "MCP_CONFORMANCE_CONTEXT environment variable not set. " + "Expected JSON with client_id, client_secret, and/or private_key_pem." + ) + try: + return json.loads(context_json) + except json.JSONDecodeError as e: + raise RuntimeError(f"Failed to parse MCP_CONFORMANCE_CONTEXT as JSON: {e}") from e + + +class InMemoryTokenStorage(TokenStorage): + """Simple in-memory token storage for conformance testing.""" + + def __init__(self) -> None: + self._tokens: OAuthToken | None = None + self._client_info: OAuthClientInformationFull | None = None + + async def get_tokens(self) -> OAuthToken | None: + return self._tokens + + async def set_tokens(self, tokens: OAuthToken) -> None: + self._tokens = tokens + + async def get_client_info(self) -> OAuthClientInformationFull | None: + return self._client_info + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + self._client_info = client_info + + +class ConformanceOAuthCallbackHandler: + """OAuth callback handler that automatically fetches the authorization URL + and extracts the auth code, without requiring user interaction. + """ + + def __init__(self) -> None: + self._auth_code: str | None = None + self._state: str | None = None + + async def handle_redirect(self, authorization_url: str) -> None: + """Fetch the authorization URL and extract the auth code from the redirect.""" + logger.debug(f"Fetching authorization URL: {authorization_url}") + + async with httpx.AsyncClient() as client: + response = await client.get( + authorization_url, + follow_redirects=False, + ) + + if response.status_code in (301, 302, 303, 307, 308): + location = cast(str, response.headers.get("location")) + if location: + redirect_url = urlparse(location) + query_params: dict[str, list[str]] = parse_qs(redirect_url.query) + + if "code" in query_params: + self._auth_code = query_params["code"][0] + state_values = query_params.get("state") + self._state = state_values[0] if state_values else None + logger.debug(f"Got auth code from redirect: {self._auth_code[:10]}...") + return + else: + raise RuntimeError(f"No auth code in redirect URL: {location}") + else: + raise RuntimeError(f"No redirect location received from {authorization_url}") + else: + raise RuntimeError(f"Expected redirect response, got {response.status_code} from {authorization_url}") + + async def handle_callback(self) -> tuple[str, str | None]: + """Return the captured auth code and state.""" + if self._auth_code is None: + raise RuntimeError("No authorization code available - was handle_redirect called?") + auth_code = self._auth_code + state = self._state + self._auth_code = None + self._state = None + return auth_code, state + + +# --- Scenario Handlers --- + + +@register("initialize") +async def run_initialize(server_url: str) -> None: + """Connect, initialize, list tools, close.""" + async with streamable_http_client(url=server_url) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + logger.debug("Initialized successfully") + await session.list_tools() + logger.debug("Listed tools successfully") + + +@register("tools_call") +async def run_tools_call(server_url: str) -> None: + """Connect, initialize, list tools, call add_numbers(a=5, b=3), close.""" + async with streamable_http_client(url=server_url) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + await session.list_tools() + result = await session.call_tool("add_numbers", {"a": 5, "b": 3}) + logger.debug(f"add_numbers result: {result}") + + +@register("sse-retry") +async def run_sse_retry(server_url: str) -> None: + """Connect, initialize, list tools, call test_reconnection, close.""" + async with streamable_http_client(url=server_url) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + await session.list_tools() + result = await session.call_tool("test_reconnection", {}) + logger.debug(f"test_reconnection result: {result}") + + +async def default_elicitation_callback( + context: ClientRequestContext, + params: types.ElicitRequestParams, +) -> types.ElicitResult | types.ErrorData: + """Accept elicitation and apply defaults from the schema (SEP-1034).""" + content: dict[str, str | int | float | bool | list[str] | None] = {} + + # For form mode, extract defaults from the requested_schema + if isinstance(params, types.ElicitRequestFormParams): + schema = params.requested_schema + logger.debug(f"Elicitation schema: {schema}") + properties = schema.get("properties", {}) + for prop_name, prop_schema in properties.items(): + if "default" in prop_schema: + content[prop_name] = prop_schema["default"] + logger.debug(f"Applied defaults: {content}") + + return types.ElicitResult(action="accept", content=content) + + +@register("elicitation-sep1034-client-defaults") +async def run_elicitation_defaults(server_url: str) -> None: + """Connect with elicitation callback that applies schema defaults.""" + async with streamable_http_client(url=server_url) as (read_stream, write_stream): + async with ClientSession( + read_stream, write_stream, elicitation_callback=default_elicitation_callback + ) as session: + await session.initialize() + await session.list_tools() + result = await session.call_tool("test_client_elicitation_defaults", {}) + logger.debug(f"test_client_elicitation_defaults result: {result}") + + +@register("auth/client-credentials-jwt") +async def run_client_credentials_jwt(server_url: str) -> None: + """Client credentials flow with private_key_jwt authentication.""" + context = get_conformance_context() + client_id = context.get("client_id") + private_key_pem = context.get("private_key_pem") + signing_algorithm = context.get("signing_algorithm", "ES256") + + if not client_id: + raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_id'") + if not private_key_pem: + raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'private_key_pem'") + + jwt_params = SignedJWTParameters( + issuer=client_id, + subject=client_id, + signing_algorithm=signing_algorithm, + signing_key=private_key_pem, + ) + + oauth_auth = PrivateKeyJWTOAuthProvider( + server_url=server_url, + storage=InMemoryTokenStorage(), + client_id=client_id, + assertion_provider=jwt_params.create_assertion_provider(), + ) + + await _run_auth_session(server_url, oauth_auth) + + +@register("auth/client-credentials-basic") +async def run_client_credentials_basic(server_url: str) -> None: + """Client credentials flow with client_secret_basic authentication.""" + context = get_conformance_context() + client_id = context.get("client_id") + client_secret = context.get("client_secret") + + if not client_id: + raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_id'") + if not client_secret: + raise RuntimeError("MCP_CONFORMANCE_CONTEXT missing 'client_secret'") + + oauth_auth = ClientCredentialsOAuthProvider( + server_url=server_url, + storage=InMemoryTokenStorage(), + client_id=client_id, + client_secret=client_secret, + token_endpoint_auth_method="client_secret_basic", + ) + + await _run_auth_session(server_url, oauth_auth) + + +async def run_auth_code_client(server_url: str) -> None: + """Authorization code flow (default for auth/* scenarios).""" + callback_handler = ConformanceOAuthCallbackHandler() + storage = InMemoryTokenStorage() + + # Check for pre-registered client credentials from context + context_json = os.environ.get("MCP_CONFORMANCE_CONTEXT") + if context_json: + try: + context = json.loads(context_json) + client_id = context.get("client_id") + client_secret = context.get("client_secret") + if client_id: + await storage.set_client_info( + OAuthClientInformationFull( + client_id=client_id, + client_secret=client_secret, + redirect_uris=[AnyUrl("http://localhost:3000/callback")], + token_endpoint_auth_method="client_secret_basic" if client_secret else "none", + ) + ) + logger.debug(f"Pre-loaded client credentials: client_id={client_id}") + except json.JSONDecodeError: + logger.exception("Failed to parse MCP_CONFORMANCE_CONTEXT") + + oauth_auth = OAuthClientProvider( + server_url=server_url, + client_metadata=OAuthClientMetadata( + client_name="conformance-client", + redirect_uris=[AnyUrl("http://localhost:3000/callback")], + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + ), + storage=storage, + redirect_handler=callback_handler.handle_redirect, + callback_handler=callback_handler.handle_callback, + client_metadata_url="https://conformance-test.local/client-metadata.json", + ) + + await _run_auth_session(server_url, oauth_auth) + + +async def _run_auth_session(server_url: str, oauth_auth: OAuthClientProvider) -> None: + """Common session logic for all OAuth flows.""" + client = httpx.AsyncClient(auth=oauth_auth, timeout=30.0) + async with streamable_http_client(url=server_url, http_client=client) as (read_stream, write_stream): + async with ClientSession( + read_stream, write_stream, elicitation_callback=default_elicitation_callback + ) as session: + await session.initialize() + logger.debug("Initialized successfully") + + tools_result = await session.list_tools() + logger.debug(f"Listed tools: {[t.name for t in tools_result.tools]}") + + # Call the first available tool (different tests have different tools) + if tools_result.tools: + tool_name = tools_result.tools[0].name + try: + result = await session.call_tool(tool_name, {}) + logger.debug(f"Called {tool_name}, result: {result}") + except Exception as e: + logger.debug(f"Tool call result/error: {e}") + + logger.debug("Connection closed successfully") + + +def main() -> None: + """Main entry point for the conformance client.""" + if len(sys.argv) < 2: + print(f"Usage: {sys.argv[0]} <server-url>", file=sys.stderr) + sys.exit(1) + + server_url = sys.argv[1] + scenario = os.environ.get("MCP_CONFORMANCE_SCENARIO") + + if scenario: + logger.debug(f"Running explicit scenario '{scenario}' against {server_url}") + handler = HANDLERS.get(scenario) + if handler: + asyncio.run(handler(server_url)) + elif scenario.startswith("auth/"): + asyncio.run(run_auth_code_client(server_url)) + else: + print(f"Unknown scenario: {scenario}", file=sys.stderr) + sys.exit(1) + else: + logger.debug(f"Running default auth flow against {server_url}") + asyncio.run(run_auth_code_client(server_url)) + + +if __name__ == "__main__": + main() diff --git a/.github/actions/conformance/run-server.sh b/.github/actions/conformance/run-server.sh index 01af13612..60af90c12 100755 --- a/.github/actions/conformance/run-server.sh +++ b/.github/actions/conformance/run-server.sh @@ -1,30 +1,30 @@ -#!/bin/bash -set -e - -PORT="${PORT:-3001}" -SERVER_URL="http://localhost:${PORT}/mcp" - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -cd "$SCRIPT_DIR/../../.." - -# Start everything-server -uv run --frozen mcp-everything-server --port "$PORT" & -SERVER_PID=$! -trap "kill $SERVER_PID 2>/dev/null || true; wait $SERVER_PID 2>/dev/null || true" EXIT - -# Wait for server to be ready -MAX_RETRIES=30 -RETRY_COUNT=0 -while ! curl -s "$SERVER_URL" > /dev/null 2>&1; do - RETRY_COUNT=$((RETRY_COUNT + 1)) - if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then - echo "Server failed to start after ${MAX_RETRIES} retries" >&2 - exit 1 - fi - sleep 0.5 -done - -echo "Server ready at $SERVER_URL" - -# Run conformance tests -npx @modelcontextprotocol/conformance@0.1.10 server --url "$SERVER_URL" "$@" +#!/bin/bash +set -e + +PORT="${PORT:-3001}" +SERVER_URL="http://localhost:${PORT}/mcp" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR/../../.." + +# Start everything-server +uv run --frozen mcp-everything-server --port "$PORT" & +SERVER_PID=$! +trap "kill $SERVER_PID 2>/dev/null || true; wait $SERVER_PID 2>/dev/null || true" EXIT + +# Wait for server to be ready +MAX_RETRIES=30 +RETRY_COUNT=0 +while ! curl -s "$SERVER_URL" > /dev/null 2>&1; do + RETRY_COUNT=$((RETRY_COUNT + 1)) + if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then + echo "Server failed to start after ${MAX_RETRIES} retries" >&2 + exit 1 + fi + sleep 0.5 +done + +echo "Server ready at $SERVER_URL" + +# Run conformance tests +npx @modelcontextprotocol/conformance@0.1.10 server --url "$SERVER_URL" "$@" diff --git a/.gitignore b/.gitignore index 5ff4ce977..695d672aa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,174 +1,174 @@ -.DS_Store -scratch/ - -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -.ruff_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -.python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -.idea/ - -# vscode -.vscode/ -.windsurfrules -**/CLAUDE.local.md - -# claude code -results/ +.DS_Store +scratch/ + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +.ruff_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ + +# vscode +.vscode/ +.windsurfrules +**/CLAUDE.local.md + +# claude code +results/ diff --git a/AGENTS.md b/AGENTS.md index 307bd81b3..492c6c853 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,140 +1,140 @@ -# Development Guidelines - -## Branching Model - -<!-- TODO: drop this section once v2 ships and main becomes the stable line --> - -- `main` is currently the V2 rework. Breaking changes are expected here — when removing or - replacing an API, delete it outright and document the change in - `docs/migration.md`. Do not add `@deprecated` shims or backward-compat layers - on `main`. -- `v1.x` is the release branch for the current stable line. Backport PRs target - this branch and use a `[v1.x]` title prefix. -- `README.md` is frozen at v1 (a pre-commit hook rejects edits). Edit - `README.v2.md` instead. - -## Package Management - -- ONLY use uv, NEVER pip -- Installation: `uv add <package>` -- Running tools: `uv run --frozen <tool>`. Always pass `--frozen` so uv doesn't - rewrite `uv.lock` as a side effect. -- Cross-version testing: `uv run --frozen --python 3.10 pytest ...` to run - against a specific interpreter (CI covers 3.10–3.14). -- Upgrading: `uv lock --upgrade-package <package>` -- FORBIDDEN: `uv pip install`, `@latest` syntax -- Don't raise dependency floors for CVEs alone. The `>=` constraint already - lets users upgrade. Only raise a floor when the SDK needs functionality from - the newer version, and don't add SDK code to work around a dependency's - vulnerability. See Kludex/uvicorn#2643 and python-sdk #1552 for reasoning. - -## Code Quality - -- Type hints required for all code -- Public APIs must have docstrings. When a public API raises exceptions a - caller would reasonably catch, document them in a `Raises:` section. Don't - list exceptions from argument validation or programmer error. -- `src/mcp/__init__.py` defines the public API surface via `__all__`. Adding a - symbol there is a deliberate API decision, not a convenience re-export. -- IMPORTANT: All imports go at the top of the file — inline imports hide - dependencies and obscure circular-import bugs. Only exception: when a - top-level import genuinely can't work (lazy-loading optional deps, or - tests that re-import a module). - -## Testing - -- Framework: `uv run --frozen pytest` -- Async testing: use anyio, not asyncio -- Do not use `Test` prefixed classes — write plain top-level `test_*` functions. - Legacy files still contain `Test*` classes; do NOT follow that pattern for new - tests even when adding to such a file. -- IMPORTANT: Tests should be fast and deterministic. Prefer in-memory async execution; - reach for threads only when necessary, and subprocesses only as a last resort. -- For end-to-end behavior, an in-memory `Client(server)` is usually the - cleanest approach (see `tests/client/test_client.py` for the canonical - pattern). For narrower changes, testing the function directly is fine. Use - judgment. -- Test files mirror the source tree: `src/mcp/client/stdio.py` → - `tests/client/test_stdio.py`. Add tests to the existing file for that module. -- Avoid `anyio.sleep()` with a fixed duration to wait for async operations. Instead: - - Use `anyio.Event` — set it in the callback/handler, `await event.wait()` in the test - - For stream messages, use `await stream.receive()` instead of `sleep()` + `receive_nowait()` - - Exception: `sleep()` is appropriate when testing time-based features (e.g., timeouts) -- Wrap indefinite waits (`event.wait()`, `stream.receive()`) in `anyio.fail_after(5)` to prevent hangs -- Pytest is configured with `filterwarnings = ["error"]`, so warnings fail - tests. Don't silence warnings from your own code; fix the underlying cause. - Scoped `ignore::` entries for upstream libraries are acceptable in - `pyproject.toml` with a comment explaining why. - -### Coverage - -CI requires 100% (`fail_under = 100`, `branch = true`). - -- Full check: `./scripts/test` (~23s). Runs coverage + `strict-no-cover` on the - default Python. Not identical to CI: CI runs 3.10–3.14 × {ubuntu, windows} - × {locked, lowest-direct}, and some branch-coverage quirks only surface on - specific matrix entries. -- Targeted check while iterating (~4s, deterministic): - - ```bash - uv run --frozen coverage erase - uv run --frozen coverage run -m pytest tests/path/test_foo.py - uv run --frozen coverage combine - uv run --frozen coverage report --include='src/mcp/path/foo.py' --fail-under=0 - # UV_FROZEN=1 propagates --frozen to the uv subprocess strict-no-cover spawns - UV_FROZEN=1 uv run --frozen strict-no-cover - ``` - - Partial runs can't hit 100% (coverage tracks `tests/` too), so `--fail-under=0` - and `--include` scope the report. `strict-no-cover` has no false positives on - partial runs — if your new test executes a line marked `# pragma: no cover`, - even a single-file run catches it. - -Avoid adding new `# pragma: no cover`, `# type: ignore`, or `# noqa` comments. -In tests, use `assert isinstance(x, T)` to narrow types instead of -`# type: ignore`. In library code (`src/`), a `# pragma: no cover` needs very -good reasoning — it usually means a test is missing. Audit before pushing: - -```bash -git diff origin/main... | grep -E '^\+.*(pragma|type: ignore|noqa)' -``` - -What the existing pragmas mean: - -- `# pragma: no cover` — line is never executed. CI's `strict-no-cover` (skipped - on Windows runners) fails if it IS executed. When your test starts covering - such a line, remove the pragma. -- `# pragma: lax no cover` — excluded from coverage but not checked by - `strict-no-cover`. Use for lines covered on some platforms/versions but not - others. -- `# pragma: no branch` — excludes branch arcs only. coverage.py misreports the - `->exit` arc for nested `async with` on Python 3.11+ (worse on 3.14/Windows). - -## Breaking Changes - -When making breaking changes, document them in `docs/migration.md`. Include: - -- What changed -- Why it changed -- How to migrate existing code - -Search for related sections in the migration guide and group related changes together -rather than adding new standalone sections. - -## Formatting & Type Checking - -- Format: `uv run --frozen ruff format .` -- Lint: `uv run --frozen ruff check . --fix` -- Type check: `uv run --frozen pyright` -- Pre-commit runs all of the above plus markdownlint, a `uv.lock` consistency - check, and README checks — see `.pre-commit-config.yaml` - -## Exception Handling - -- **Always use `logger.exception()` instead of `logger.error()` when catching exceptions** - - Don't include the exception in the message: `logger.exception("Failed")` not `logger.exception(f"Failed: {e}")` -- **Catch specific exceptions** where possible: - - File ops: `except (OSError, PermissionError):` - - JSON: `except json.JSONDecodeError:` - - Network: `except (ConnectionError, TimeoutError):` -- **FORBIDDEN** `except Exception:` - unless in top-level handlers +# Development Guidelines + +## Branching Model + +<!-- TODO: drop this section once v2 ships and main becomes the stable line --> + +- `main` is currently the V2 rework. Breaking changes are expected here — when removing or + replacing an API, delete it outright and document the change in + `docs/migration.md`. Do not add `@deprecated` shims or backward-compat layers + on `main`. +- `v1.x` is the release branch for the current stable line. Backport PRs target + this branch and use a `[v1.x]` title prefix. +- `README.md` is frozen at v1 (a pre-commit hook rejects edits). Edit + `README.v2.md` instead. + +## Package Management + +- ONLY use uv, NEVER pip +- Installation: `uv add <package>` +- Running tools: `uv run --frozen <tool>`. Always pass `--frozen` so uv doesn't + rewrite `uv.lock` as a side effect. +- Cross-version testing: `uv run --frozen --python 3.10 pytest ...` to run + against a specific interpreter (CI covers 3.10–3.14). +- Upgrading: `uv lock --upgrade-package <package>` +- FORBIDDEN: `uv pip install`, `@latest` syntax +- Don't raise dependency floors for CVEs alone. The `>=` constraint already + lets users upgrade. Only raise a floor when the SDK needs functionality from + the newer version, and don't add SDK code to work around a dependency's + vulnerability. See Kludex/uvicorn#2643 and python-sdk #1552 for reasoning. + +## Code Quality + +- Type hints required for all code +- Public APIs must have docstrings. When a public API raises exceptions a + caller would reasonably catch, document them in a `Raises:` section. Don't + list exceptions from argument validation or programmer error. +- `src/mcp/__init__.py` defines the public API surface via `__all__`. Adding a + symbol there is a deliberate API decision, not a convenience re-export. +- IMPORTANT: All imports go at the top of the file — inline imports hide + dependencies and obscure circular-import bugs. Only exception: when a + top-level import genuinely can't work (lazy-loading optional deps, or + tests that re-import a module). + +## Testing + +- Framework: `uv run --frozen pytest` +- Async testing: use anyio, not asyncio +- Do not use `Test` prefixed classes — write plain top-level `test_*` functions. + Legacy files still contain `Test*` classes; do NOT follow that pattern for new + tests even when adding to such a file. +- IMPORTANT: Tests should be fast and deterministic. Prefer in-memory async execution; + reach for threads only when necessary, and subprocesses only as a last resort. +- For end-to-end behavior, an in-memory `Client(server)` is usually the + cleanest approach (see `tests/client/test_client.py` for the canonical + pattern). For narrower changes, testing the function directly is fine. Use + judgment. +- Test files mirror the source tree: `src/mcp/client/stdio.py` → + `tests/client/test_stdio.py`. Add tests to the existing file for that module. +- Avoid `anyio.sleep()` with a fixed duration to wait for async operations. Instead: + - Use `anyio.Event` — set it in the callback/handler, `await event.wait()` in the test + - For stream messages, use `await stream.receive()` instead of `sleep()` + `receive_nowait()` + - Exception: `sleep()` is appropriate when testing time-based features (e.g., timeouts) +- Wrap indefinite waits (`event.wait()`, `stream.receive()`) in `anyio.fail_after(5)` to prevent hangs +- Pytest is configured with `filterwarnings = ["error"]`, so warnings fail + tests. Don't silence warnings from your own code; fix the underlying cause. + Scoped `ignore::` entries for upstream libraries are acceptable in + `pyproject.toml` with a comment explaining why. + +### Coverage + +CI requires 100% (`fail_under = 100`, `branch = true`). + +- Full check: `./scripts/test` (~23s). Runs coverage + `strict-no-cover` on the + default Python. Not identical to CI: CI runs 3.10–3.14 × {ubuntu, windows} + × {locked, lowest-direct}, and some branch-coverage quirks only surface on + specific matrix entries. +- Targeted check while iterating (~4s, deterministic): + + ```bash + uv run --frozen coverage erase + uv run --frozen coverage run -m pytest tests/path/test_foo.py + uv run --frozen coverage combine + uv run --frozen coverage report --include='src/mcp/path/foo.py' --fail-under=0 + # UV_FROZEN=1 propagates --frozen to the uv subprocess strict-no-cover spawns + UV_FROZEN=1 uv run --frozen strict-no-cover + ``` + + Partial runs can't hit 100% (coverage tracks `tests/` too), so `--fail-under=0` + and `--include` scope the report. `strict-no-cover` has no false positives on + partial runs — if your new test executes a line marked `# pragma: no cover`, + even a single-file run catches it. + +Avoid adding new `# pragma: no cover`, `# type: ignore`, or `# noqa` comments. +In tests, use `assert isinstance(x, T)` to narrow types instead of +`# type: ignore`. In library code (`src/`), a `# pragma: no cover` needs very +good reasoning — it usually means a test is missing. Audit before pushing: + +```bash +git diff origin/main... | grep -E '^\+.*(pragma|type: ignore|noqa)' +``` + +What the existing pragmas mean: + +- `# pragma: no cover` — line is never executed. CI's `strict-no-cover` (skipped + on Windows runners) fails if it IS executed. When your test starts covering + such a line, remove the pragma. +- `# pragma: lax no cover` — excluded from coverage but not checked by + `strict-no-cover`. Use for lines covered on some platforms/versions but not + others. +- `# pragma: no branch` — excludes branch arcs only. coverage.py misreports the + `->exit` arc for nested `async with` on Python 3.11+ (worse on 3.14/Windows). + +## Breaking Changes + +When making breaking changes, document them in `docs/migration.md`. Include: + +- What changed +- Why it changed +- How to migrate existing code + +Search for related sections in the migration guide and group related changes together +rather than adding new standalone sections. + +## Formatting & Type Checking + +- Format: `uv run --frozen ruff format .` +- Lint: `uv run --frozen ruff check . --fix` +- Type check: `uv run --frozen pyright` +- Pre-commit runs all of the above plus markdownlint, a `uv.lock` consistency + check, and README checks — see `.pre-commit-config.yaml` + +## Exception Handling + +- **Always use `logger.exception()` instead of `logger.error()` when catching exceptions** + - Don't include the exception in the message: `logger.exception("Failed")` not `logger.exception(f"Failed: {e}")` +- **Catch specific exceptions** where possible: + - File ops: `except (OSError, PermissionError):` + - JSON: `except json.JSONDecodeError:` + - Network: `except (ConnectionError, TimeoutError):` +- **FORBIDDEN** `except Exception:` - unless in top-level handlers diff --git a/CLAUDE.md b/CLAUDE.md index 43c994c2d..fd25442bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1 @@ -@AGENTS.md +@AGENTS.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 985a28566..a933e1224 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,128 +1,128 @@ -# Contributor Covenant Code of Conduct - -## Our Pledge - -We as members, contributors, and leaders pledge to make participation in our -community a harassment-free experience for everyone, regardless of age, body -size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, -nationality, personal appearance, race, religion, or sexual identity -and orientation. - -We pledge to act and interact in ways that contribute to an open, welcoming, -diverse, inclusive, and healthy community. - -## Our Standards - -Examples of behavior that contributes to a positive environment for our -community include: - -* Demonstrating empathy and kindness toward other people -* Being respectful of differing opinions, viewpoints, and experiences -* Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, - and learning from the experience -* Focusing on what is best not just for us as individuals, but for the - overall community - -Examples of unacceptable behavior include: - -* The use of sexualized language or imagery, and sexual attention or - advances of any kind -* Trolling, insulting or derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or email - address, without their explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting - -## Enforcement Responsibilities - -Community leaders are responsible for clarifying and enforcing our standards of -acceptable behavior and will take appropriate and fair corrective action in -response to any behavior that they deem inappropriate, threatening, offensive, -or harmful. - -Community leaders have the right and responsibility to remove, edit, or reject -comments, commits, code, wiki edits, issues, and other contributions that are -not aligned to this Code of Conduct, and will communicate reasons for moderation -decisions when appropriate. - -## Scope - -This Code of Conduct applies within all community spaces, and also applies when -an individual is officially representing the community in public spaces. -Examples of representing our community include using an official e-mail address, -posting via an official social media account, or acting as an appointed -representative at an online or offline event. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the community leaders responsible for enforcement at -<mcp-coc@anthropic.com>. -All complaints will be reviewed and investigated promptly and fairly. - -All community leaders are obligated to respect the privacy and security of the -reporter of any incident. - -## Enforcement Guidelines - -Community leaders will follow these Community Impact Guidelines in determining -the consequences for any action they deem in violation of this Code of Conduct: - -### 1. Correction - -**Community Impact**: Use of inappropriate language or other behavior deemed -unprofessional or unwelcome in the community. - -**Consequence**: A private, written warning from community leaders, providing -clarity around the nature of the violation and an explanation of why the -behavior was inappropriate. A public apology may be requested. - -### 2. Warning - -**Community Impact**: A violation through a single incident or series -of actions. - -**Consequence**: A warning with consequences for continued behavior. No -interaction with the people involved, including unsolicited interaction with -those enforcing the Code of Conduct, for a specified period of time. This -includes avoiding interactions in community spaces as well as external channels -like social media. Violating these terms may lead to a temporary or -permanent ban. - -### 3. Temporary Ban - -**Community Impact**: A serious violation of community standards, including -sustained inappropriate behavior. - -**Consequence**: A temporary ban from any sort of interaction or public -communication with the community for a specified period of time. No public or -private interaction with the people involved, including unsolicited interaction -with those enforcing the Code of Conduct, is allowed during this period. -Violating these terms may lead to a permanent ban. - -### 4. Permanent Ban - -**Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, harassment of an -individual, or aggression toward or disparagement of classes of individuals. - -**Consequence**: A permanent ban from any sort of public interaction within -the community. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], -version 2.0, available at -<https://www.contributor-covenant.org/version/2/0/code_of_conduct.html>. - -Community Impact Guidelines were inspired by [Mozilla's code of conduct -enforcement ladder](https://github.com/mozilla/diversity). - -[homepage]: https://www.contributor-covenant.org - -For answers to common questions about this code of conduct, see the FAQ at -<https://www.contributor-covenant.org/faq>. Translations are available at -<https://www.contributor-covenant.org/translations>. +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +<mcp-coc@anthropic.com>. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +<https://www.contributor-covenant.org/version/2/0/code_of_conduct.html>. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +<https://www.contributor-covenant.org/faq>. Translations are available at +<https://www.contributor-covenant.org/translations>. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 64187086f..d3a94d43f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,146 +1,146 @@ -# Contributing - -Thank you for your interest in contributing to the MCP Python SDK! This document provides guidelines and instructions for contributing. - -## Before You Start - -We welcome contributions! These guidelines exist to save everyone time, yours included. Following them means your work is more likely to be accepted. - -**All pull requests require a corresponding issue.** Unless your change is trivial (typo, docs tweak, broken link), create an issue first. Every merged feature becomes ongoing maintenance, so we need to agree something is worth doing before reviewing code. PRs without a linked issue will be closed. - -Having an issue doesn't guarantee acceptance. Wait for maintainer feedback or a `ready for work` label before starting. PRs for issues without buy-in may also be closed. - -Use issues to validate your idea before investing time in code. PRs are for execution, not exploration. - -### The SDK is Opinionated - -Not every contribution will be accepted, even with a working implementation. We prioritize maintainability and consistency over adding capabilities. This is at maintainers' discretion. - -### What Needs Discussion - -These always require an issue first: - -- New public APIs or decorators -- Architectural changes or refactoring -- Changes that touch multiple modules -- Features that might require spec changes (these need a [SEP](https://github.com/modelcontextprotocol/modelcontextprotocol) first) - -Bug fixes for clear, reproducible issues are welcome—but still create an issue to track the fix. - -### Finding Issues to Work On - -| Label | For | Description | -|-------|-----|-------------| -| [`good first issue`](https://github.com/modelcontextprotocol/python-sdk/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22) | Newcomers | Can tackle without deep codebase knowledge | -| [`help wanted`](https://github.com/modelcontextprotocol/python-sdk/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22) | Experienced contributors | Maintainers probably won't get to this | -| [`ready for work`](https://github.com/modelcontextprotocol/python-sdk/issues?q=is%3Aopen+is%3Aissue+label%3A%22ready+for+work%22) | Maintainers | Triaged and ready for a maintainer to pick up | - -Issues labeled `needs confirmation` or `needs maintainer action` are **not** ready for work—wait for maintainer input first. - -Before starting, comment on the issue so we can assign it to you. This prevents duplicate effort. - -## Development Setup - -1. Make sure you have Python 3.10+ installed -2. Install [uv](https://docs.astral.sh/uv/getting-started/installation/) -3. Fork the repository -4. Clone your fork: `git clone https://github.com/YOUR-USERNAME/python-sdk.git` -5. Install dependencies: - -```bash -uv sync --frozen --all-extras --dev -``` - -6. Set up pre-commit hooks: - -```bash -uv tool install pre-commit --with pre-commit-uv --force-reinstall -``` - -## Development Workflow - -1. Choose the correct branch for your changes: - - | Change Type | Target Branch | Example | - |-------------|---------------|---------| - | New features, breaking changes | `main` | New APIs, refactors | - | Security fixes for v1 | `v1.x` | Critical patches | - | Bug fixes for v1 | `v1.x` | Non-breaking fixes | - - > **Note:** `main` is the v2 development branch. Breaking changes are welcome on `main`. The `v1.x` branch receives only security and critical bug fixes. - -2. Create a new branch from your chosen base branch - -3. Make your changes - -4. Ensure tests pass: - -```bash -uv run pytest -``` - -5. Run type checking: - -```bash -uv run pyright -``` - -6. Run linting: - -```bash -uv run ruff check . -uv run ruff format . -``` - -7. Update README snippets if you modified example code: - -```bash -uv run scripts/update_readme_snippets.py -``` - -8. (Optional) Run pre-commit hooks on all files: - -```bash -pre-commit run --all-files -``` - -9. Submit a pull request to the same branch you branched from - -## Code Style - -- We use `ruff` for linting and formatting -- Follow PEP 8 style guidelines -- Add type hints to all functions -- Include docstrings for public APIs - -## Pull Requests - -By the time you open a PR, the "what" and "why" should already be settled in an issue. This keeps reviews focused on implementation. - -### Scope - -Small PRs get reviewed fast. Large PRs sit in the queue. - -A few dozen lines can be reviewed in minutes. Hundreds of lines across many files takes real effort and things slip through. If your change is big, break it into smaller PRs or get alignment from a maintainer first. - -### What Gets Rejected - -- **No prior discussion**: Features or significant changes without an approved issue -- **Scope creep**: Changes that go beyond what was discussed -- **Misalignment**: Even well-implemented features may be rejected if they don't fit the SDK's direction -- **Overengineering**: Unnecessary complexity for simple problems - -### Checklist - -1. Update documentation as needed -2. Add tests for new functionality -3. Ensure CI passes -4. Address review feedback - -## Code of Conduct - -Please note that this project is released with a [Code of Conduct](CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms. - -## License - -By contributing, you agree that your contributions will be licensed under the MIT License. +# Contributing + +Thank you for your interest in contributing to the MCP Python SDK! This document provides guidelines and instructions for contributing. + +## Before You Start + +We welcome contributions! These guidelines exist to save everyone time, yours included. Following them means your work is more likely to be accepted. + +**All pull requests require a corresponding issue.** Unless your change is trivial (typo, docs tweak, broken link), create an issue first. Every merged feature becomes ongoing maintenance, so we need to agree something is worth doing before reviewing code. PRs without a linked issue will be closed. + +Having an issue doesn't guarantee acceptance. Wait for maintainer feedback or a `ready for work` label before starting. PRs for issues without buy-in may also be closed. + +Use issues to validate your idea before investing time in code. PRs are for execution, not exploration. + +### The SDK is Opinionated + +Not every contribution will be accepted, even with a working implementation. We prioritize maintainability and consistency over adding capabilities. This is at maintainers' discretion. + +### What Needs Discussion + +These always require an issue first: + +- New public APIs or decorators +- Architectural changes or refactoring +- Changes that touch multiple modules +- Features that might require spec changes (these need a [SEP](https://github.com/modelcontextprotocol/modelcontextprotocol) first) + +Bug fixes for clear, reproducible issues are welcome—but still create an issue to track the fix. + +### Finding Issues to Work On + +| Label | For | Description | +|-------|-----|-------------| +| [`good first issue`](https://github.com/modelcontextprotocol/python-sdk/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22) | Newcomers | Can tackle without deep codebase knowledge | +| [`help wanted`](https://github.com/modelcontextprotocol/python-sdk/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22) | Experienced contributors | Maintainers probably won't get to this | +| [`ready for work`](https://github.com/modelcontextprotocol/python-sdk/issues?q=is%3Aopen+is%3Aissue+label%3A%22ready+for+work%22) | Maintainers | Triaged and ready for a maintainer to pick up | + +Issues labeled `needs confirmation` or `needs maintainer action` are **not** ready for work—wait for maintainer input first. + +Before starting, comment on the issue so we can assign it to you. This prevents duplicate effort. + +## Development Setup + +1. Make sure you have Python 3.10+ installed +2. Install [uv](https://docs.astral.sh/uv/getting-started/installation/) +3. Fork the repository +4. Clone your fork: `git clone https://github.com/YOUR-USERNAME/python-sdk.git` +5. Install dependencies: + +```bash +uv sync --frozen --all-extras --dev +``` + +6. Set up pre-commit hooks: + +```bash +uv tool install pre-commit --with pre-commit-uv --force-reinstall +``` + +## Development Workflow + +1. Choose the correct branch for your changes: + + | Change Type | Target Branch | Example | + |-------------|---------------|---------| + | New features, breaking changes | `main` | New APIs, refactors | + | Security fixes for v1 | `v1.x` | Critical patches | + | Bug fixes for v1 | `v1.x` | Non-breaking fixes | + + > **Note:** `main` is the v2 development branch. Breaking changes are welcome on `main`. The `v1.x` branch receives only security and critical bug fixes. + +2. Create a new branch from your chosen base branch + +3. Make your changes + +4. Ensure tests pass: + +```bash +uv run pytest +``` + +5. Run type checking: + +```bash +uv run pyright +``` + +6. Run linting: + +```bash +uv run ruff check . +uv run ruff format . +``` + +7. Update README snippets if you modified example code: + +```bash +uv run scripts/update_readme_snippets.py +``` + +8. (Optional) Run pre-commit hooks on all files: + +```bash +pre-commit run --all-files +``` + +9. Submit a pull request to the same branch you branched from + +## Code Style + +- We use `ruff` for linting and formatting +- Follow PEP 8 style guidelines +- Add type hints to all functions +- Include docstrings for public APIs + +## Pull Requests + +By the time you open a PR, the "what" and "why" should already be settled in an issue. This keeps reviews focused on implementation. + +### Scope + +Small PRs get reviewed fast. Large PRs sit in the queue. + +A few dozen lines can be reviewed in minutes. Hundreds of lines across many files takes real effort and things slip through. If your change is big, break it into smaller PRs or get alignment from a maintainer first. + +### What Gets Rejected + +- **No prior discussion**: Features or significant changes without an approved issue +- **Scope creep**: Changes that go beyond what was discussed +- **Misalignment**: Even well-implemented features may be rejected if they don't fit the SDK's direction +- **Overengineering**: Unnecessary complexity for simple problems + +### Checklist + +1. Update documentation as needed +2. Add tests for new functionality +3. Ensure CI passes +4. Address review feedback + +## Code of Conduct + +Please note that this project is released with a [Code of Conduct](CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms. + +## License + +By contributing, you agree that your contributions will be licensed under the MIT License. diff --git a/LICENSE b/LICENSE index 3d4843545..2f352f619 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,21 @@ -MIT License - -Copyright (c) 2024 Anthropic, PBC - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +MIT License + +Copyright (c) 2024 Anthropic, PBC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 487d48bee..3b9f7f549 100644 --- a/README.md +++ b/README.md @@ -1,2570 +1,2570 @@ -# MCP Python SDK - -<div align="center"> - -<strong>Python implementation of the Model Context Protocol (MCP)</strong> - -[![PyPI][pypi-badge]][pypi-url] -[![MIT licensed][mit-badge]][mit-url] -[![Python Version][python-badge]][python-url] -[![Documentation][docs-badge]][docs-url] -[![Protocol][protocol-badge]][protocol-url] -[![Specification][spec-badge]][spec-url] - -</div> - -<!-- TODO(v2): Replace this README with README.v2.md when v2 is released --> - -> [!NOTE] -> **This README documents v1.x of the MCP Python SDK (the current stable release).** -> -> For v1.x code and documentation, see the [`v1.x` branch](https://github.com/modelcontextprotocol/python-sdk/tree/v1.x). -> For the upcoming v2 documentation (pre-alpha, in development on `main`), see [`README.v2.md`](README.v2.md). - -<!-- omit in toc --> -## Table of Contents - -- [MCP Python SDK](#mcp-python-sdk) - - [Overview](#overview) - - [Installation](#installation) - - [Adding MCP to your python project](#adding-mcp-to-your-python-project) - - [Running the standalone MCP development tools](#running-the-standalone-mcp-development-tools) - - [Quickstart](#quickstart) - - [What is MCP?](#what-is-mcp) - - [Core Concepts](#core-concepts) - - [Server](#server) - - [Resources](#resources) - - [Tools](#tools) - - [Structured Output](#structured-output) - - [Prompts](#prompts) - - [Images](#images) - - [Context](#context) - - [Getting Context in Functions](#getting-context-in-functions) - - [Context Properties and Methods](#context-properties-and-methods) - - [Completions](#completions) - - [Elicitation](#elicitation) - - [Sampling](#sampling) - - [Logging and Notifications](#logging-and-notifications) - - [Authentication](#authentication) - - [FastMCP Properties](#fastmcp-properties) - - [Session Properties and Methods](#session-properties-and-methods) - - [Request Context Properties](#request-context-properties) - - [Running Your Server](#running-your-server) - - [Development Mode](#development-mode) - - [Claude Desktop Integration](#claude-desktop-integration) - - [Direct Execution](#direct-execution) - - [Streamable HTTP Transport](#streamable-http-transport) - - [CORS Configuration for Browser-Based Clients](#cors-configuration-for-browser-based-clients) - - [Mounting to an Existing ASGI Server](#mounting-to-an-existing-asgi-server) - - [StreamableHTTP servers](#streamablehttp-servers) - - [Basic mounting](#basic-mounting) - - [Host-based routing](#host-based-routing) - - [Multiple servers with path configuration](#multiple-servers-with-path-configuration) - - [Path configuration at initialization](#path-configuration-at-initialization) - - [SSE servers](#sse-servers) - - [Advanced Usage](#advanced-usage) - - [Low-Level Server](#low-level-server) - - [Structured Output Support](#structured-output-support) - - [Pagination (Advanced)](#pagination-advanced) - - [Writing MCP Clients](#writing-mcp-clients) - - [Client Display Utilities](#client-display-utilities) - - [OAuth Authentication for Clients](#oauth-authentication-for-clients) - - [Parsing Tool Results](#parsing-tool-results) - - [MCP Primitives](#mcp-primitives) - - [Server Capabilities](#server-capabilities) - - [Documentation](#documentation) - - [Contributing](#contributing) - - [License](#license) - -[pypi-badge]: https://img.shields.io/pypi/v/mcp.svg -[pypi-url]: https://pypi.org/project/mcp/ -[mit-badge]: https://img.shields.io/pypi/l/mcp.svg -[mit-url]: https://github.com/modelcontextprotocol/python-sdk/blob/main/LICENSE -[python-badge]: https://img.shields.io/pypi/pyversions/mcp.svg -[python-url]: https://www.python.org/downloads/ -[docs-badge]: https://img.shields.io/badge/docs-python--sdk-blue.svg -[docs-url]: https://modelcontextprotocol.github.io/python-sdk/ -[protocol-badge]: https://img.shields.io/badge/protocol-modelcontextprotocol.io-blue.svg -[protocol-url]: https://modelcontextprotocol.io -[spec-badge]: https://img.shields.io/badge/spec-spec.modelcontextprotocol.io-blue.svg -[spec-url]: https://modelcontextprotocol.io/specification/latest - -## Overview - -The Model Context Protocol allows applications to provide context for LLMs in a standardized way, separating the concerns of providing context from the actual LLM interaction. This Python SDK implements the full MCP specification, making it easy to: - -- Build MCP clients that can connect to any MCP server -- Create MCP servers that expose resources, prompts and tools -- Use standard transports like stdio, SSE, and Streamable HTTP -- Handle all MCP protocol messages and lifecycle events - -## Installation - -### Adding MCP to your python project - -We recommend using [uv](https://docs.astral.sh/uv/) to manage your Python projects. - -If you haven't created a uv-managed project yet, create one: - - ```bash - uv init mcp-server-demo - cd mcp-server-demo - ``` - - Then add MCP to your project dependencies: - - ```bash - uv add "mcp[cli]" - ``` - -Alternatively, for projects using pip for dependencies: - -```bash -pip install "mcp[cli]" -``` - -### Running the standalone MCP development tools - -To run the mcp command with uv: - -```bash -uv run mcp -``` - -## Quickstart - -Let's create a simple MCP server that exposes a calculator tool and some data: - -<!-- snippet-source examples/snippets/servers/fastmcp_quickstart.py --> -```python -""" -FastMCP quickstart example. - -Run from the repository root: - uv run examples/snippets/servers/fastmcp_quickstart.py -""" - -from mcp.server.fastmcp import FastMCP - -# Create an MCP server -mcp = FastMCP("Demo", json_response=True) - - -# Add an addition tool -@mcp.tool() -def add(a: int, b: int) -> int: - """Add two numbers""" - return a + b - - -# Add a dynamic greeting resource -@mcp.resource("greeting://{name}") -def get_greeting(name: str) -> str: - """Get a personalized greeting""" - return f"Hello, {name}!" - - -# Add a prompt -@mcp.prompt() -def greet_user(name: str, style: str = "friendly") -> str: - """Generate a greeting prompt""" - styles = { - "friendly": "Please write a warm, friendly greeting", - "formal": "Please write a formal, professional greeting", - "casual": "Please write a casual, relaxed greeting", - } - - return f"{styles.get(style, styles['friendly'])} for someone named {name}." - - -# Run with streamable HTTP transport -if __name__ == "__main__": - mcp.run(transport="streamable-http") -``` - -_Full example: [examples/snippets/servers/fastmcp_quickstart.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/fastmcp_quickstart.py)_ -<!-- /snippet-source --> - -You can install this server in [Claude Code](https://docs.claude.com/en/docs/claude-code/mcp) and interact with it right away. First, run the server: - -```bash -uv run --with mcp examples/snippets/servers/fastmcp_quickstart.py -``` - -Then add it to Claude Code: - -```bash -claude mcp add --transport http my-server http://localhost:8000/mcp -``` - -Alternatively, you can test it with the MCP Inspector. Start the server as above, then in a separate terminal: - -```bash -npx -y @modelcontextprotocol/inspector -``` - -In the inspector UI, connect to `http://localhost:8000/mcp`. - -## What is MCP? - -The [Model Context Protocol (MCP)](https://modelcontextprotocol.io) lets you build servers that expose data and functionality to LLM applications in a secure, standardized way. Think of it like a web API, but specifically designed for LLM interactions. MCP servers can: - -- Expose data through **Resources** (think of these sort of like GET endpoints; they are used to load information into the LLM's context) -- Provide functionality through **Tools** (sort of like POST endpoints; they are used to execute code or otherwise produce a side effect) -- Define interaction patterns through **Prompts** (reusable templates for LLM interactions) -- And more! - -## Core Concepts - -### Server - -The FastMCP server is your core interface to the MCP protocol. It handles connection management, protocol compliance, and message routing: - -<!-- snippet-source examples/snippets/servers/lifespan_example.py --> -```python -"""Example showing lifespan support for startup/shutdown with strong typing.""" - -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from dataclasses import dataclass - -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession - - -# Mock database class for example -class Database: - """Mock database class for example.""" - - @classmethod - async def connect(cls) -> "Database": - """Connect to database.""" - return cls() - - async def disconnect(self) -> None: - """Disconnect from database.""" - pass - - def query(self) -> str: - """Execute a query.""" - return "Query result" - - -@dataclass -class AppContext: - """Application context with typed dependencies.""" - - db: Database - - -@asynccontextmanager -async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: - """Manage application lifecycle with type-safe context.""" - # Initialize on startup - db = await Database.connect() - try: - yield AppContext(db=db) - finally: - # Cleanup on shutdown - await db.disconnect() - - -# Pass lifespan to server -mcp = FastMCP("My App", lifespan=app_lifespan) - - -# Access type-safe lifespan context in tools -@mcp.tool() -def query_db(ctx: Context[ServerSession, AppContext]) -> str: - """Tool that uses initialized resources.""" - db = ctx.request_context.lifespan_context.db - return db.query() -``` - -_Full example: [examples/snippets/servers/lifespan_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lifespan_example.py)_ -<!-- /snippet-source --> - -### Resources - -Resources are how you expose data to LLMs. They're similar to GET endpoints in a REST API - they provide data but shouldn't perform significant computation or have side effects: - -<!-- snippet-source examples/snippets/servers/basic_resource.py --> -```python -from mcp.server.fastmcp import FastMCP - -mcp = FastMCP(name="Resource Example") - - -@mcp.resource("file://documents/{name}") -def read_document(name: str) -> str: - """Read a document by name.""" - # This would normally read from disk - return f"Content of {name}" - - -@mcp.resource("config://settings") -def get_settings() -> str: - """Get application settings.""" - return """{ - "theme": "dark", - "language": "en", - "debug": false -}""" -``` - -_Full example: [examples/snippets/servers/basic_resource.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/basic_resource.py)_ -<!-- /snippet-source --> - -### Tools - -Tools let LLMs take actions through your server. Unlike resources, tools are expected to perform computation and have side effects: - -<!-- snippet-source examples/snippets/servers/basic_tool.py --> -```python -from mcp.server.fastmcp import FastMCP - -mcp = FastMCP(name="Tool Example") - - -@mcp.tool() -def sum(a: int, b: int) -> int: - """Add two numbers together.""" - return a + b - - -@mcp.tool() -def get_weather(city: str, unit: str = "celsius") -> str: - """Get weather for a city.""" - # This would normally call a weather API - return f"Weather in {city}: 22degrees{unit[0].upper()}" -``` - -_Full example: [examples/snippets/servers/basic_tool.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/basic_tool.py)_ -<!-- /snippet-source --> - -Tools can optionally receive a Context object by including a parameter with the `Context` type annotation. This context is automatically injected by the FastMCP framework and provides access to MCP capabilities: - -<!-- snippet-source examples/snippets/servers/tool_progress.py --> -```python -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession - -mcp = FastMCP(name="Progress Example") - - -@mcp.tool() -async def long_running_task(task_name: str, ctx: Context[ServerSession, None], steps: int = 5) -> str: - """Execute a task with progress updates.""" - await ctx.info(f"Starting: {task_name}") - - for i in range(steps): - progress = (i + 1) / steps - await ctx.report_progress( - progress=progress, - total=1.0, - message=f"Step {i + 1}/{steps}", - ) - await ctx.debug(f"Completed step {i + 1}") - - return f"Task '{task_name}' completed" -``` - -_Full example: [examples/snippets/servers/tool_progress.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/tool_progress.py)_ -<!-- /snippet-source --> - -#### Structured Output - -Tools will return structured results by default, if their return type -annotation is compatible. Otherwise, they will return unstructured results. - -Structured output supports these return types: - -- Pydantic models (BaseModel subclasses) -- TypedDicts -- Dataclasses and other classes with type hints -- `dict[str, T]` (where T is any JSON-serializable type) -- Primitive types (str, int, float, bool, bytes, None) - wrapped in `{"result": value}` -- Generic types (list, tuple, Union, Optional, etc.) - wrapped in `{"result": value}` - -Classes without type hints cannot be serialized for structured output. Only -classes with properly annotated attributes will be converted to Pydantic models -for schema generation and validation. - -Structured results are automatically validated against the output schema -generated from the annotation. This ensures the tool returns well-typed, -validated data that clients can easily process. - -**Note:** For backward compatibility, unstructured results are also -returned. Unstructured results are provided for backward compatibility -with previous versions of the MCP specification, and are quirks-compatible -with previous versions of FastMCP in the current version of the SDK. - -**Note:** In cases where a tool function's return type annotation -causes the tool to be classified as structured _and this is undesirable_, -the classification can be suppressed by passing `structured_output=False` -to the `@tool` decorator. - -##### Advanced: Direct CallToolResult - -For full control over tool responses including the `_meta` field (for passing data to client applications without exposing it to the model), you can return `CallToolResult` directly: - -<!-- snippet-source examples/snippets/servers/direct_call_tool_result.py --> -```python -"""Example showing direct CallToolResult return for advanced control.""" - -from typing import Annotated - -from pydantic import BaseModel - -from mcp.server.fastmcp import FastMCP -from mcp.types import CallToolResult, TextContent - -mcp = FastMCP("CallToolResult Example") - - -class ValidationModel(BaseModel): - """Model for validating structured output.""" - - status: str - data: dict[str, int] - - -@mcp.tool() -def advanced_tool() -> CallToolResult: - """Return CallToolResult directly for full control including _meta field.""" - return CallToolResult( - content=[TextContent(type="text", text="Response visible to the model")], - _meta={"hidden": "data for client applications only"}, - ) - - -@mcp.tool() -def validated_tool() -> Annotated[CallToolResult, ValidationModel]: - """Return CallToolResult with structured output validation.""" - return CallToolResult( - content=[TextContent(type="text", text="Validated response")], - structuredContent={"status": "success", "data": {"result": 42}}, - _meta={"internal": "metadata"}, - ) - - -@mcp.tool() -def empty_result_tool() -> CallToolResult: - """For empty results, return CallToolResult with empty content.""" - return CallToolResult(content=[]) -``` - -_Full example: [examples/snippets/servers/direct_call_tool_result.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/direct_call_tool_result.py)_ -<!-- /snippet-source --> - -**Important:** `CallToolResult` must always be returned (no `Optional` or `Union`). For empty results, use `CallToolResult(content=[])`. For optional simple types, use `str | None` without `CallToolResult`. - -<!-- snippet-source examples/snippets/servers/structured_output.py --> -```python -"""Example showing structured output with tools.""" - -from typing import TypedDict - -from pydantic import BaseModel, Field - -from mcp.server.fastmcp import FastMCP - -mcp = FastMCP("Structured Output Example") - - -# Using Pydantic models for rich structured data -class WeatherData(BaseModel): - """Weather information structure.""" - - temperature: float = Field(description="Temperature in Celsius") - humidity: float = Field(description="Humidity percentage") - condition: str - wind_speed: float - - -@mcp.tool() -def get_weather(city: str) -> WeatherData: - """Get weather for a city - returns structured data.""" - # Simulated weather data - return WeatherData( - temperature=22.5, - humidity=45.0, - condition="sunny", - wind_speed=5.2, - ) - - -# Using TypedDict for simpler structures -class LocationInfo(TypedDict): - latitude: float - longitude: float - name: str - - -@mcp.tool() -def get_location(address: str) -> LocationInfo: - """Get location coordinates""" - return LocationInfo(latitude=51.5074, longitude=-0.1278, name="London, UK") - - -# Using dict[str, Any] for flexible schemas -@mcp.tool() -def get_statistics(data_type: str) -> dict[str, float]: - """Get various statistics""" - return {"mean": 42.5, "median": 40.0, "std_dev": 5.2} - - -# Ordinary classes with type hints work for structured output -class UserProfile: - name: str - age: int - email: str | None = None - - def __init__(self, name: str, age: int, email: str | None = None): - self.name = name - self.age = age - self.email = email - - -@mcp.tool() -def get_user(user_id: str) -> UserProfile: - """Get user profile - returns structured data""" - return UserProfile(name="Alice", age=30, email="alice@example.com") - - -# Classes WITHOUT type hints cannot be used for structured output -class UntypedConfig: - def __init__(self, setting1, setting2): # type: ignore[reportMissingParameterType] - self.setting1 = setting1 - self.setting2 = setting2 - - -@mcp.tool() -def get_config() -> UntypedConfig: - """This returns unstructured output - no schema generated""" - return UntypedConfig("value1", "value2") - - -# Lists and other types are wrapped automatically -@mcp.tool() -def list_cities() -> list[str]: - """Get a list of cities""" - return ["London", "Paris", "Tokyo"] - # Returns: {"result": ["London", "Paris", "Tokyo"]} - - -@mcp.tool() -def get_temperature(city: str) -> float: - """Get temperature as a simple float""" - return 22.5 - # Returns: {"result": 22.5} -``` - -_Full example: [examples/snippets/servers/structured_output.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/structured_output.py)_ -<!-- /snippet-source --> - -### Prompts - -Prompts are reusable templates that help LLMs interact with your server effectively: - -<!-- snippet-source examples/snippets/servers/basic_prompt.py --> -```python -from mcp.server.fastmcp import FastMCP -from mcp.server.fastmcp.prompts import base - -mcp = FastMCP(name="Prompt Example") - - -@mcp.prompt(title="Code Review") -def review_code(code: str) -> str: - return f"Please review this code:\n\n{code}" - - -@mcp.prompt(title="Debug Assistant") -def debug_error(error: str) -> list[base.Message]: - return [ - base.UserMessage("I'm seeing this error:"), - base.UserMessage(error), - base.AssistantMessage("I'll help debug that. What have you tried so far?"), - ] -``` - -_Full example: [examples/snippets/servers/basic_prompt.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/basic_prompt.py)_ -<!-- /snippet-source --> - -### Icons - -MCP servers can provide icons for UI display. Icons can be added to the server implementation, tools, resources, and prompts: - -```python -from mcp.server.fastmcp import FastMCP, Icon - -# Create an icon from a file path or URL -icon = Icon( - src="icon.png", - mimeType="image/png", - sizes="64x64" -) - -# Add icons to server -mcp = FastMCP( - "My Server", - website_url="https://example.com", - icons=[icon] -) - -# Add icons to tools, resources, and prompts -@mcp.tool(icons=[icon]) -def my_tool(): - """Tool with an icon.""" - return "result" - -@mcp.resource("demo://resource", icons=[icon]) -def my_resource(): - """Resource with an icon.""" - return "content" -``` - -_Full example: [examples/fastmcp/icons_demo.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/fastmcp/icons_demo.py)_ - -### Images - -FastMCP provides an `Image` class that automatically handles image data: - -<!-- snippet-source examples/snippets/servers/images.py --> -```python -"""Example showing image handling with FastMCP.""" - -from PIL import Image as PILImage - -from mcp.server.fastmcp import FastMCP, Image - -mcp = FastMCP("Image Example") - - -@mcp.tool() -def create_thumbnail(image_path: str) -> Image: - """Create a thumbnail from an image""" - img = PILImage.open(image_path) - img.thumbnail((100, 100)) - return Image(data=img.tobytes(), format="png") -``` - -_Full example: [examples/snippets/servers/images.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/images.py)_ -<!-- /snippet-source --> - -### Context - -The Context object is automatically injected into tool and resource functions that request it via type hints. It provides access to MCP capabilities like logging, progress reporting, resource reading, user interaction, and request metadata. - -#### Getting Context in Functions - -To use context in a tool or resource function, add a parameter with the `Context` type annotation: - -```python -from mcp.server.fastmcp import Context, FastMCP - -mcp = FastMCP(name="Context Example") - - -@mcp.tool() -async def my_tool(x: int, ctx: Context) -> str: - """Tool that uses context capabilities.""" - # The context parameter can have any name as long as it's type-annotated - return await process_with_context(x, ctx) -``` - -#### Context Properties and Methods - -The Context object provides the following capabilities: - -- `ctx.request_id` - Unique ID for the current request -- `ctx.client_id` - Client ID if available -- `ctx.fastmcp` - Access to the FastMCP server instance (see [FastMCP Properties](#fastmcp-properties)) -- `ctx.session` - Access to the underlying session for advanced communication (see [Session Properties and Methods](#session-properties-and-methods)) -- `ctx.request_context` - Access to request-specific data and lifespan resources (see [Request Context Properties](#request-context-properties)) -- `await ctx.debug(message)` - Send debug log message -- `await ctx.info(message)` - Send info log message -- `await ctx.warning(message)` - Send warning log message -- `await ctx.error(message)` - Send error log message -- `await ctx.log(level, message, logger_name=None)` - Send log with custom level -- `await ctx.report_progress(progress, total=None, message=None)` - Report operation progress -- `await ctx.read_resource(uri)` - Read a resource by URI -- `await ctx.elicit(message, schema)` - Request additional information from user with validation - -<!-- snippet-source examples/snippets/servers/tool_progress.py --> -```python -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession - -mcp = FastMCP(name="Progress Example") - - -@mcp.tool() -async def long_running_task(task_name: str, ctx: Context[ServerSession, None], steps: int = 5) -> str: - """Execute a task with progress updates.""" - await ctx.info(f"Starting: {task_name}") - - for i in range(steps): - progress = (i + 1) / steps - await ctx.report_progress( - progress=progress, - total=1.0, - message=f"Step {i + 1}/{steps}", - ) - await ctx.debug(f"Completed step {i + 1}") - - return f"Task '{task_name}' completed" -``` - -_Full example: [examples/snippets/servers/tool_progress.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/tool_progress.py)_ -<!-- /snippet-source --> - -### Completions - -MCP supports providing completion suggestions for prompt arguments and resource template parameters. With the context parameter, servers can provide completions based on previously resolved values: - -Client usage: - -<!-- snippet-source examples/snippets/clients/completion_client.py --> -```python -""" -cd to the `examples/snippets` directory and run: - uv run completion-client -""" - -import asyncio -import os - -from mcp import ClientSession, StdioServerParameters -from mcp.client.stdio import stdio_client -from mcp.types import PromptReference, ResourceTemplateReference - -# Create server parameters for stdio connection -server_params = StdioServerParameters( - command="uv", # Using uv to run the server - args=["run", "server", "completion", "stdio"], # Server with completion support - env={"UV_INDEX": os.environ.get("UV_INDEX", "")}, -) - - -async def run(): - """Run the completion client example.""" - async with stdio_client(server_params) as (read, write): - async with ClientSession(read, write) as session: - # Initialize the connection - await session.initialize() - - # List available resource templates - templates = await session.list_resource_templates() - print("Available resource templates:") - for template in templates.resourceTemplates: - print(f" - {template.uriTemplate}") - - # List available prompts - prompts = await session.list_prompts() - print("\nAvailable prompts:") - for prompt in prompts.prompts: - print(f" - {prompt.name}") - - # Complete resource template arguments - if templates.resourceTemplates: - template = templates.resourceTemplates[0] - print(f"\nCompleting arguments for resource template: {template.uriTemplate}") - - # Complete without context - result = await session.complete( - ref=ResourceTemplateReference(type="ref/resource", uri=template.uriTemplate), - argument={"name": "owner", "value": "model"}, - ) - print(f"Completions for 'owner' starting with 'model': {result.completion.values}") - - # Complete with context - repo suggestions based on owner - result = await session.complete( - ref=ResourceTemplateReference(type="ref/resource", uri=template.uriTemplate), - argument={"name": "repo", "value": ""}, - context_arguments={"owner": "modelcontextprotocol"}, - ) - print(f"Completions for 'repo' with owner='modelcontextprotocol': {result.completion.values}") - - # Complete prompt arguments - if prompts.prompts: - prompt_name = prompts.prompts[0].name - print(f"\nCompleting arguments for prompt: {prompt_name}") - - result = await session.complete( - ref=PromptReference(type="ref/prompt", name=prompt_name), - argument={"name": "style", "value": ""}, - ) - print(f"Completions for 'style' argument: {result.completion.values}") - - -def main(): - """Entry point for the completion client.""" - asyncio.run(run()) - - -if __name__ == "__main__": - main() -``` - -_Full example: [examples/snippets/clients/completion_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/completion_client.py)_ -<!-- /snippet-source --> -### Elicitation - -Request additional information from users. This example shows an Elicitation during a Tool Call: - -<!-- snippet-source examples/snippets/servers/elicitation.py --> -```python -"""Elicitation examples demonstrating form and URL mode elicitation. - -Form mode elicitation collects structured, non-sensitive data through a schema. -URL mode elicitation directs users to external URLs for sensitive operations -like OAuth flows, credential collection, or payment processing. -""" - -import uuid - -from pydantic import BaseModel, Field - -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession -from mcp.shared.exceptions import UrlElicitationRequiredError -from mcp.types import ElicitRequestURLParams - -mcp = FastMCP(name="Elicitation Example") - - -class BookingPreferences(BaseModel): - """Schema for collecting user preferences.""" - - checkAlternative: bool = Field(description="Would you like to check another date?") - alternativeDate: str = Field( - default="2024-12-26", - description="Alternative date (YYYY-MM-DD)", - ) - - -@mcp.tool() -async def book_table(date: str, time: str, party_size: int, ctx: Context[ServerSession, None]) -> str: - """Book a table with date availability check. - - This demonstrates form mode elicitation for collecting non-sensitive user input. - """ - # Check if date is available - if date == "2024-12-25": - # Date unavailable - ask user for alternative - result = await ctx.elicit( - message=(f"No tables available for {party_size} on {date}. Would you like to try another date?"), - schema=BookingPreferences, - ) - - if result.action == "accept" and result.data: - if result.data.checkAlternative: - return f"[SUCCESS] Booked for {result.data.alternativeDate}" - return "[CANCELLED] No booking made" - return "[CANCELLED] Booking cancelled" - - # Date available - return f"[SUCCESS] Booked for {date} at {time}" - - -@mcp.tool() -async def secure_payment(amount: float, ctx: Context[ServerSession, None]) -> str: - """Process a secure payment requiring URL confirmation. - - This demonstrates URL mode elicitation using ctx.elicit_url() for - operations that require out-of-band user interaction. - """ - elicitation_id = str(uuid.uuid4()) - - result = await ctx.elicit_url( - message=f"Please confirm payment of ${amount:.2f}", - url=f"https://payments.example.com/confirm?amount={amount}&id={elicitation_id}", - elicitation_id=elicitation_id, - ) - - if result.action == "accept": - # In a real app, the payment confirmation would happen out-of-band - # and you'd verify the payment status from your backend - return f"Payment of ${amount:.2f} initiated - check your browser to complete" - elif result.action == "decline": - return "Payment declined by user" - return "Payment cancelled" - - -@mcp.tool() -async def connect_service(service_name: str, ctx: Context[ServerSession, None]) -> str: - """Connect to a third-party service requiring OAuth authorization. - - This demonstrates the "throw error" pattern using UrlElicitationRequiredError. - Use this pattern when the tool cannot proceed without user authorization. - """ - elicitation_id = str(uuid.uuid4()) - - # Raise UrlElicitationRequiredError to signal that the client must complete - # a URL elicitation before this request can be processed. - # The MCP framework will convert this to a -32042 error response. - raise UrlElicitationRequiredError( - [ - ElicitRequestURLParams( - mode="url", - message=f"Authorization required to connect to {service_name}", - url=f"https://{service_name}.example.com/oauth/authorize?elicit={elicitation_id}", - elicitationId=elicitation_id, - ) - ] - ) -``` - -_Full example: [examples/snippets/servers/elicitation.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/elicitation.py)_ -<!-- /snippet-source --> - -Elicitation schemas support default values for all field types. Default values are automatically included in the JSON schema sent to clients, allowing them to pre-populate forms. - -The `elicit()` method returns an `ElicitationResult` with: - -- `action`: "accept", "decline", or "cancel" -- `data`: The validated response (only when accepted) -- `validation_error`: Any validation error message - -### Sampling - -Tools can interact with LLMs through sampling (generating text): - -<!-- snippet-source examples/snippets/servers/sampling.py --> -```python -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession -from mcp.types import SamplingMessage, TextContent - -mcp = FastMCP(name="Sampling Example") - - -@mcp.tool() -async def generate_poem(topic: str, ctx: Context[ServerSession, None]) -> str: - """Generate a poem using LLM sampling.""" - prompt = f"Write a short poem about {topic}" - - result = await ctx.session.create_message( - messages=[ - SamplingMessage( - role="user", - content=TextContent(type="text", text=prompt), - ) - ], - max_tokens=100, - ) - - # Since we're not passing tools param, result.content is single content - if result.content.type == "text": - return result.content.text - return str(result.content) -``` - -_Full example: [examples/snippets/servers/sampling.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/sampling.py)_ -<!-- /snippet-source --> - -### Logging and Notifications - -Tools can send logs and notifications through the context: - -<!-- snippet-source examples/snippets/servers/notifications.py --> -```python -from mcp.server.fastmcp import Context, FastMCP -from mcp.server.session import ServerSession - -mcp = FastMCP(name="Notifications Example") - - -@mcp.tool() -async def process_data(data: str, ctx: Context[ServerSession, None]) -> str: - """Process data with logging.""" - # Different log levels - await ctx.debug(f"Debug: Processing '{data}'") - await ctx.info("Info: Starting processing") - await ctx.warning("Warning: This is experimental") - await ctx.error("Error: (This is just a demo)") - - # Notify about resource changes - await ctx.session.send_resource_list_changed() - - return f"Processed: {data}" -``` - -_Full example: [examples/snippets/servers/notifications.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/notifications.py)_ -<!-- /snippet-source --> - -### Authentication - -Authentication can be used by servers that want to expose tools accessing protected resources. - -`mcp.server.auth` implements OAuth 2.1 resource server functionality, where MCP servers act as Resource Servers (RS) that validate tokens issued by separate Authorization Servers (AS). This follows the [MCP authorization specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization) and implements RFC 9728 (Protected Resource Metadata) for AS discovery. - -MCP servers can use authentication by providing an implementation of the `TokenVerifier` protocol: - -<!-- snippet-source examples/snippets/servers/oauth_server.py --> -```python -""" -Run from the repository root: - uv run examples/snippets/servers/oauth_server.py -""" - -from pydantic import AnyHttpUrl - -from mcp.server.auth.provider import AccessToken, TokenVerifier -from mcp.server.auth.settings import AuthSettings -from mcp.server.fastmcp import FastMCP - - -class SimpleTokenVerifier(TokenVerifier): - """Simple token verifier for demonstration.""" - - async def verify_token(self, token: str) -> AccessToken | None: - pass # This is where you would implement actual token validation - - -# Create FastMCP instance as a Resource Server -mcp = FastMCP( - "Weather Service", - json_response=True, - # Token verifier for authentication - token_verifier=SimpleTokenVerifier(), - # Auth settings for RFC 9728 Protected Resource Metadata - auth=AuthSettings( - issuer_url=AnyHttpUrl("https://auth.example.com"), # Authorization Server URL - resource_server_url=AnyHttpUrl("http://localhost:3001"), # This server's URL - required_scopes=["user"], - ), -) - - -@mcp.tool() -async def get_weather(city: str = "London") -> dict[str, str]: - """Get weather data for a city""" - return { - "city": city, - "temperature": "22", - "condition": "Partly cloudy", - "humidity": "65%", - } - - -if __name__ == "__main__": - mcp.run(transport="streamable-http") -``` - -_Full example: [examples/snippets/servers/oauth_server.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/oauth_server.py)_ -<!-- /snippet-source --> - -For a complete example with separate Authorization Server and Resource Server implementations, see [`examples/servers/simple-auth/`](examples/servers/simple-auth/). - -**Architecture:** - -- **Authorization Server (AS)**: Handles OAuth flows, user authentication, and token issuance -- **Resource Server (RS)**: Your MCP server that validates tokens and serves protected resources -- **Client**: Discovers AS through RFC 9728, obtains tokens, and uses them with the MCP server - -See [TokenVerifier](src/mcp/server/auth/provider.py) for more details on implementing token validation. - -### FastMCP Properties - -The FastMCP server instance accessible via `ctx.fastmcp` provides access to server configuration and metadata: - -- `ctx.fastmcp.name` - The server's name as defined during initialization -- `ctx.fastmcp.instructions` - Server instructions/description provided to clients -- `ctx.fastmcp.website_url` - Optional website URL for the server -- `ctx.fastmcp.icons` - Optional list of icons for UI display -- `ctx.fastmcp.settings` - Complete server configuration object containing: - - `debug` - Debug mode flag - - `log_level` - Current logging level - - `host` and `port` - Server network configuration - - `mount_path`, `sse_path`, `streamable_http_path` - Transport paths - - `stateless_http` - Whether the server operates in stateless mode - - And other configuration options - -```python -@mcp.tool() -def server_info(ctx: Context) -> dict: - """Get information about the current server.""" - return { - "name": ctx.fastmcp.name, - "instructions": ctx.fastmcp.instructions, - "debug_mode": ctx.fastmcp.settings.debug, - "log_level": ctx.fastmcp.settings.log_level, - "host": ctx.fastmcp.settings.host, - "port": ctx.fastmcp.settings.port, - } -``` - -### Session Properties and Methods - -The session object accessible via `ctx.session` provides advanced control over client communication: - -- `ctx.session.client_params` - Client initialization parameters and declared capabilities -- `await ctx.session.send_log_message(level, data, logger)` - Send log messages with full control -- `await ctx.session.create_message(messages, max_tokens)` - Request LLM sampling/completion -- `await ctx.session.send_progress_notification(token, progress, total, message)` - Direct progress updates -- `await ctx.session.send_resource_updated(uri)` - Notify clients that a specific resource changed -- `await ctx.session.send_resource_list_changed()` - Notify clients that the resource list changed -- `await ctx.session.send_tool_list_changed()` - Notify clients that the tool list changed -- `await ctx.session.send_prompt_list_changed()` - Notify clients that the prompt list changed - -```python -@mcp.tool() -async def notify_data_update(resource_uri: str, ctx: Context) -> str: - """Update data and notify clients of the change.""" - # Perform data update logic here - - # Notify clients that this specific resource changed - await ctx.session.send_resource_updated(AnyUrl(resource_uri)) - - # If this affects the overall resource list, notify about that too - await ctx.session.send_resource_list_changed() - - return f"Updated {resource_uri} and notified clients" -``` - -### Request Context Properties - -The request context accessible via `ctx.request_context` contains request-specific information and resources: - -- `ctx.request_context.lifespan_context` - Access to resources initialized during server startup - - Database connections, configuration objects, shared services - - Type-safe access to resources defined in your server's lifespan function -- `ctx.request_context.meta` - Request metadata from the client including: - - `progressToken` - Token for progress notifications - - Other client-provided metadata -- `ctx.request_context.request` - The original MCP request object for advanced processing -- `ctx.request_context.request_id` - Unique identifier for this request - -```python -# Example with typed lifespan context -@dataclass -class AppContext: - db: Database - config: AppConfig - -@mcp.tool() -def query_with_config(query: str, ctx: Context) -> str: - """Execute a query using shared database and configuration.""" - # Access typed lifespan context - app_ctx: AppContext = ctx.request_context.lifespan_context - - # Use shared resources - connection = app_ctx.db - settings = app_ctx.config - - # Execute query with configuration - result = connection.execute(query, timeout=settings.query_timeout) - return str(result) -``` - -_Full lifespan example: [examples/snippets/servers/lifespan_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lifespan_example.py)_ - -## Running Your Server - -### Development Mode - -The fastest way to test and debug your server is with the MCP Inspector: - -```bash -uv run mcp dev server.py - -# Add dependencies -uv run mcp dev server.py --with pandas --with numpy - -# Mount local code -uv run mcp dev server.py --with-editable . -``` - -### Claude Desktop Integration - -Once your server is ready, install it in Claude Desktop: - -```bash -uv run mcp install server.py - -# Custom name -uv run mcp install server.py --name "My Analytics Server" - -# Environment variables -uv run mcp install server.py -v API_KEY=abc123 -v DB_URL=postgres://... -uv run mcp install server.py -f .env -``` - -### Direct Execution - -For advanced scenarios like custom deployments: - -<!-- snippet-source examples/snippets/servers/direct_execution.py --> -```python -"""Example showing direct execution of an MCP server. - -This is the simplest way to run an MCP server directly. -cd to the `examples/snippets` directory and run: - uv run direct-execution-server - or - python servers/direct_execution.py -""" - -from mcp.server.fastmcp import FastMCP - -mcp = FastMCP("My App") - - -@mcp.tool() -def hello(name: str = "World") -> str: - """Say hello to someone.""" - return f"Hello, {name}!" - - -def main(): - """Entry point for the direct execution server.""" - mcp.run() - - -if __name__ == "__main__": - main() -``` - -_Full example: [examples/snippets/servers/direct_execution.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/direct_execution.py)_ -<!-- /snippet-source --> - -Run it with: - -```bash -python servers/direct_execution.py -# or -uv run mcp run servers/direct_execution.py -``` - -Note that `uv run mcp run` or `uv run mcp dev` only supports server using FastMCP and not the low-level server variant. - -### Streamable HTTP Transport - -> **Note**: Streamable HTTP transport is the recommended transport for production deployments. Use `stateless_http=True` and `json_response=True` for optimal scalability. - -<!-- snippet-source examples/snippets/servers/streamable_config.py --> -```python -""" -Run from the repository root: - uv run examples/snippets/servers/streamable_config.py -""" - -from mcp.server.fastmcp import FastMCP - -# Stateless server with JSON responses (recommended) -mcp = FastMCP("StatelessServer", stateless_http=True, json_response=True) - -# Other configuration options: -# Stateless server with SSE streaming responses -# mcp = FastMCP("StatelessServer", stateless_http=True) - -# Stateful server with session persistence -# mcp = FastMCP("StatefulServer") - - -# Add a simple tool to demonstrate the server -@mcp.tool() -def greet(name: str = "World") -> str: - """Greet someone by name.""" - return f"Hello, {name}!" - - -# Run server with streamable_http transport -if __name__ == "__main__": - mcp.run(transport="streamable-http") -``` - -_Full example: [examples/snippets/servers/streamable_config.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_config.py)_ -<!-- /snippet-source --> - -You can mount multiple FastMCP servers in a Starlette application: - -<!-- snippet-source examples/snippets/servers/streamable_starlette_mount.py --> -```python -""" -Run from the repository root: - uvicorn examples.snippets.servers.streamable_starlette_mount:app --reload -""" - -import contextlib - -from starlette.applications import Starlette -from starlette.routing import Mount - -from mcp.server.fastmcp import FastMCP - -# Create the Echo server -echo_mcp = FastMCP(name="EchoServer", stateless_http=True, json_response=True) - - -@echo_mcp.tool() -def echo(message: str) -> str: - """A simple echo tool""" - return f"Echo: {message}" - - -# Create the Math server -math_mcp = FastMCP(name="MathServer", stateless_http=True, json_response=True) - - -@math_mcp.tool() -def add_two(n: int) -> int: - """Tool to add two to the input""" - return n + 2 - - -# Create a combined lifespan to manage both session managers -@contextlib.asynccontextmanager -async def lifespan(app: Starlette): - async with contextlib.AsyncExitStack() as stack: - await stack.enter_async_context(echo_mcp.session_manager.run()) - await stack.enter_async_context(math_mcp.session_manager.run()) - yield - - -# Create the Starlette app and mount the MCP servers -app = Starlette( - routes=[ - Mount("/echo", echo_mcp.streamable_http_app()), - Mount("/math", math_mcp.streamable_http_app()), - ], - lifespan=lifespan, -) - -# Note: Clients connect to http://localhost:8000/echo/mcp and http://localhost:8000/math/mcp -# To mount at the root of each path (e.g., /echo instead of /echo/mcp): -# echo_mcp.settings.streamable_http_path = "/" -# math_mcp.settings.streamable_http_path = "/" -``` - -_Full example: [examples/snippets/servers/streamable_starlette_mount.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_starlette_mount.py)_ -<!-- /snippet-source --> - -For low level server with Streamable HTTP implementations, see: - -- Stateful server: [`examples/servers/simple-streamablehttp/`](examples/servers/simple-streamablehttp/) -- Stateless server: [`examples/servers/simple-streamablehttp-stateless/`](examples/servers/simple-streamablehttp-stateless/) - -The streamable HTTP transport supports: - -- Stateful and stateless operation modes -- Resumability with event stores -- JSON or SSE response formats -- Better scalability for multi-node deployments - -#### CORS Configuration for Browser-Based Clients - -If you'd like your server to be accessible by browser-based MCP clients, you'll need to configure CORS headers. The `Mcp-Session-Id` header must be exposed for browser clients to access it: - -```python -from starlette.applications import Starlette -from starlette.middleware.cors import CORSMiddleware - -# Create your Starlette app first -starlette_app = Starlette(routes=[...]) - -# Then wrap it with CORS middleware -starlette_app = CORSMiddleware( - starlette_app, - allow_origins=["*"], # Configure appropriately for production - allow_methods=["GET", "POST", "DELETE"], # MCP streamable HTTP methods - expose_headers=["Mcp-Session-Id"], -) -``` - -This configuration is necessary because: - -- The MCP streamable HTTP transport uses the `Mcp-Session-Id` header for session management -- Browsers restrict access to response headers unless explicitly exposed via CORS -- Without this configuration, browser-based clients won't be able to read the session ID from initialization responses - -### Mounting to an Existing ASGI Server - -By default, SSE servers are mounted at `/sse` and Streamable HTTP servers are mounted at `/mcp`. You can customize these paths using the methods described below. - -For more information on mounting applications in Starlette, see the [Starlette documentation](https://www.starlette.io/routing/#submounting-routes). - -#### StreamableHTTP servers - -You can mount the StreamableHTTP server to an existing ASGI server using the `streamable_http_app` method. This allows you to integrate the StreamableHTTP server with other ASGI applications. - -##### Basic mounting - -<!-- snippet-source examples/snippets/servers/streamable_http_basic_mounting.py --> -```python -""" -Basic example showing how to mount StreamableHTTP server in Starlette. - -Run from the repository root: - uvicorn examples.snippets.servers.streamable_http_basic_mounting:app --reload -""" - -import contextlib - -from starlette.applications import Starlette -from starlette.routing import Mount - -from mcp.server.fastmcp import FastMCP - -# Create MCP server -mcp = FastMCP("My App", json_response=True) - - -@mcp.tool() -def hello() -> str: - """A simple hello tool""" - return "Hello from MCP!" - - -# Create a lifespan context manager to run the session manager -@contextlib.asynccontextmanager -async def lifespan(app: Starlette): - async with mcp.session_manager.run(): - yield - - -# Mount the StreamableHTTP server to the existing ASGI server -app = Starlette( - routes=[ - Mount("/", app=mcp.streamable_http_app()), - ], - lifespan=lifespan, -) -``` - -_Full example: [examples/snippets/servers/streamable_http_basic_mounting.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_http_basic_mounting.py)_ -<!-- /snippet-source --> - -##### Host-based routing - -<!-- snippet-source examples/snippets/servers/streamable_http_host_mounting.py --> -```python -""" -Example showing how to mount StreamableHTTP server using Host-based routing. - -Run from the repository root: - uvicorn examples.snippets.servers.streamable_http_host_mounting:app --reload -""" - -import contextlib - -from starlette.applications import Starlette -from starlette.routing import Host - -from mcp.server.fastmcp import FastMCP - -# Create MCP server -mcp = FastMCP("MCP Host App", json_response=True) - - -@mcp.tool() -def domain_info() -> str: - """Get domain-specific information""" - return "This is served from mcp.acme.corp" - - -# Create a lifespan context manager to run the session manager -@contextlib.asynccontextmanager -async def lifespan(app: Starlette): - async with mcp.session_manager.run(): - yield - - -# Mount using Host-based routing -app = Starlette( - routes=[ - Host("mcp.acme.corp", app=mcp.streamable_http_app()), - ], - lifespan=lifespan, -) -``` - -_Full example: [examples/snippets/servers/streamable_http_host_mounting.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_http_host_mounting.py)_ -<!-- /snippet-source --> - -##### Multiple servers with path configuration - -<!-- snippet-source examples/snippets/servers/streamable_http_multiple_servers.py --> -```python -""" -Example showing how to mount multiple StreamableHTTP servers with path configuration. - -Run from the repository root: - uvicorn examples.snippets.servers.streamable_http_multiple_servers:app --reload -""" - -import contextlib - -from starlette.applications import Starlette -from starlette.routing import Mount - -from mcp.server.fastmcp import FastMCP - -# Create multiple MCP servers -api_mcp = FastMCP("API Server", json_response=True) -chat_mcp = FastMCP("Chat Server", json_response=True) - - -@api_mcp.tool() -def api_status() -> str: - """Get API status""" - return "API is running" - - -@chat_mcp.tool() -def send_message(message: str) -> str: - """Send a chat message""" - return f"Message sent: {message}" - - -# Configure servers to mount at the root of each path -# This means endpoints will be at /api and /chat instead of /api/mcp and /chat/mcp -api_mcp.settings.streamable_http_path = "/" -chat_mcp.settings.streamable_http_path = "/" - - -# Create a combined lifespan to manage both session managers -@contextlib.asynccontextmanager -async def lifespan(app: Starlette): - async with contextlib.AsyncExitStack() as stack: - await stack.enter_async_context(api_mcp.session_manager.run()) - await stack.enter_async_context(chat_mcp.session_manager.run()) - yield - - -# Mount the servers -app = Starlette( - routes=[ - Mount("/api", app=api_mcp.streamable_http_app()), - Mount("/chat", app=chat_mcp.streamable_http_app()), - ], - lifespan=lifespan, -) -``` - -_Full example: [examples/snippets/servers/streamable_http_multiple_servers.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_http_multiple_servers.py)_ -<!-- /snippet-source --> - -##### Path configuration at initialization - -<!-- snippet-source examples/snippets/servers/streamable_http_path_config.py --> -```python -""" -Example showing path configuration during FastMCP initialization. - -Run from the repository root: - uvicorn examples.snippets.servers.streamable_http_path_config:app --reload -""" - -from starlette.applications import Starlette -from starlette.routing import Mount - -from mcp.server.fastmcp import FastMCP - -# Configure streamable_http_path during initialization -# This server will mount at the root of wherever it's mounted -mcp_at_root = FastMCP( - "My Server", - json_response=True, - streamable_http_path="/", -) - - -@mcp_at_root.tool() -def process_data(data: str) -> str: - """Process some data""" - return f"Processed: {data}" - - -# Mount at /process - endpoints will be at /process instead of /process/mcp -app = Starlette( - routes=[ - Mount("/process", app=mcp_at_root.streamable_http_app()), - ] -) -``` - -_Full example: [examples/snippets/servers/streamable_http_path_config.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_http_path_config.py)_ -<!-- /snippet-source --> - -#### SSE servers - -> **Note**: SSE transport is being superseded by [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http). - -You can mount the SSE server to an existing ASGI server using the `sse_app` method. This allows you to integrate the SSE server with other ASGI applications. - -```python -from starlette.applications import Starlette -from starlette.routing import Mount, Host -from mcp.server.fastmcp import FastMCP - - -mcp = FastMCP("My App") - -# Mount the SSE server to the existing ASGI server -app = Starlette( - routes=[ - Mount('/', app=mcp.sse_app()), - ] -) - -# or dynamically mount as host -app.router.routes.append(Host('mcp.acme.corp', app=mcp.sse_app())) -``` - -When mounting multiple MCP servers under different paths, you can configure the mount path in several ways: - -```python -from starlette.applications import Starlette -from starlette.routing import Mount -from mcp.server.fastmcp import FastMCP - -# Create multiple MCP servers -github_mcp = FastMCP("GitHub API") -browser_mcp = FastMCP("Browser") -curl_mcp = FastMCP("Curl") -search_mcp = FastMCP("Search") - -# Method 1: Configure mount paths via settings (recommended for persistent configuration) -github_mcp.settings.mount_path = "/github" -browser_mcp.settings.mount_path = "/browser" - -# Method 2: Pass mount path directly to sse_app (preferred for ad-hoc mounting) -# This approach doesn't modify the server's settings permanently - -# Create Starlette app with multiple mounted servers -app = Starlette( - routes=[ - # Using settings-based configuration - Mount("/github", app=github_mcp.sse_app()), - Mount("/browser", app=browser_mcp.sse_app()), - # Using direct mount path parameter - Mount("/curl", app=curl_mcp.sse_app("/curl")), - Mount("/search", app=search_mcp.sse_app("/search")), - ] -) - -# Method 3: For direct execution, you can also pass the mount path to run() -if __name__ == "__main__": - search_mcp.run(transport="sse", mount_path="/search") -``` - -For more information on mounting applications in Starlette, see the [Starlette documentation](https://www.starlette.io/routing/#submounting-routes). - -## Advanced Usage - -### Low-Level Server - -For more control, you can use the low-level server implementation directly. This gives you full access to the protocol and allows you to customize every aspect of your server, including lifecycle management through the lifespan API: - -<!-- snippet-source examples/snippets/servers/lowlevel/lifespan.py --> -```python -""" -Run from the repository root: - uv run examples/snippets/servers/lowlevel/lifespan.py -""" - -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from typing import Any - -import mcp.server.stdio -import mcp.types as types -from mcp.server.lowlevel import NotificationOptions, Server -from mcp.server.models import InitializationOptions - - -# Mock database class for example -class Database: - """Mock database class for example.""" - - @classmethod - async def connect(cls) -> "Database": - """Connect to database.""" - print("Database connected") - return cls() - - async def disconnect(self) -> None: - """Disconnect from database.""" - print("Database disconnected") - - async def query(self, query_str: str) -> list[dict[str, str]]: - """Execute a query.""" - # Simulate database query - return [{"id": "1", "name": "Example", "query": query_str}] - - -@asynccontextmanager -async def server_lifespan(_server: Server) -> AsyncIterator[dict[str, Any]]: - """Manage server startup and shutdown lifecycle.""" - # Initialize resources on startup - db = await Database.connect() - try: - yield {"db": db} - finally: - # Clean up on shutdown - await db.disconnect() - - -# Pass lifespan to server -server = Server("example-server", lifespan=server_lifespan) - - -@server.list_tools() -async def handle_list_tools() -> list[types.Tool]: - """List available tools.""" - return [ - types.Tool( - name="query_db", - description="Query the database", - inputSchema={ - "type": "object", - "properties": {"query": {"type": "string", "description": "SQL query to execute"}}, - "required": ["query"], - }, - ) - ] - - -@server.call_tool() -async def query_db(name: str, arguments: dict[str, Any]) -> list[types.TextContent]: - """Handle database query tool call.""" - if name != "query_db": - raise ValueError(f"Unknown tool: {name}") - - # Access lifespan context - ctx = server.request_context - db = ctx.lifespan_context["db"] - - # Execute query - results = await db.query(arguments["query"]) - - return [types.TextContent(type="text", text=f"Query results: {results}")] - - -async def run(): - """Run the server with lifespan management.""" - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - InitializationOptions( - server_name="example-server", - server_version="0.1.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), - ) - - -if __name__ == "__main__": - import asyncio - - asyncio.run(run()) -``` - -_Full example: [examples/snippets/servers/lowlevel/lifespan.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lowlevel/lifespan.py)_ -<!-- /snippet-source --> - -The lifespan API provides: - -- A way to initialize resources when the server starts and clean them up when it stops -- Access to initialized resources through the request context in handlers -- Type-safe context passing between lifespan and request handlers - -<!-- snippet-source examples/snippets/servers/lowlevel/basic.py --> -```python -""" -Run from the repository root: -uv run examples/snippets/servers/lowlevel/basic.py -""" - -import asyncio - -import mcp.server.stdio -import mcp.types as types -from mcp.server.lowlevel import NotificationOptions, Server -from mcp.server.models import InitializationOptions - -# Create a server instance -server = Server("example-server") - - -@server.list_prompts() -async def handle_list_prompts() -> list[types.Prompt]: - """List available prompts.""" - return [ - types.Prompt( - name="example-prompt", - description="An example prompt template", - arguments=[types.PromptArgument(name="arg1", description="Example argument", required=True)], - ) - ] - - -@server.get_prompt() -async def handle_get_prompt(name: str, arguments: dict[str, str] | None) -> types.GetPromptResult: - """Get a specific prompt by name.""" - if name != "example-prompt": - raise ValueError(f"Unknown prompt: {name}") - - arg1_value = (arguments or {}).get("arg1", "default") - - return types.GetPromptResult( - description="Example prompt", - messages=[ - types.PromptMessage( - role="user", - content=types.TextContent(type="text", text=f"Example prompt text with argument: {arg1_value}"), - ) - ], - ) - - -async def run(): - """Run the basic low-level server.""" - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - InitializationOptions( - server_name="example", - server_version="0.1.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), - ) - - -if __name__ == "__main__": - asyncio.run(run()) -``` - -_Full example: [examples/snippets/servers/lowlevel/basic.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lowlevel/basic.py)_ -<!-- /snippet-source --> - -Caution: The `uv run mcp run` and `uv run mcp dev` tool doesn't support low-level server. - -#### Structured Output Support - -The low-level server supports structured output for tools, allowing you to return both human-readable content and machine-readable structured data. Tools can define an `outputSchema` to validate their structured output: - -<!-- snippet-source examples/snippets/servers/lowlevel/structured_output.py --> -```python -""" -Run from the repository root: - uv run examples/snippets/servers/lowlevel/structured_output.py -""" - -import asyncio -from typing import Any - -import mcp.server.stdio -import mcp.types as types -from mcp.server.lowlevel import NotificationOptions, Server -from mcp.server.models import InitializationOptions - -server = Server("example-server") - - -@server.list_tools() -async def list_tools() -> list[types.Tool]: - """List available tools with structured output schemas.""" - return [ - types.Tool( - name="get_weather", - description="Get current weather for a city", - inputSchema={ - "type": "object", - "properties": {"city": {"type": "string", "description": "City name"}}, - "required": ["city"], - }, - outputSchema={ - "type": "object", - "properties": { - "temperature": {"type": "number", "description": "Temperature in Celsius"}, - "condition": {"type": "string", "description": "Weather condition"}, - "humidity": {"type": "number", "description": "Humidity percentage"}, - "city": {"type": "string", "description": "City name"}, - }, - "required": ["temperature", "condition", "humidity", "city"], - }, - ) - ] - - -@server.call_tool() -async def call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]: - """Handle tool calls with structured output.""" - if name == "get_weather": - city = arguments["city"] - - # Simulated weather data - in production, call a weather API - weather_data = { - "temperature": 22.5, - "condition": "partly cloudy", - "humidity": 65, - "city": city, # Include the requested city - } - - # low-level server will validate structured output against the tool's - # output schema, and additionally serialize it into a TextContent block - # for backwards compatibility with pre-2025-06-18 clients. - return weather_data - else: - raise ValueError(f"Unknown tool: {name}") - - -async def run(): - """Run the structured output server.""" - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - InitializationOptions( - server_name="structured-output-example", - server_version="0.1.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), - ) - - -if __name__ == "__main__": - asyncio.run(run()) -``` - -_Full example: [examples/snippets/servers/lowlevel/structured_output.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lowlevel/structured_output.py)_ -<!-- /snippet-source --> - -Tools can return data in four ways: - -1. **Content only**: Return a list of content blocks (default behavior before spec revision 2025-06-18) -2. **Structured data only**: Return a dictionary that will be serialized to JSON (Introduced in spec revision 2025-06-18) -3. **Both**: Return a tuple of (content, structured_data) preferred option to use for backwards compatibility -4. **Direct CallToolResult**: Return `CallToolResult` directly for full control (including `_meta` field) - -When an `outputSchema` is defined, the server automatically validates the structured output against the schema. This ensures type safety and helps catch errors early. - -##### Returning CallToolResult Directly - -For full control over the response including the `_meta` field (for passing data to client applications without exposing it to the model), return `CallToolResult` directly: - -<!-- snippet-source examples/snippets/servers/lowlevel/direct_call_tool_result.py --> -```python -""" -Run from the repository root: - uv run examples/snippets/servers/lowlevel/direct_call_tool_result.py -""" - -import asyncio -from typing import Any - -import mcp.server.stdio -import mcp.types as types -from mcp.server.lowlevel import NotificationOptions, Server -from mcp.server.models import InitializationOptions - -server = Server("example-server") - - -@server.list_tools() -async def list_tools() -> list[types.Tool]: - """List available tools.""" - return [ - types.Tool( - name="advanced_tool", - description="Tool with full control including _meta field", - inputSchema={ - "type": "object", - "properties": {"message": {"type": "string"}}, - "required": ["message"], - }, - ) - ] - - -@server.call_tool() -async def handle_call_tool(name: str, arguments: dict[str, Any]) -> types.CallToolResult: - """Handle tool calls by returning CallToolResult directly.""" - if name == "advanced_tool": - message = str(arguments.get("message", "")) - return types.CallToolResult( - content=[types.TextContent(type="text", text=f"Processed: {message}")], - structuredContent={"result": "success", "message": message}, - _meta={"hidden": "data for client applications only"}, - ) - - raise ValueError(f"Unknown tool: {name}") - - -async def run(): - """Run the server.""" - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - InitializationOptions( - server_name="example", - server_version="0.1.0", - capabilities=server.get_capabilities( - notification_options=NotificationOptions(), - experimental_capabilities={}, - ), - ), - ) - - -if __name__ == "__main__": - asyncio.run(run()) -``` - -_Full example: [examples/snippets/servers/lowlevel/direct_call_tool_result.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lowlevel/direct_call_tool_result.py)_ -<!-- /snippet-source --> - -**Note:** When returning `CallToolResult`, you bypass the automatic content/structured conversion. You must construct the complete response yourself. - -### Pagination (Advanced) - -For servers that need to handle large datasets, the low-level server provides paginated versions of list operations. This is an optional optimization - most servers won't need pagination unless they're dealing with hundreds or thousands of items. - -#### Server-side Implementation - -<!-- snippet-source examples/snippets/servers/pagination_example.py --> -```python -""" -Example of implementing pagination with MCP server decorators. -""" - -from pydantic import AnyUrl - -import mcp.types as types -from mcp.server.lowlevel import Server - -# Initialize the server -server = Server("paginated-server") - -# Sample data to paginate -ITEMS = [f"Item {i}" for i in range(1, 101)] # 100 items - - -@server.list_resources() -async def list_resources_paginated(request: types.ListResourcesRequest) -> types.ListResourcesResult: - """List resources with pagination support.""" - page_size = 10 - - # Extract cursor from request params - cursor = request.params.cursor if request.params is not None else None - - # Parse cursor to get offset - start = 0 if cursor is None else int(cursor) - end = start + page_size - - # Get page of resources - page_items = [ - types.Resource(uri=AnyUrl(f"resource://items/{item}"), name=item, description=f"Description for {item}") - for item in ITEMS[start:end] - ] - - # Determine next cursor - next_cursor = str(end) if end < len(ITEMS) else None - - return types.ListResourcesResult(resources=page_items, nextCursor=next_cursor) -``` - -_Full example: [examples/snippets/servers/pagination_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/pagination_example.py)_ -<!-- /snippet-source --> - -#### Client-side Consumption - -<!-- snippet-source examples/snippets/clients/pagination_client.py --> -```python -""" -Example of consuming paginated MCP endpoints from a client. -""" - -import asyncio - -from mcp.client.session import ClientSession -from mcp.client.stdio import StdioServerParameters, stdio_client -from mcp.types import PaginatedRequestParams, Resource - - -async def list_all_resources() -> None: - """Fetch all resources using pagination.""" - async with stdio_client(StdioServerParameters(command="uv", args=["run", "mcp-simple-pagination"])) as ( - read, - write, - ): - async with ClientSession(read, write) as session: - await session.initialize() - - all_resources: list[Resource] = [] - cursor = None - - while True: - # Fetch a page of resources - result = await session.list_resources(params=PaginatedRequestParams(cursor=cursor)) - all_resources.extend(result.resources) - - print(f"Fetched {len(result.resources)} resources") - - # Check if there are more pages - if result.nextCursor: - cursor = result.nextCursor - else: - break - - print(f"Total resources: {len(all_resources)}") - - -if __name__ == "__main__": - asyncio.run(list_all_resources()) -``` - -_Full example: [examples/snippets/clients/pagination_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/pagination_client.py)_ -<!-- /snippet-source --> - -#### Key Points - -- **Cursors are opaque strings** - the server defines the format (numeric offsets, timestamps, etc.) -- **Return `nextCursor=None`** when there are no more pages -- **Backward compatible** - clients that don't support pagination will still work (they'll just get the first page) -- **Flexible page sizes** - Each endpoint can define its own page size based on data characteristics - -See the [simple-pagination example](examples/servers/simple-pagination) for a complete implementation. - -### Writing MCP Clients - -The SDK provides a high-level client interface for connecting to MCP servers using various [transports](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports): - -<!-- snippet-source examples/snippets/clients/stdio_client.py --> -```python -""" -cd to the `examples/snippets/clients` directory and run: - uv run client -""" - -import asyncio -import os - -from pydantic import AnyUrl - -from mcp import ClientSession, StdioServerParameters, types -from mcp.client.stdio import stdio_client -from mcp.shared.context import RequestContext - -# Create server parameters for stdio connection -server_params = StdioServerParameters( - command="uv", # Using uv to run the server - args=["run", "server", "fastmcp_quickstart", "stdio"], # We're already in snippets dir - env={"UV_INDEX": os.environ.get("UV_INDEX", "")}, -) - - -# Optional: create a sampling callback -async def handle_sampling_message( - context: RequestContext[ClientSession, None], params: types.CreateMessageRequestParams -) -> types.CreateMessageResult: - print(f"Sampling request: {params.messages}") - return types.CreateMessageResult( - role="assistant", - content=types.TextContent( - type="text", - text="Hello, world! from model", - ), - model="gpt-3.5-turbo", - stopReason="endTurn", - ) - - -async def run(): - async with stdio_client(server_params) as (read, write): - async with ClientSession(read, write, sampling_callback=handle_sampling_message) as session: - # Initialize the connection - await session.initialize() - - # List available prompts - prompts = await session.list_prompts() - print(f"Available prompts: {[p.name for p in prompts.prompts]}") - - # Get a prompt (greet_user prompt from fastmcp_quickstart) - if prompts.prompts: - prompt = await session.get_prompt("greet_user", arguments={"name": "Alice", "style": "friendly"}) - print(f"Prompt result: {prompt.messages[0].content}") - - # List available resources - resources = await session.list_resources() - print(f"Available resources: {[r.uri for r in resources.resources]}") - - # List available tools - tools = await session.list_tools() - print(f"Available tools: {[t.name for t in tools.tools]}") - - # Read a resource (greeting resource from fastmcp_quickstart) - resource_content = await session.read_resource(AnyUrl("greeting://World")) - content_block = resource_content.contents[0] - if isinstance(content_block, types.TextContent): - print(f"Resource content: {content_block.text}") - - # Call a tool (add tool from fastmcp_quickstart) - result = await session.call_tool("add", arguments={"a": 5, "b": 3}) - result_unstructured = result.content[0] - if isinstance(result_unstructured, types.TextContent): - print(f"Tool result: {result_unstructured.text}") - result_structured = result.structuredContent - print(f"Structured tool result: {result_structured}") - - -def main(): - """Entry point for the client script.""" - asyncio.run(run()) - - -if __name__ == "__main__": - main() -``` - -_Full example: [examples/snippets/clients/stdio_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/stdio_client.py)_ -<!-- /snippet-source --> - -Clients can also connect using [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http): - -<!-- snippet-source examples/snippets/clients/streamable_basic.py --> -```python -""" -Run from the repository root: - uv run examples/snippets/clients/streamable_basic.py -""" - -import asyncio - -from mcp import ClientSession -from mcp.client.streamable_http import streamable_http_client - - -async def main(): - # Connect to a streamable HTTP server - async with streamable_http_client("http://localhost:8000/mcp") as ( - read_stream, - write_stream, - _, - ): - # Create a session using the client streams - async with ClientSession(read_stream, write_stream) as session: - # Initialize the connection - await session.initialize() - # List available tools - tools = await session.list_tools() - print(f"Available tools: {[tool.name for tool in tools.tools]}") - - -if __name__ == "__main__": - asyncio.run(main()) -``` - -_Full example: [examples/snippets/clients/streamable_basic.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/streamable_basic.py)_ -<!-- /snippet-source --> - -### Client Display Utilities - -When building MCP clients, the SDK provides utilities to help display human-readable names for tools, resources, and prompts: - -<!-- snippet-source examples/snippets/clients/display_utilities.py --> -```python -""" -cd to the `examples/snippets` directory and run: - uv run display-utilities-client -""" - -import asyncio -import os - -from mcp import ClientSession, StdioServerParameters -from mcp.client.stdio import stdio_client -from mcp.shared.metadata_utils import get_display_name - -# Create server parameters for stdio connection -server_params = StdioServerParameters( - command="uv", # Using uv to run the server - args=["run", "server", "fastmcp_quickstart", "stdio"], - env={"UV_INDEX": os.environ.get("UV_INDEX", "")}, -) - - -async def display_tools(session: ClientSession): - """Display available tools with human-readable names""" - tools_response = await session.list_tools() - - for tool in tools_response.tools: - # get_display_name() returns the title if available, otherwise the name - display_name = get_display_name(tool) - print(f"Tool: {display_name}") - if tool.description: - print(f" {tool.description}") - - -async def display_resources(session: ClientSession): - """Display available resources with human-readable names""" - resources_response = await session.list_resources() - - for resource in resources_response.resources: - display_name = get_display_name(resource) - print(f"Resource: {display_name} ({resource.uri})") - - templates_response = await session.list_resource_templates() - for template in templates_response.resourceTemplates: - display_name = get_display_name(template) - print(f"Resource Template: {display_name}") - - -async def run(): - """Run the display utilities example.""" - async with stdio_client(server_params) as (read, write): - async with ClientSession(read, write) as session: - # Initialize the connection - await session.initialize() - - print("=== Available Tools ===") - await display_tools(session) - - print("\n=== Available Resources ===") - await display_resources(session) - - -def main(): - """Entry point for the display utilities client.""" - asyncio.run(run()) - - -if __name__ == "__main__": - main() -``` - -_Full example: [examples/snippets/clients/display_utilities.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/display_utilities.py)_ -<!-- /snippet-source --> - -The `get_display_name()` function implements the proper precedence rules for displaying names: - -- For tools: `title` > `annotations.title` > `name` -- For other objects: `title` > `name` - -This ensures your client UI shows the most user-friendly names that servers provide. - -### OAuth Authentication for Clients - -The SDK includes [authorization support](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization) for connecting to protected MCP servers: - -<!-- snippet-source examples/snippets/clients/oauth_client.py --> -```python -""" -Before running, specify running MCP RS server URL. -To spin up RS server locally, see - examples/servers/simple-auth/README.md - -cd to the `examples/snippets` directory and run: - uv run oauth-client -""" - -import asyncio -from urllib.parse import parse_qs, urlparse - -import httpx -from pydantic import AnyUrl - -from mcp import ClientSession -from mcp.client.auth import OAuthClientProvider, TokenStorage -from mcp.client.streamable_http import streamable_http_client -from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken - - -class InMemoryTokenStorage(TokenStorage): - """Demo In-memory token storage implementation.""" - - def __init__(self): - self.tokens: OAuthToken | None = None - self.client_info: OAuthClientInformationFull | None = None - - async def get_tokens(self) -> OAuthToken | None: - """Get stored tokens.""" - return self.tokens - - async def set_tokens(self, tokens: OAuthToken) -> None: - """Store tokens.""" - self.tokens = tokens - - async def get_client_info(self) -> OAuthClientInformationFull | None: - """Get stored client information.""" - return self.client_info - - async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: - """Store client information.""" - self.client_info = client_info - - -async def handle_redirect(auth_url: str) -> None: - print(f"Visit: {auth_url}") - - -async def handle_callback() -> tuple[str, str | None]: - callback_url = input("Paste callback URL: ") - params = parse_qs(urlparse(callback_url).query) - return params["code"][0], params.get("state", [None])[0] - - -async def main(): - """Run the OAuth client example.""" - oauth_auth = OAuthClientProvider( - server_url="http://localhost:8001", - client_metadata=OAuthClientMetadata( - client_name="Example MCP Client", - redirect_uris=[AnyUrl("http://localhost:3000/callback")], - grant_types=["authorization_code", "refresh_token"], - response_types=["code"], - scope="user", - ), - storage=InMemoryTokenStorage(), - redirect_handler=handle_redirect, - callback_handler=handle_callback, - ) - - async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client: - async with streamable_http_client("http://localhost:8001/mcp", http_client=custom_client) as (read, write, _): - async with ClientSession(read, write) as session: - await session.initialize() - - tools = await session.list_tools() - print(f"Available tools: {[tool.name for tool in tools.tools]}") - - resources = await session.list_resources() - print(f"Available resources: {[r.uri for r in resources.resources]}") - - -def run(): - asyncio.run(main()) - - -if __name__ == "__main__": - run() -``` - -_Full example: [examples/snippets/clients/oauth_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/oauth_client.py)_ -<!-- /snippet-source --> - -For a complete working example, see [`examples/clients/simple-auth-client/`](examples/clients/simple-auth-client/). - -### Parsing Tool Results - -When calling tools through MCP, the `CallToolResult` object contains the tool's response in a structured format. Understanding how to parse this result is essential for properly handling tool outputs. - -```python -"""examples/snippets/clients/parsing_tool_results.py""" - -import asyncio - -from mcp import ClientSession, StdioServerParameters, types -from mcp.client.stdio import stdio_client - - -async def parse_tool_results(): - """Demonstrates how to parse different types of content in CallToolResult.""" - server_params = StdioServerParameters( - command="python", args=["path/to/mcp_server.py"] - ) - - async with stdio_client(server_params) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - - # Example 1: Parsing text content - result = await session.call_tool("get_data", {"format": "text"}) - for content in result.content: - if isinstance(content, types.TextContent): - print(f"Text: {content.text}") - - # Example 2: Parsing structured content from JSON tools - result = await session.call_tool("get_user", {"id": "123"}) - if hasattr(result, "structuredContent") and result.structuredContent: - # Access structured data directly - user_data = result.structuredContent - print(f"User: {user_data.get('name')}, Age: {user_data.get('age')}") - - # Example 3: Parsing embedded resources - result = await session.call_tool("read_config", {}) - for content in result.content: - if isinstance(content, types.EmbeddedResource): - resource = content.resource - if isinstance(resource, types.TextResourceContents): - print(f"Config from {resource.uri}: {resource.text}") - elif isinstance(resource, types.BlobResourceContents): - print(f"Binary data from {resource.uri}") - - # Example 4: Parsing image content - result = await session.call_tool("generate_chart", {"data": [1, 2, 3]}) - for content in result.content: - if isinstance(content, types.ImageContent): - print(f"Image ({content.mimeType}): {len(content.data)} bytes") - - # Example 5: Handling errors - result = await session.call_tool("failing_tool", {}) - if result.isError: - print("Tool execution failed!") - for content in result.content: - if isinstance(content, types.TextContent): - print(f"Error: {content.text}") - - -async def main(): - await parse_tool_results() - - -if __name__ == "__main__": - asyncio.run(main()) -``` - -### MCP Primitives - -The MCP protocol defines three core primitives that servers can implement: - -| Primitive | Control | Description | Example Use | -|-----------|-----------------------|-----------------------------------------------------|------------------------------| -| Prompts | User-controlled | Interactive templates invoked by user choice | Slash commands, menu options | -| Resources | Application-controlled| Contextual data managed by the client application | File contents, API responses | -| Tools | Model-controlled | Functions exposed to the LLM to take actions | API calls, data updates | - -### Server Capabilities - -MCP servers declare capabilities during initialization: - -| Capability | Feature Flag | Description | -|--------------|------------------------------|------------------------------------| -| `prompts` | `listChanged` | Prompt template management | -| `resources` | `subscribe`<br/>`listChanged`| Resource exposure and updates | -| `tools` | `listChanged` | Tool discovery and execution | -| `logging` | - | Server logging configuration | -| `completions`| - | Argument completion suggestions | - -## Documentation - -- [API Reference](https://modelcontextprotocol.github.io/python-sdk/api/) -- [Experimental Features (Tasks)](https://modelcontextprotocol.github.io/python-sdk/experimental/tasks/) -- [Model Context Protocol documentation](https://modelcontextprotocol.io) -- [Model Context Protocol specification](https://modelcontextprotocol.io/specification/latest) -- [Officially supported servers](https://github.com/modelcontextprotocol/servers) - -## Contributing - -We are passionate about supporting contributors of all levels of experience and would love to see you get involved in the project. See the [contributing guide](CONTRIBUTING.md) to get started. - -## License - -This project is licensed under the MIT License - see the LICENSE file for details. +# MCP Python SDK + +<div align="center"> + +<strong>Python implementation of the Model Context Protocol (MCP)</strong> + +[![PyPI][pypi-badge]][pypi-url] +[![MIT licensed][mit-badge]][mit-url] +[![Python Version][python-badge]][python-url] +[![Documentation][docs-badge]][docs-url] +[![Protocol][protocol-badge]][protocol-url] +[![Specification][spec-badge]][spec-url] + +</div> + +<!-- TODO(v2): Replace this README with README.v2.md when v2 is released --> + +> [!NOTE] +> **This README documents v1.x of the MCP Python SDK (the current stable release).** +> +> For v1.x code and documentation, see the [`v1.x` branch](https://github.com/modelcontextprotocol/python-sdk/tree/v1.x). +> For the upcoming v2 documentation (pre-alpha, in development on `main`), see [`README.v2.md`](README.v2.md). + +<!-- omit in toc --> +## Table of Contents + +- [MCP Python SDK](#mcp-python-sdk) + - [Overview](#overview) + - [Installation](#installation) + - [Adding MCP to your python project](#adding-mcp-to-your-python-project) + - [Running the standalone MCP development tools](#running-the-standalone-mcp-development-tools) + - [Quickstart](#quickstart) + - [What is MCP?](#what-is-mcp) + - [Core Concepts](#core-concepts) + - [Server](#server) + - [Resources](#resources) + - [Tools](#tools) + - [Structured Output](#structured-output) + - [Prompts](#prompts) + - [Images](#images) + - [Context](#context) + - [Getting Context in Functions](#getting-context-in-functions) + - [Context Properties and Methods](#context-properties-and-methods) + - [Completions](#completions) + - [Elicitation](#elicitation) + - [Sampling](#sampling) + - [Logging and Notifications](#logging-and-notifications) + - [Authentication](#authentication) + - [FastMCP Properties](#fastmcp-properties) + - [Session Properties and Methods](#session-properties-and-methods) + - [Request Context Properties](#request-context-properties) + - [Running Your Server](#running-your-server) + - [Development Mode](#development-mode) + - [Claude Desktop Integration](#claude-desktop-integration) + - [Direct Execution](#direct-execution) + - [Streamable HTTP Transport](#streamable-http-transport) + - [CORS Configuration for Browser-Based Clients](#cors-configuration-for-browser-based-clients) + - [Mounting to an Existing ASGI Server](#mounting-to-an-existing-asgi-server) + - [StreamableHTTP servers](#streamablehttp-servers) + - [Basic mounting](#basic-mounting) + - [Host-based routing](#host-based-routing) + - [Multiple servers with path configuration](#multiple-servers-with-path-configuration) + - [Path configuration at initialization](#path-configuration-at-initialization) + - [SSE servers](#sse-servers) + - [Advanced Usage](#advanced-usage) + - [Low-Level Server](#low-level-server) + - [Structured Output Support](#structured-output-support) + - [Pagination (Advanced)](#pagination-advanced) + - [Writing MCP Clients](#writing-mcp-clients) + - [Client Display Utilities](#client-display-utilities) + - [OAuth Authentication for Clients](#oauth-authentication-for-clients) + - [Parsing Tool Results](#parsing-tool-results) + - [MCP Primitives](#mcp-primitives) + - [Server Capabilities](#server-capabilities) + - [Documentation](#documentation) + - [Contributing](#contributing) + - [License](#license) + +[pypi-badge]: https://img.shields.io/pypi/v/mcp.svg +[pypi-url]: https://pypi.org/project/mcp/ +[mit-badge]: https://img.shields.io/pypi/l/mcp.svg +[mit-url]: https://github.com/modelcontextprotocol/python-sdk/blob/main/LICENSE +[python-badge]: https://img.shields.io/pypi/pyversions/mcp.svg +[python-url]: https://www.python.org/downloads/ +[docs-badge]: https://img.shields.io/badge/docs-python--sdk-blue.svg +[docs-url]: https://modelcontextprotocol.github.io/python-sdk/ +[protocol-badge]: https://img.shields.io/badge/protocol-modelcontextprotocol.io-blue.svg +[protocol-url]: https://modelcontextprotocol.io +[spec-badge]: https://img.shields.io/badge/spec-spec.modelcontextprotocol.io-blue.svg +[spec-url]: https://modelcontextprotocol.io/specification/latest + +## Overview + +The Model Context Protocol allows applications to provide context for LLMs in a standardized way, separating the concerns of providing context from the actual LLM interaction. This Python SDK implements the full MCP specification, making it easy to: + +- Build MCP clients that can connect to any MCP server +- Create MCP servers that expose resources, prompts and tools +- Use standard transports like stdio, SSE, and Streamable HTTP +- Handle all MCP protocol messages and lifecycle events + +## Installation + +### Adding MCP to your python project + +We recommend using [uv](https://docs.astral.sh/uv/) to manage your Python projects. + +If you haven't created a uv-managed project yet, create one: + + ```bash + uv init mcp-server-demo + cd mcp-server-demo + ``` + + Then add MCP to your project dependencies: + + ```bash + uv add "mcp[cli]" + ``` + +Alternatively, for projects using pip for dependencies: + +```bash +pip install "mcp[cli]" +``` + +### Running the standalone MCP development tools + +To run the mcp command with uv: + +```bash +uv run mcp +``` + +## Quickstart + +Let's create a simple MCP server that exposes a calculator tool and some data: + +<!-- snippet-source examples/snippets/servers/fastmcp_quickstart.py --> +```python +""" +FastMCP quickstart example. + +Run from the repository root: + uv run examples/snippets/servers/fastmcp_quickstart.py +""" + +from mcp.server.fastmcp import FastMCP + +# Create an MCP server +mcp = FastMCP("Demo", json_response=True) + + +# Add an addition tool +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two numbers""" + return a + b + + +# Add a dynamic greeting resource +@mcp.resource("greeting://{name}") +def get_greeting(name: str) -> str: + """Get a personalized greeting""" + return f"Hello, {name}!" + + +# Add a prompt +@mcp.prompt() +def greet_user(name: str, style: str = "friendly") -> str: + """Generate a greeting prompt""" + styles = { + "friendly": "Please write a warm, friendly greeting", + "formal": "Please write a formal, professional greeting", + "casual": "Please write a casual, relaxed greeting", + } + + return f"{styles.get(style, styles['friendly'])} for someone named {name}." + + +# Run with streamable HTTP transport +if __name__ == "__main__": + mcp.run(transport="streamable-http") +``` + +_Full example: [examples/snippets/servers/fastmcp_quickstart.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/fastmcp_quickstart.py)_ +<!-- /snippet-source --> + +You can install this server in [Claude Code](https://docs.claude.com/en/docs/claude-code/mcp) and interact with it right away. First, run the server: + +```bash +uv run --with mcp examples/snippets/servers/fastmcp_quickstart.py +``` + +Then add it to Claude Code: + +```bash +claude mcp add --transport http my-server http://localhost:8000/mcp +``` + +Alternatively, you can test it with the MCP Inspector. Start the server as above, then in a separate terminal: + +```bash +npx -y @modelcontextprotocol/inspector +``` + +In the inspector UI, connect to `http://localhost:8000/mcp`. + +## What is MCP? + +The [Model Context Protocol (MCP)](https://modelcontextprotocol.io) lets you build servers that expose data and functionality to LLM applications in a secure, standardized way. Think of it like a web API, but specifically designed for LLM interactions. MCP servers can: + +- Expose data through **Resources** (think of these sort of like GET endpoints; they are used to load information into the LLM's context) +- Provide functionality through **Tools** (sort of like POST endpoints; they are used to execute code or otherwise produce a side effect) +- Define interaction patterns through **Prompts** (reusable templates for LLM interactions) +- And more! + +## Core Concepts + +### Server + +The FastMCP server is your core interface to the MCP protocol. It handles connection management, protocol compliance, and message routing: + +<!-- snippet-source examples/snippets/servers/lifespan_example.py --> +```python +"""Example showing lifespan support for startup/shutdown with strong typing.""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass + +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession + + +# Mock database class for example +class Database: + """Mock database class for example.""" + + @classmethod + async def connect(cls) -> "Database": + """Connect to database.""" + return cls() + + async def disconnect(self) -> None: + """Disconnect from database.""" + pass + + def query(self) -> str: + """Execute a query.""" + return "Query result" + + +@dataclass +class AppContext: + """Application context with typed dependencies.""" + + db: Database + + +@asynccontextmanager +async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: + """Manage application lifecycle with type-safe context.""" + # Initialize on startup + db = await Database.connect() + try: + yield AppContext(db=db) + finally: + # Cleanup on shutdown + await db.disconnect() + + +# Pass lifespan to server +mcp = FastMCP("My App", lifespan=app_lifespan) + + +# Access type-safe lifespan context in tools +@mcp.tool() +def query_db(ctx: Context[ServerSession, AppContext]) -> str: + """Tool that uses initialized resources.""" + db = ctx.request_context.lifespan_context.db + return db.query() +``` + +_Full example: [examples/snippets/servers/lifespan_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lifespan_example.py)_ +<!-- /snippet-source --> + +### Resources + +Resources are how you expose data to LLMs. They're similar to GET endpoints in a REST API - they provide data but shouldn't perform significant computation or have side effects: + +<!-- snippet-source examples/snippets/servers/basic_resource.py --> +```python +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP(name="Resource Example") + + +@mcp.resource("file://documents/{name}") +def read_document(name: str) -> str: + """Read a document by name.""" + # This would normally read from disk + return f"Content of {name}" + + +@mcp.resource("config://settings") +def get_settings() -> str: + """Get application settings.""" + return """{ + "theme": "dark", + "language": "en", + "debug": false +}""" +``` + +_Full example: [examples/snippets/servers/basic_resource.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/basic_resource.py)_ +<!-- /snippet-source --> + +### Tools + +Tools let LLMs take actions through your server. Unlike resources, tools are expected to perform computation and have side effects: + +<!-- snippet-source examples/snippets/servers/basic_tool.py --> +```python +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP(name="Tool Example") + + +@mcp.tool() +def sum(a: int, b: int) -> int: + """Add two numbers together.""" + return a + b + + +@mcp.tool() +def get_weather(city: str, unit: str = "celsius") -> str: + """Get weather for a city.""" + # This would normally call a weather API + return f"Weather in {city}: 22degrees{unit[0].upper()}" +``` + +_Full example: [examples/snippets/servers/basic_tool.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/basic_tool.py)_ +<!-- /snippet-source --> + +Tools can optionally receive a Context object by including a parameter with the `Context` type annotation. This context is automatically injected by the FastMCP framework and provides access to MCP capabilities: + +<!-- snippet-source examples/snippets/servers/tool_progress.py --> +```python +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession + +mcp = FastMCP(name="Progress Example") + + +@mcp.tool() +async def long_running_task(task_name: str, ctx: Context[ServerSession, None], steps: int = 5) -> str: + """Execute a task with progress updates.""" + await ctx.info(f"Starting: {task_name}") + + for i in range(steps): + progress = (i + 1) / steps + await ctx.report_progress( + progress=progress, + total=1.0, + message=f"Step {i + 1}/{steps}", + ) + await ctx.debug(f"Completed step {i + 1}") + + return f"Task '{task_name}' completed" +``` + +_Full example: [examples/snippets/servers/tool_progress.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/tool_progress.py)_ +<!-- /snippet-source --> + +#### Structured Output + +Tools will return structured results by default, if their return type +annotation is compatible. Otherwise, they will return unstructured results. + +Structured output supports these return types: + +- Pydantic models (BaseModel subclasses) +- TypedDicts +- Dataclasses and other classes with type hints +- `dict[str, T]` (where T is any JSON-serializable type) +- Primitive types (str, int, float, bool, bytes, None) - wrapped in `{"result": value}` +- Generic types (list, tuple, Union, Optional, etc.) - wrapped in `{"result": value}` + +Classes without type hints cannot be serialized for structured output. Only +classes with properly annotated attributes will be converted to Pydantic models +for schema generation and validation. + +Structured results are automatically validated against the output schema +generated from the annotation. This ensures the tool returns well-typed, +validated data that clients can easily process. + +**Note:** For backward compatibility, unstructured results are also +returned. Unstructured results are provided for backward compatibility +with previous versions of the MCP specification, and are quirks-compatible +with previous versions of FastMCP in the current version of the SDK. + +**Note:** In cases where a tool function's return type annotation +causes the tool to be classified as structured _and this is undesirable_, +the classification can be suppressed by passing `structured_output=False` +to the `@tool` decorator. + +##### Advanced: Direct CallToolResult + +For full control over tool responses including the `_meta` field (for passing data to client applications without exposing it to the model), you can return `CallToolResult` directly: + +<!-- snippet-source examples/snippets/servers/direct_call_tool_result.py --> +```python +"""Example showing direct CallToolResult return for advanced control.""" + +from typing import Annotated + +from pydantic import BaseModel + +from mcp.server.fastmcp import FastMCP +from mcp.types import CallToolResult, TextContent + +mcp = FastMCP("CallToolResult Example") + + +class ValidationModel(BaseModel): + """Model for validating structured output.""" + + status: str + data: dict[str, int] + + +@mcp.tool() +def advanced_tool() -> CallToolResult: + """Return CallToolResult directly for full control including _meta field.""" + return CallToolResult( + content=[TextContent(type="text", text="Response visible to the model")], + _meta={"hidden": "data for client applications only"}, + ) + + +@mcp.tool() +def validated_tool() -> Annotated[CallToolResult, ValidationModel]: + """Return CallToolResult with structured output validation.""" + return CallToolResult( + content=[TextContent(type="text", text="Validated response")], + structuredContent={"status": "success", "data": {"result": 42}}, + _meta={"internal": "metadata"}, + ) + + +@mcp.tool() +def empty_result_tool() -> CallToolResult: + """For empty results, return CallToolResult with empty content.""" + return CallToolResult(content=[]) +``` + +_Full example: [examples/snippets/servers/direct_call_tool_result.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/direct_call_tool_result.py)_ +<!-- /snippet-source --> + +**Important:** `CallToolResult` must always be returned (no `Optional` or `Union`). For empty results, use `CallToolResult(content=[])`. For optional simple types, use `str | None` without `CallToolResult`. + +<!-- snippet-source examples/snippets/servers/structured_output.py --> +```python +"""Example showing structured output with tools.""" + +from typing import TypedDict + +from pydantic import BaseModel, Field + +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("Structured Output Example") + + +# Using Pydantic models for rich structured data +class WeatherData(BaseModel): + """Weather information structure.""" + + temperature: float = Field(description="Temperature in Celsius") + humidity: float = Field(description="Humidity percentage") + condition: str + wind_speed: float + + +@mcp.tool() +def get_weather(city: str) -> WeatherData: + """Get weather for a city - returns structured data.""" + # Simulated weather data + return WeatherData( + temperature=22.5, + humidity=45.0, + condition="sunny", + wind_speed=5.2, + ) + + +# Using TypedDict for simpler structures +class LocationInfo(TypedDict): + latitude: float + longitude: float + name: str + + +@mcp.tool() +def get_location(address: str) -> LocationInfo: + """Get location coordinates""" + return LocationInfo(latitude=51.5074, longitude=-0.1278, name="London, UK") + + +# Using dict[str, Any] for flexible schemas +@mcp.tool() +def get_statistics(data_type: str) -> dict[str, float]: + """Get various statistics""" + return {"mean": 42.5, "median": 40.0, "std_dev": 5.2} + + +# Ordinary classes with type hints work for structured output +class UserProfile: + name: str + age: int + email: str | None = None + + def __init__(self, name: str, age: int, email: str | None = None): + self.name = name + self.age = age + self.email = email + + +@mcp.tool() +def get_user(user_id: str) -> UserProfile: + """Get user profile - returns structured data""" + return UserProfile(name="Alice", age=30, email="alice@example.com") + + +# Classes WITHOUT type hints cannot be used for structured output +class UntypedConfig: + def __init__(self, setting1, setting2): # type: ignore[reportMissingParameterType] + self.setting1 = setting1 + self.setting2 = setting2 + + +@mcp.tool() +def get_config() -> UntypedConfig: + """This returns unstructured output - no schema generated""" + return UntypedConfig("value1", "value2") + + +# Lists and other types are wrapped automatically +@mcp.tool() +def list_cities() -> list[str]: + """Get a list of cities""" + return ["London", "Paris", "Tokyo"] + # Returns: {"result": ["London", "Paris", "Tokyo"]} + + +@mcp.tool() +def get_temperature(city: str) -> float: + """Get temperature as a simple float""" + return 22.5 + # Returns: {"result": 22.5} +``` + +_Full example: [examples/snippets/servers/structured_output.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/structured_output.py)_ +<!-- /snippet-source --> + +### Prompts + +Prompts are reusable templates that help LLMs interact with your server effectively: + +<!-- snippet-source examples/snippets/servers/basic_prompt.py --> +```python +from mcp.server.fastmcp import FastMCP +from mcp.server.fastmcp.prompts import base + +mcp = FastMCP(name="Prompt Example") + + +@mcp.prompt(title="Code Review") +def review_code(code: str) -> str: + return f"Please review this code:\n\n{code}" + + +@mcp.prompt(title="Debug Assistant") +def debug_error(error: str) -> list[base.Message]: + return [ + base.UserMessage("I'm seeing this error:"), + base.UserMessage(error), + base.AssistantMessage("I'll help debug that. What have you tried so far?"), + ] +``` + +_Full example: [examples/snippets/servers/basic_prompt.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/basic_prompt.py)_ +<!-- /snippet-source --> + +### Icons + +MCP servers can provide icons for UI display. Icons can be added to the server implementation, tools, resources, and prompts: + +```python +from mcp.server.fastmcp import FastMCP, Icon + +# Create an icon from a file path or URL +icon = Icon( + src="icon.png", + mimeType="image/png", + sizes="64x64" +) + +# Add icons to server +mcp = FastMCP( + "My Server", + website_url="https://example.com", + icons=[icon] +) + +# Add icons to tools, resources, and prompts +@mcp.tool(icons=[icon]) +def my_tool(): + """Tool with an icon.""" + return "result" + +@mcp.resource("demo://resource", icons=[icon]) +def my_resource(): + """Resource with an icon.""" + return "content" +``` + +_Full example: [examples/fastmcp/icons_demo.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/fastmcp/icons_demo.py)_ + +### Images + +FastMCP provides an `Image` class that automatically handles image data: + +<!-- snippet-source examples/snippets/servers/images.py --> +```python +"""Example showing image handling with FastMCP.""" + +from PIL import Image as PILImage + +from mcp.server.fastmcp import FastMCP, Image + +mcp = FastMCP("Image Example") + + +@mcp.tool() +def create_thumbnail(image_path: str) -> Image: + """Create a thumbnail from an image""" + img = PILImage.open(image_path) + img.thumbnail((100, 100)) + return Image(data=img.tobytes(), format="png") +``` + +_Full example: [examples/snippets/servers/images.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/images.py)_ +<!-- /snippet-source --> + +### Context + +The Context object is automatically injected into tool and resource functions that request it via type hints. It provides access to MCP capabilities like logging, progress reporting, resource reading, user interaction, and request metadata. + +#### Getting Context in Functions + +To use context in a tool or resource function, add a parameter with the `Context` type annotation: + +```python +from mcp.server.fastmcp import Context, FastMCP + +mcp = FastMCP(name="Context Example") + + +@mcp.tool() +async def my_tool(x: int, ctx: Context) -> str: + """Tool that uses context capabilities.""" + # The context parameter can have any name as long as it's type-annotated + return await process_with_context(x, ctx) +``` + +#### Context Properties and Methods + +The Context object provides the following capabilities: + +- `ctx.request_id` - Unique ID for the current request +- `ctx.client_id` - Client ID if available +- `ctx.fastmcp` - Access to the FastMCP server instance (see [FastMCP Properties](#fastmcp-properties)) +- `ctx.session` - Access to the underlying session for advanced communication (see [Session Properties and Methods](#session-properties-and-methods)) +- `ctx.request_context` - Access to request-specific data and lifespan resources (see [Request Context Properties](#request-context-properties)) +- `await ctx.debug(message)` - Send debug log message +- `await ctx.info(message)` - Send info log message +- `await ctx.warning(message)` - Send warning log message +- `await ctx.error(message)` - Send error log message +- `await ctx.log(level, message, logger_name=None)` - Send log with custom level +- `await ctx.report_progress(progress, total=None, message=None)` - Report operation progress +- `await ctx.read_resource(uri)` - Read a resource by URI +- `await ctx.elicit(message, schema)` - Request additional information from user with validation + +<!-- snippet-source examples/snippets/servers/tool_progress.py --> +```python +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession + +mcp = FastMCP(name="Progress Example") + + +@mcp.tool() +async def long_running_task(task_name: str, ctx: Context[ServerSession, None], steps: int = 5) -> str: + """Execute a task with progress updates.""" + await ctx.info(f"Starting: {task_name}") + + for i in range(steps): + progress = (i + 1) / steps + await ctx.report_progress( + progress=progress, + total=1.0, + message=f"Step {i + 1}/{steps}", + ) + await ctx.debug(f"Completed step {i + 1}") + + return f"Task '{task_name}' completed" +``` + +_Full example: [examples/snippets/servers/tool_progress.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/tool_progress.py)_ +<!-- /snippet-source --> + +### Completions + +MCP supports providing completion suggestions for prompt arguments and resource template parameters. With the context parameter, servers can provide completions based on previously resolved values: + +Client usage: + +<!-- snippet-source examples/snippets/clients/completion_client.py --> +```python +""" +cd to the `examples/snippets` directory and run: + uv run completion-client +""" + +import asyncio +import os + +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client +from mcp.types import PromptReference, ResourceTemplateReference + +# Create server parameters for stdio connection +server_params = StdioServerParameters( + command="uv", # Using uv to run the server + args=["run", "server", "completion", "stdio"], # Server with completion support + env={"UV_INDEX": os.environ.get("UV_INDEX", "")}, +) + + +async def run(): + """Run the completion client example.""" + async with stdio_client(server_params) as (read, write): + async with ClientSession(read, write) as session: + # Initialize the connection + await session.initialize() + + # List available resource templates + templates = await session.list_resource_templates() + print("Available resource templates:") + for template in templates.resourceTemplates: + print(f" - {template.uriTemplate}") + + # List available prompts + prompts = await session.list_prompts() + print("\nAvailable prompts:") + for prompt in prompts.prompts: + print(f" - {prompt.name}") + + # Complete resource template arguments + if templates.resourceTemplates: + template = templates.resourceTemplates[0] + print(f"\nCompleting arguments for resource template: {template.uriTemplate}") + + # Complete without context + result = await session.complete( + ref=ResourceTemplateReference(type="ref/resource", uri=template.uriTemplate), + argument={"name": "owner", "value": "model"}, + ) + print(f"Completions for 'owner' starting with 'model': {result.completion.values}") + + # Complete with context - repo suggestions based on owner + result = await session.complete( + ref=ResourceTemplateReference(type="ref/resource", uri=template.uriTemplate), + argument={"name": "repo", "value": ""}, + context_arguments={"owner": "modelcontextprotocol"}, + ) + print(f"Completions for 'repo' with owner='modelcontextprotocol': {result.completion.values}") + + # Complete prompt arguments + if prompts.prompts: + prompt_name = prompts.prompts[0].name + print(f"\nCompleting arguments for prompt: {prompt_name}") + + result = await session.complete( + ref=PromptReference(type="ref/prompt", name=prompt_name), + argument={"name": "style", "value": ""}, + ) + print(f"Completions for 'style' argument: {result.completion.values}") + + +def main(): + """Entry point for the completion client.""" + asyncio.run(run()) + + +if __name__ == "__main__": + main() +``` + +_Full example: [examples/snippets/clients/completion_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/completion_client.py)_ +<!-- /snippet-source --> +### Elicitation + +Request additional information from users. This example shows an Elicitation during a Tool Call: + +<!-- snippet-source examples/snippets/servers/elicitation.py --> +```python +"""Elicitation examples demonstrating form and URL mode elicitation. + +Form mode elicitation collects structured, non-sensitive data through a schema. +URL mode elicitation directs users to external URLs for sensitive operations +like OAuth flows, credential collection, or payment processing. +""" + +import uuid + +from pydantic import BaseModel, Field + +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession +from mcp.shared.exceptions import UrlElicitationRequiredError +from mcp.types import ElicitRequestURLParams + +mcp = FastMCP(name="Elicitation Example") + + +class BookingPreferences(BaseModel): + """Schema for collecting user preferences.""" + + checkAlternative: bool = Field(description="Would you like to check another date?") + alternativeDate: str = Field( + default="2024-12-26", + description="Alternative date (YYYY-MM-DD)", + ) + + +@mcp.tool() +async def book_table(date: str, time: str, party_size: int, ctx: Context[ServerSession, None]) -> str: + """Book a table with date availability check. + + This demonstrates form mode elicitation for collecting non-sensitive user input. + """ + # Check if date is available + if date == "2024-12-25": + # Date unavailable - ask user for alternative + result = await ctx.elicit( + message=(f"No tables available for {party_size} on {date}. Would you like to try another date?"), + schema=BookingPreferences, + ) + + if result.action == "accept" and result.data: + if result.data.checkAlternative: + return f"[SUCCESS] Booked for {result.data.alternativeDate}" + return "[CANCELLED] No booking made" + return "[CANCELLED] Booking cancelled" + + # Date available + return f"[SUCCESS] Booked for {date} at {time}" + + +@mcp.tool() +async def secure_payment(amount: float, ctx: Context[ServerSession, None]) -> str: + """Process a secure payment requiring URL confirmation. + + This demonstrates URL mode elicitation using ctx.elicit_url() for + operations that require out-of-band user interaction. + """ + elicitation_id = str(uuid.uuid4()) + + result = await ctx.elicit_url( + message=f"Please confirm payment of ${amount:.2f}", + url=f"https://payments.example.com/confirm?amount={amount}&id={elicitation_id}", + elicitation_id=elicitation_id, + ) + + if result.action == "accept": + # In a real app, the payment confirmation would happen out-of-band + # and you'd verify the payment status from your backend + return f"Payment of ${amount:.2f} initiated - check your browser to complete" + elif result.action == "decline": + return "Payment declined by user" + return "Payment cancelled" + + +@mcp.tool() +async def connect_service(service_name: str, ctx: Context[ServerSession, None]) -> str: + """Connect to a third-party service requiring OAuth authorization. + + This demonstrates the "throw error" pattern using UrlElicitationRequiredError. + Use this pattern when the tool cannot proceed without user authorization. + """ + elicitation_id = str(uuid.uuid4()) + + # Raise UrlElicitationRequiredError to signal that the client must complete + # a URL elicitation before this request can be processed. + # The MCP framework will convert this to a -32042 error response. + raise UrlElicitationRequiredError( + [ + ElicitRequestURLParams( + mode="url", + message=f"Authorization required to connect to {service_name}", + url=f"https://{service_name}.example.com/oauth/authorize?elicit={elicitation_id}", + elicitationId=elicitation_id, + ) + ] + ) +``` + +_Full example: [examples/snippets/servers/elicitation.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/elicitation.py)_ +<!-- /snippet-source --> + +Elicitation schemas support default values for all field types. Default values are automatically included in the JSON schema sent to clients, allowing them to pre-populate forms. + +The `elicit()` method returns an `ElicitationResult` with: + +- `action`: "accept", "decline", or "cancel" +- `data`: The validated response (only when accepted) +- `validation_error`: Any validation error message + +### Sampling + +Tools can interact with LLMs through sampling (generating text): + +<!-- snippet-source examples/snippets/servers/sampling.py --> +```python +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession +from mcp.types import SamplingMessage, TextContent + +mcp = FastMCP(name="Sampling Example") + + +@mcp.tool() +async def generate_poem(topic: str, ctx: Context[ServerSession, None]) -> str: + """Generate a poem using LLM sampling.""" + prompt = f"Write a short poem about {topic}" + + result = await ctx.session.create_message( + messages=[ + SamplingMessage( + role="user", + content=TextContent(type="text", text=prompt), + ) + ], + max_tokens=100, + ) + + # Since we're not passing tools param, result.content is single content + if result.content.type == "text": + return result.content.text + return str(result.content) +``` + +_Full example: [examples/snippets/servers/sampling.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/sampling.py)_ +<!-- /snippet-source --> + +### Logging and Notifications + +Tools can send logs and notifications through the context: + +<!-- snippet-source examples/snippets/servers/notifications.py --> +```python +from mcp.server.fastmcp import Context, FastMCP +from mcp.server.session import ServerSession + +mcp = FastMCP(name="Notifications Example") + + +@mcp.tool() +async def process_data(data: str, ctx: Context[ServerSession, None]) -> str: + """Process data with logging.""" + # Different log levels + await ctx.debug(f"Debug: Processing '{data}'") + await ctx.info("Info: Starting processing") + await ctx.warning("Warning: This is experimental") + await ctx.error("Error: (This is just a demo)") + + # Notify about resource changes + await ctx.session.send_resource_list_changed() + + return f"Processed: {data}" +``` + +_Full example: [examples/snippets/servers/notifications.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/notifications.py)_ +<!-- /snippet-source --> + +### Authentication + +Authentication can be used by servers that want to expose tools accessing protected resources. + +`mcp.server.auth` implements OAuth 2.1 resource server functionality, where MCP servers act as Resource Servers (RS) that validate tokens issued by separate Authorization Servers (AS). This follows the [MCP authorization specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization) and implements RFC 9728 (Protected Resource Metadata) for AS discovery. + +MCP servers can use authentication by providing an implementation of the `TokenVerifier` protocol: + +<!-- snippet-source examples/snippets/servers/oauth_server.py --> +```python +""" +Run from the repository root: + uv run examples/snippets/servers/oauth_server.py +""" + +from pydantic import AnyHttpUrl + +from mcp.server.auth.provider import AccessToken, TokenVerifier +from mcp.server.auth.settings import AuthSettings +from mcp.server.fastmcp import FastMCP + + +class SimpleTokenVerifier(TokenVerifier): + """Simple token verifier for demonstration.""" + + async def verify_token(self, token: str) -> AccessToken | None: + pass # This is where you would implement actual token validation + + +# Create FastMCP instance as a Resource Server +mcp = FastMCP( + "Weather Service", + json_response=True, + # Token verifier for authentication + token_verifier=SimpleTokenVerifier(), + # Auth settings for RFC 9728 Protected Resource Metadata + auth=AuthSettings( + issuer_url=AnyHttpUrl("https://auth.example.com"), # Authorization Server URL + resource_server_url=AnyHttpUrl("http://localhost:3001"), # This server's URL + required_scopes=["user"], + ), +) + + +@mcp.tool() +async def get_weather(city: str = "London") -> dict[str, str]: + """Get weather data for a city""" + return { + "city": city, + "temperature": "22", + "condition": "Partly cloudy", + "humidity": "65%", + } + + +if __name__ == "__main__": + mcp.run(transport="streamable-http") +``` + +_Full example: [examples/snippets/servers/oauth_server.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/oauth_server.py)_ +<!-- /snippet-source --> + +For a complete example with separate Authorization Server and Resource Server implementations, see [`examples/servers/simple-auth/`](examples/servers/simple-auth/). + +**Architecture:** + +- **Authorization Server (AS)**: Handles OAuth flows, user authentication, and token issuance +- **Resource Server (RS)**: Your MCP server that validates tokens and serves protected resources +- **Client**: Discovers AS through RFC 9728, obtains tokens, and uses them with the MCP server + +See [TokenVerifier](src/mcp/server/auth/provider.py) for more details on implementing token validation. + +### FastMCP Properties + +The FastMCP server instance accessible via `ctx.fastmcp` provides access to server configuration and metadata: + +- `ctx.fastmcp.name` - The server's name as defined during initialization +- `ctx.fastmcp.instructions` - Server instructions/description provided to clients +- `ctx.fastmcp.website_url` - Optional website URL for the server +- `ctx.fastmcp.icons` - Optional list of icons for UI display +- `ctx.fastmcp.settings` - Complete server configuration object containing: + - `debug` - Debug mode flag + - `log_level` - Current logging level + - `host` and `port` - Server network configuration + - `mount_path`, `sse_path`, `streamable_http_path` - Transport paths + - `stateless_http` - Whether the server operates in stateless mode + - And other configuration options + +```python +@mcp.tool() +def server_info(ctx: Context) -> dict: + """Get information about the current server.""" + return { + "name": ctx.fastmcp.name, + "instructions": ctx.fastmcp.instructions, + "debug_mode": ctx.fastmcp.settings.debug, + "log_level": ctx.fastmcp.settings.log_level, + "host": ctx.fastmcp.settings.host, + "port": ctx.fastmcp.settings.port, + } +``` + +### Session Properties and Methods + +The session object accessible via `ctx.session` provides advanced control over client communication: + +- `ctx.session.client_params` - Client initialization parameters and declared capabilities +- `await ctx.session.send_log_message(level, data, logger)` - Send log messages with full control +- `await ctx.session.create_message(messages, max_tokens)` - Request LLM sampling/completion +- `await ctx.session.send_progress_notification(token, progress, total, message)` - Direct progress updates +- `await ctx.session.send_resource_updated(uri)` - Notify clients that a specific resource changed +- `await ctx.session.send_resource_list_changed()` - Notify clients that the resource list changed +- `await ctx.session.send_tool_list_changed()` - Notify clients that the tool list changed +- `await ctx.session.send_prompt_list_changed()` - Notify clients that the prompt list changed + +```python +@mcp.tool() +async def notify_data_update(resource_uri: str, ctx: Context) -> str: + """Update data and notify clients of the change.""" + # Perform data update logic here + + # Notify clients that this specific resource changed + await ctx.session.send_resource_updated(AnyUrl(resource_uri)) + + # If this affects the overall resource list, notify about that too + await ctx.session.send_resource_list_changed() + + return f"Updated {resource_uri} and notified clients" +``` + +### Request Context Properties + +The request context accessible via `ctx.request_context` contains request-specific information and resources: + +- `ctx.request_context.lifespan_context` - Access to resources initialized during server startup + - Database connections, configuration objects, shared services + - Type-safe access to resources defined in your server's lifespan function +- `ctx.request_context.meta` - Request metadata from the client including: + - `progressToken` - Token for progress notifications + - Other client-provided metadata +- `ctx.request_context.request` - The original MCP request object for advanced processing +- `ctx.request_context.request_id` - Unique identifier for this request + +```python +# Example with typed lifespan context +@dataclass +class AppContext: + db: Database + config: AppConfig + +@mcp.tool() +def query_with_config(query: str, ctx: Context) -> str: + """Execute a query using shared database and configuration.""" + # Access typed lifespan context + app_ctx: AppContext = ctx.request_context.lifespan_context + + # Use shared resources + connection = app_ctx.db + settings = app_ctx.config + + # Execute query with configuration + result = connection.execute(query, timeout=settings.query_timeout) + return str(result) +``` + +_Full lifespan example: [examples/snippets/servers/lifespan_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lifespan_example.py)_ + +## Running Your Server + +### Development Mode + +The fastest way to test and debug your server is with the MCP Inspector: + +```bash +uv run mcp dev server.py + +# Add dependencies +uv run mcp dev server.py --with pandas --with numpy + +# Mount local code +uv run mcp dev server.py --with-editable . +``` + +### Claude Desktop Integration + +Once your server is ready, install it in Claude Desktop: + +```bash +uv run mcp install server.py + +# Custom name +uv run mcp install server.py --name "My Analytics Server" + +# Environment variables +uv run mcp install server.py -v API_KEY=abc123 -v DB_URL=postgres://... +uv run mcp install server.py -f .env +``` + +### Direct Execution + +For advanced scenarios like custom deployments: + +<!-- snippet-source examples/snippets/servers/direct_execution.py --> +```python +"""Example showing direct execution of an MCP server. + +This is the simplest way to run an MCP server directly. +cd to the `examples/snippets` directory and run: + uv run direct-execution-server + or + python servers/direct_execution.py +""" + +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("My App") + + +@mcp.tool() +def hello(name: str = "World") -> str: + """Say hello to someone.""" + return f"Hello, {name}!" + + +def main(): + """Entry point for the direct execution server.""" + mcp.run() + + +if __name__ == "__main__": + main() +``` + +_Full example: [examples/snippets/servers/direct_execution.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/direct_execution.py)_ +<!-- /snippet-source --> + +Run it with: + +```bash +python servers/direct_execution.py +# or +uv run mcp run servers/direct_execution.py +``` + +Note that `uv run mcp run` or `uv run mcp dev` only supports server using FastMCP and not the low-level server variant. + +### Streamable HTTP Transport + +> **Note**: Streamable HTTP transport is the recommended transport for production deployments. Use `stateless_http=True` and `json_response=True` for optimal scalability. + +<!-- snippet-source examples/snippets/servers/streamable_config.py --> +```python +""" +Run from the repository root: + uv run examples/snippets/servers/streamable_config.py +""" + +from mcp.server.fastmcp import FastMCP + +# Stateless server with JSON responses (recommended) +mcp = FastMCP("StatelessServer", stateless_http=True, json_response=True) + +# Other configuration options: +# Stateless server with SSE streaming responses +# mcp = FastMCP("StatelessServer", stateless_http=True) + +# Stateful server with session persistence +# mcp = FastMCP("StatefulServer") + + +# Add a simple tool to demonstrate the server +@mcp.tool() +def greet(name: str = "World") -> str: + """Greet someone by name.""" + return f"Hello, {name}!" + + +# Run server with streamable_http transport +if __name__ == "__main__": + mcp.run(transport="streamable-http") +``` + +_Full example: [examples/snippets/servers/streamable_config.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_config.py)_ +<!-- /snippet-source --> + +You can mount multiple FastMCP servers in a Starlette application: + +<!-- snippet-source examples/snippets/servers/streamable_starlette_mount.py --> +```python +""" +Run from the repository root: + uvicorn examples.snippets.servers.streamable_starlette_mount:app --reload +""" + +import contextlib + +from starlette.applications import Starlette +from starlette.routing import Mount + +from mcp.server.fastmcp import FastMCP + +# Create the Echo server +echo_mcp = FastMCP(name="EchoServer", stateless_http=True, json_response=True) + + +@echo_mcp.tool() +def echo(message: str) -> str: + """A simple echo tool""" + return f"Echo: {message}" + + +# Create the Math server +math_mcp = FastMCP(name="MathServer", stateless_http=True, json_response=True) + + +@math_mcp.tool() +def add_two(n: int) -> int: + """Tool to add two to the input""" + return n + 2 + + +# Create a combined lifespan to manage both session managers +@contextlib.asynccontextmanager +async def lifespan(app: Starlette): + async with contextlib.AsyncExitStack() as stack: + await stack.enter_async_context(echo_mcp.session_manager.run()) + await stack.enter_async_context(math_mcp.session_manager.run()) + yield + + +# Create the Starlette app and mount the MCP servers +app = Starlette( + routes=[ + Mount("/echo", echo_mcp.streamable_http_app()), + Mount("/math", math_mcp.streamable_http_app()), + ], + lifespan=lifespan, +) + +# Note: Clients connect to http://localhost:8000/echo/mcp and http://localhost:8000/math/mcp +# To mount at the root of each path (e.g., /echo instead of /echo/mcp): +# echo_mcp.settings.streamable_http_path = "/" +# math_mcp.settings.streamable_http_path = "/" +``` + +_Full example: [examples/snippets/servers/streamable_starlette_mount.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_starlette_mount.py)_ +<!-- /snippet-source --> + +For low level server with Streamable HTTP implementations, see: + +- Stateful server: [`examples/servers/simple-streamablehttp/`](examples/servers/simple-streamablehttp/) +- Stateless server: [`examples/servers/simple-streamablehttp-stateless/`](examples/servers/simple-streamablehttp-stateless/) + +The streamable HTTP transport supports: + +- Stateful and stateless operation modes +- Resumability with event stores +- JSON or SSE response formats +- Better scalability for multi-node deployments + +#### CORS Configuration for Browser-Based Clients + +If you'd like your server to be accessible by browser-based MCP clients, you'll need to configure CORS headers. The `Mcp-Session-Id` header must be exposed for browser clients to access it: + +```python +from starlette.applications import Starlette +from starlette.middleware.cors import CORSMiddleware + +# Create your Starlette app first +starlette_app = Starlette(routes=[...]) + +# Then wrap it with CORS middleware +starlette_app = CORSMiddleware( + starlette_app, + allow_origins=["*"], # Configure appropriately for production + allow_methods=["GET", "POST", "DELETE"], # MCP streamable HTTP methods + expose_headers=["Mcp-Session-Id"], +) +``` + +This configuration is necessary because: + +- The MCP streamable HTTP transport uses the `Mcp-Session-Id` header for session management +- Browsers restrict access to response headers unless explicitly exposed via CORS +- Without this configuration, browser-based clients won't be able to read the session ID from initialization responses + +### Mounting to an Existing ASGI Server + +By default, SSE servers are mounted at `/sse` and Streamable HTTP servers are mounted at `/mcp`. You can customize these paths using the methods described below. + +For more information on mounting applications in Starlette, see the [Starlette documentation](https://www.starlette.io/routing/#submounting-routes). + +#### StreamableHTTP servers + +You can mount the StreamableHTTP server to an existing ASGI server using the `streamable_http_app` method. This allows you to integrate the StreamableHTTP server with other ASGI applications. + +##### Basic mounting + +<!-- snippet-source examples/snippets/servers/streamable_http_basic_mounting.py --> +```python +""" +Basic example showing how to mount StreamableHTTP server in Starlette. + +Run from the repository root: + uvicorn examples.snippets.servers.streamable_http_basic_mounting:app --reload +""" + +import contextlib + +from starlette.applications import Starlette +from starlette.routing import Mount + +from mcp.server.fastmcp import FastMCP + +# Create MCP server +mcp = FastMCP("My App", json_response=True) + + +@mcp.tool() +def hello() -> str: + """A simple hello tool""" + return "Hello from MCP!" + + +# Create a lifespan context manager to run the session manager +@contextlib.asynccontextmanager +async def lifespan(app: Starlette): + async with mcp.session_manager.run(): + yield + + +# Mount the StreamableHTTP server to the existing ASGI server +app = Starlette( + routes=[ + Mount("/", app=mcp.streamable_http_app()), + ], + lifespan=lifespan, +) +``` + +_Full example: [examples/snippets/servers/streamable_http_basic_mounting.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_http_basic_mounting.py)_ +<!-- /snippet-source --> + +##### Host-based routing + +<!-- snippet-source examples/snippets/servers/streamable_http_host_mounting.py --> +```python +""" +Example showing how to mount StreamableHTTP server using Host-based routing. + +Run from the repository root: + uvicorn examples.snippets.servers.streamable_http_host_mounting:app --reload +""" + +import contextlib + +from starlette.applications import Starlette +from starlette.routing import Host + +from mcp.server.fastmcp import FastMCP + +# Create MCP server +mcp = FastMCP("MCP Host App", json_response=True) + + +@mcp.tool() +def domain_info() -> str: + """Get domain-specific information""" + return "This is served from mcp.acme.corp" + + +# Create a lifespan context manager to run the session manager +@contextlib.asynccontextmanager +async def lifespan(app: Starlette): + async with mcp.session_manager.run(): + yield + + +# Mount using Host-based routing +app = Starlette( + routes=[ + Host("mcp.acme.corp", app=mcp.streamable_http_app()), + ], + lifespan=lifespan, +) +``` + +_Full example: [examples/snippets/servers/streamable_http_host_mounting.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_http_host_mounting.py)_ +<!-- /snippet-source --> + +##### Multiple servers with path configuration + +<!-- snippet-source examples/snippets/servers/streamable_http_multiple_servers.py --> +```python +""" +Example showing how to mount multiple StreamableHTTP servers with path configuration. + +Run from the repository root: + uvicorn examples.snippets.servers.streamable_http_multiple_servers:app --reload +""" + +import contextlib + +from starlette.applications import Starlette +from starlette.routing import Mount + +from mcp.server.fastmcp import FastMCP + +# Create multiple MCP servers +api_mcp = FastMCP("API Server", json_response=True) +chat_mcp = FastMCP("Chat Server", json_response=True) + + +@api_mcp.tool() +def api_status() -> str: + """Get API status""" + return "API is running" + + +@chat_mcp.tool() +def send_message(message: str) -> str: + """Send a chat message""" + return f"Message sent: {message}" + + +# Configure servers to mount at the root of each path +# This means endpoints will be at /api and /chat instead of /api/mcp and /chat/mcp +api_mcp.settings.streamable_http_path = "/" +chat_mcp.settings.streamable_http_path = "/" + + +# Create a combined lifespan to manage both session managers +@contextlib.asynccontextmanager +async def lifespan(app: Starlette): + async with contextlib.AsyncExitStack() as stack: + await stack.enter_async_context(api_mcp.session_manager.run()) + await stack.enter_async_context(chat_mcp.session_manager.run()) + yield + + +# Mount the servers +app = Starlette( + routes=[ + Mount("/api", app=api_mcp.streamable_http_app()), + Mount("/chat", app=chat_mcp.streamable_http_app()), + ], + lifespan=lifespan, +) +``` + +_Full example: [examples/snippets/servers/streamable_http_multiple_servers.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_http_multiple_servers.py)_ +<!-- /snippet-source --> + +##### Path configuration at initialization + +<!-- snippet-source examples/snippets/servers/streamable_http_path_config.py --> +```python +""" +Example showing path configuration during FastMCP initialization. + +Run from the repository root: + uvicorn examples.snippets.servers.streamable_http_path_config:app --reload +""" + +from starlette.applications import Starlette +from starlette.routing import Mount + +from mcp.server.fastmcp import FastMCP + +# Configure streamable_http_path during initialization +# This server will mount at the root of wherever it's mounted +mcp_at_root = FastMCP( + "My Server", + json_response=True, + streamable_http_path="/", +) + + +@mcp_at_root.tool() +def process_data(data: str) -> str: + """Process some data""" + return f"Processed: {data}" + + +# Mount at /process - endpoints will be at /process instead of /process/mcp +app = Starlette( + routes=[ + Mount("/process", app=mcp_at_root.streamable_http_app()), + ] +) +``` + +_Full example: [examples/snippets/servers/streamable_http_path_config.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_http_path_config.py)_ +<!-- /snippet-source --> + +#### SSE servers + +> **Note**: SSE transport is being superseded by [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http). + +You can mount the SSE server to an existing ASGI server using the `sse_app` method. This allows you to integrate the SSE server with other ASGI applications. + +```python +from starlette.applications import Starlette +from starlette.routing import Mount, Host +from mcp.server.fastmcp import FastMCP + + +mcp = FastMCP("My App") + +# Mount the SSE server to the existing ASGI server +app = Starlette( + routes=[ + Mount('/', app=mcp.sse_app()), + ] +) + +# or dynamically mount as host +app.router.routes.append(Host('mcp.acme.corp', app=mcp.sse_app())) +``` + +When mounting multiple MCP servers under different paths, you can configure the mount path in several ways: + +```python +from starlette.applications import Starlette +from starlette.routing import Mount +from mcp.server.fastmcp import FastMCP + +# Create multiple MCP servers +github_mcp = FastMCP("GitHub API") +browser_mcp = FastMCP("Browser") +curl_mcp = FastMCP("Curl") +search_mcp = FastMCP("Search") + +# Method 1: Configure mount paths via settings (recommended for persistent configuration) +github_mcp.settings.mount_path = "/github" +browser_mcp.settings.mount_path = "/browser" + +# Method 2: Pass mount path directly to sse_app (preferred for ad-hoc mounting) +# This approach doesn't modify the server's settings permanently + +# Create Starlette app with multiple mounted servers +app = Starlette( + routes=[ + # Using settings-based configuration + Mount("/github", app=github_mcp.sse_app()), + Mount("/browser", app=browser_mcp.sse_app()), + # Using direct mount path parameter + Mount("/curl", app=curl_mcp.sse_app("/curl")), + Mount("/search", app=search_mcp.sse_app("/search")), + ] +) + +# Method 3: For direct execution, you can also pass the mount path to run() +if __name__ == "__main__": + search_mcp.run(transport="sse", mount_path="/search") +``` + +For more information on mounting applications in Starlette, see the [Starlette documentation](https://www.starlette.io/routing/#submounting-routes). + +## Advanced Usage + +### Low-Level Server + +For more control, you can use the low-level server implementation directly. This gives you full access to the protocol and allows you to customize every aspect of your server, including lifecycle management through the lifespan API: + +<!-- snippet-source examples/snippets/servers/lowlevel/lifespan.py --> +```python +""" +Run from the repository root: + uv run examples/snippets/servers/lowlevel/lifespan.py +""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Any + +import mcp.server.stdio +import mcp.types as types +from mcp.server.lowlevel import NotificationOptions, Server +from mcp.server.models import InitializationOptions + + +# Mock database class for example +class Database: + """Mock database class for example.""" + + @classmethod + async def connect(cls) -> "Database": + """Connect to database.""" + print("Database connected") + return cls() + + async def disconnect(self) -> None: + """Disconnect from database.""" + print("Database disconnected") + + async def query(self, query_str: str) -> list[dict[str, str]]: + """Execute a query.""" + # Simulate database query + return [{"id": "1", "name": "Example", "query": query_str}] + + +@asynccontextmanager +async def server_lifespan(_server: Server) -> AsyncIterator[dict[str, Any]]: + """Manage server startup and shutdown lifecycle.""" + # Initialize resources on startup + db = await Database.connect() + try: + yield {"db": db} + finally: + # Clean up on shutdown + await db.disconnect() + + +# Pass lifespan to server +server = Server("example-server", lifespan=server_lifespan) + + +@server.list_tools() +async def handle_list_tools() -> list[types.Tool]: + """List available tools.""" + return [ + types.Tool( + name="query_db", + description="Query the database", + inputSchema={ + "type": "object", + "properties": {"query": {"type": "string", "description": "SQL query to execute"}}, + "required": ["query"], + }, + ) + ] + + +@server.call_tool() +async def query_db(name: str, arguments: dict[str, Any]) -> list[types.TextContent]: + """Handle database query tool call.""" + if name != "query_db": + raise ValueError(f"Unknown tool: {name}") + + # Access lifespan context + ctx = server.request_context + db = ctx.lifespan_context["db"] + + # Execute query + results = await db.query(arguments["query"]) + + return [types.TextContent(type="text", text=f"Query results: {results}")] + + +async def run(): + """Run the server with lifespan management.""" + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + InitializationOptions( + server_name="example-server", + server_version="0.1.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) + + +if __name__ == "__main__": + import asyncio + + asyncio.run(run()) +``` + +_Full example: [examples/snippets/servers/lowlevel/lifespan.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lowlevel/lifespan.py)_ +<!-- /snippet-source --> + +The lifespan API provides: + +- A way to initialize resources when the server starts and clean them up when it stops +- Access to initialized resources through the request context in handlers +- Type-safe context passing between lifespan and request handlers + +<!-- snippet-source examples/snippets/servers/lowlevel/basic.py --> +```python +""" +Run from the repository root: +uv run examples/snippets/servers/lowlevel/basic.py +""" + +import asyncio + +import mcp.server.stdio +import mcp.types as types +from mcp.server.lowlevel import NotificationOptions, Server +from mcp.server.models import InitializationOptions + +# Create a server instance +server = Server("example-server") + + +@server.list_prompts() +async def handle_list_prompts() -> list[types.Prompt]: + """List available prompts.""" + return [ + types.Prompt( + name="example-prompt", + description="An example prompt template", + arguments=[types.PromptArgument(name="arg1", description="Example argument", required=True)], + ) + ] + + +@server.get_prompt() +async def handle_get_prompt(name: str, arguments: dict[str, str] | None) -> types.GetPromptResult: + """Get a specific prompt by name.""" + if name != "example-prompt": + raise ValueError(f"Unknown prompt: {name}") + + arg1_value = (arguments or {}).get("arg1", "default") + + return types.GetPromptResult( + description="Example prompt", + messages=[ + types.PromptMessage( + role="user", + content=types.TextContent(type="text", text=f"Example prompt text with argument: {arg1_value}"), + ) + ], + ) + + +async def run(): + """Run the basic low-level server.""" + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + InitializationOptions( + server_name="example", + server_version="0.1.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) + + +if __name__ == "__main__": + asyncio.run(run()) +``` + +_Full example: [examples/snippets/servers/lowlevel/basic.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lowlevel/basic.py)_ +<!-- /snippet-source --> + +Caution: The `uv run mcp run` and `uv run mcp dev` tool doesn't support low-level server. + +#### Structured Output Support + +The low-level server supports structured output for tools, allowing you to return both human-readable content and machine-readable structured data. Tools can define an `outputSchema` to validate their structured output: + +<!-- snippet-source examples/snippets/servers/lowlevel/structured_output.py --> +```python +""" +Run from the repository root: + uv run examples/snippets/servers/lowlevel/structured_output.py +""" + +import asyncio +from typing import Any + +import mcp.server.stdio +import mcp.types as types +from mcp.server.lowlevel import NotificationOptions, Server +from mcp.server.models import InitializationOptions + +server = Server("example-server") + + +@server.list_tools() +async def list_tools() -> list[types.Tool]: + """List available tools with structured output schemas.""" + return [ + types.Tool( + name="get_weather", + description="Get current weather for a city", + inputSchema={ + "type": "object", + "properties": {"city": {"type": "string", "description": "City name"}}, + "required": ["city"], + }, + outputSchema={ + "type": "object", + "properties": { + "temperature": {"type": "number", "description": "Temperature in Celsius"}, + "condition": {"type": "string", "description": "Weather condition"}, + "humidity": {"type": "number", "description": "Humidity percentage"}, + "city": {"type": "string", "description": "City name"}, + }, + "required": ["temperature", "condition", "humidity", "city"], + }, + ) + ] + + +@server.call_tool() +async def call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]: + """Handle tool calls with structured output.""" + if name == "get_weather": + city = arguments["city"] + + # Simulated weather data - in production, call a weather API + weather_data = { + "temperature": 22.5, + "condition": "partly cloudy", + "humidity": 65, + "city": city, # Include the requested city + } + + # low-level server will validate structured output against the tool's + # output schema, and additionally serialize it into a TextContent block + # for backwards compatibility with pre-2025-06-18 clients. + return weather_data + else: + raise ValueError(f"Unknown tool: {name}") + + +async def run(): + """Run the structured output server.""" + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + InitializationOptions( + server_name="structured-output-example", + server_version="0.1.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) + + +if __name__ == "__main__": + asyncio.run(run()) +``` + +_Full example: [examples/snippets/servers/lowlevel/structured_output.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lowlevel/structured_output.py)_ +<!-- /snippet-source --> + +Tools can return data in four ways: + +1. **Content only**: Return a list of content blocks (default behavior before spec revision 2025-06-18) +2. **Structured data only**: Return a dictionary that will be serialized to JSON (Introduced in spec revision 2025-06-18) +3. **Both**: Return a tuple of (content, structured_data) preferred option to use for backwards compatibility +4. **Direct CallToolResult**: Return `CallToolResult` directly for full control (including `_meta` field) + +When an `outputSchema` is defined, the server automatically validates the structured output against the schema. This ensures type safety and helps catch errors early. + +##### Returning CallToolResult Directly + +For full control over the response including the `_meta` field (for passing data to client applications without exposing it to the model), return `CallToolResult` directly: + +<!-- snippet-source examples/snippets/servers/lowlevel/direct_call_tool_result.py --> +```python +""" +Run from the repository root: + uv run examples/snippets/servers/lowlevel/direct_call_tool_result.py +""" + +import asyncio +from typing import Any + +import mcp.server.stdio +import mcp.types as types +from mcp.server.lowlevel import NotificationOptions, Server +from mcp.server.models import InitializationOptions + +server = Server("example-server") + + +@server.list_tools() +async def list_tools() -> list[types.Tool]: + """List available tools.""" + return [ + types.Tool( + name="advanced_tool", + description="Tool with full control including _meta field", + inputSchema={ + "type": "object", + "properties": {"message": {"type": "string"}}, + "required": ["message"], + }, + ) + ] + + +@server.call_tool() +async def handle_call_tool(name: str, arguments: dict[str, Any]) -> types.CallToolResult: + """Handle tool calls by returning CallToolResult directly.""" + if name == "advanced_tool": + message = str(arguments.get("message", "")) + return types.CallToolResult( + content=[types.TextContent(type="text", text=f"Processed: {message}")], + structuredContent={"result": "success", "message": message}, + _meta={"hidden": "data for client applications only"}, + ) + + raise ValueError(f"Unknown tool: {name}") + + +async def run(): + """Run the server.""" + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + InitializationOptions( + server_name="example", + server_version="0.1.0", + capabilities=server.get_capabilities( + notification_options=NotificationOptions(), + experimental_capabilities={}, + ), + ), + ) + + +if __name__ == "__main__": + asyncio.run(run()) +``` + +_Full example: [examples/snippets/servers/lowlevel/direct_call_tool_result.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lowlevel/direct_call_tool_result.py)_ +<!-- /snippet-source --> + +**Note:** When returning `CallToolResult`, you bypass the automatic content/structured conversion. You must construct the complete response yourself. + +### Pagination (Advanced) + +For servers that need to handle large datasets, the low-level server provides paginated versions of list operations. This is an optional optimization - most servers won't need pagination unless they're dealing with hundreds or thousands of items. + +#### Server-side Implementation + +<!-- snippet-source examples/snippets/servers/pagination_example.py --> +```python +""" +Example of implementing pagination with MCP server decorators. +""" + +from pydantic import AnyUrl + +import mcp.types as types +from mcp.server.lowlevel import Server + +# Initialize the server +server = Server("paginated-server") + +# Sample data to paginate +ITEMS = [f"Item {i}" for i in range(1, 101)] # 100 items + + +@server.list_resources() +async def list_resources_paginated(request: types.ListResourcesRequest) -> types.ListResourcesResult: + """List resources with pagination support.""" + page_size = 10 + + # Extract cursor from request params + cursor = request.params.cursor if request.params is not None else None + + # Parse cursor to get offset + start = 0 if cursor is None else int(cursor) + end = start + page_size + + # Get page of resources + page_items = [ + types.Resource(uri=AnyUrl(f"resource://items/{item}"), name=item, description=f"Description for {item}") + for item in ITEMS[start:end] + ] + + # Determine next cursor + next_cursor = str(end) if end < len(ITEMS) else None + + return types.ListResourcesResult(resources=page_items, nextCursor=next_cursor) +``` + +_Full example: [examples/snippets/servers/pagination_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/pagination_example.py)_ +<!-- /snippet-source --> + +#### Client-side Consumption + +<!-- snippet-source examples/snippets/clients/pagination_client.py --> +```python +""" +Example of consuming paginated MCP endpoints from a client. +""" + +import asyncio + +from mcp.client.session import ClientSession +from mcp.client.stdio import StdioServerParameters, stdio_client +from mcp.types import PaginatedRequestParams, Resource + + +async def list_all_resources() -> None: + """Fetch all resources using pagination.""" + async with stdio_client(StdioServerParameters(command="uv", args=["run", "mcp-simple-pagination"])) as ( + read, + write, + ): + async with ClientSession(read, write) as session: + await session.initialize() + + all_resources: list[Resource] = [] + cursor = None + + while True: + # Fetch a page of resources + result = await session.list_resources(params=PaginatedRequestParams(cursor=cursor)) + all_resources.extend(result.resources) + + print(f"Fetched {len(result.resources)} resources") + + # Check if there are more pages + if result.nextCursor: + cursor = result.nextCursor + else: + break + + print(f"Total resources: {len(all_resources)}") + + +if __name__ == "__main__": + asyncio.run(list_all_resources()) +``` + +_Full example: [examples/snippets/clients/pagination_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/pagination_client.py)_ +<!-- /snippet-source --> + +#### Key Points + +- **Cursors are opaque strings** - the server defines the format (numeric offsets, timestamps, etc.) +- **Return `nextCursor=None`** when there are no more pages +- **Backward compatible** - clients that don't support pagination will still work (they'll just get the first page) +- **Flexible page sizes** - Each endpoint can define its own page size based on data characteristics + +See the [simple-pagination example](examples/servers/simple-pagination) for a complete implementation. + +### Writing MCP Clients + +The SDK provides a high-level client interface for connecting to MCP servers using various [transports](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports): + +<!-- snippet-source examples/snippets/clients/stdio_client.py --> +```python +""" +cd to the `examples/snippets/clients` directory and run: + uv run client +""" + +import asyncio +import os + +from pydantic import AnyUrl + +from mcp import ClientSession, StdioServerParameters, types +from mcp.client.stdio import stdio_client +from mcp.shared.context import RequestContext + +# Create server parameters for stdio connection +server_params = StdioServerParameters( + command="uv", # Using uv to run the server + args=["run", "server", "fastmcp_quickstart", "stdio"], # We're already in snippets dir + env={"UV_INDEX": os.environ.get("UV_INDEX", "")}, +) + + +# Optional: create a sampling callback +async def handle_sampling_message( + context: RequestContext[ClientSession, None], params: types.CreateMessageRequestParams +) -> types.CreateMessageResult: + print(f"Sampling request: {params.messages}") + return types.CreateMessageResult( + role="assistant", + content=types.TextContent( + type="text", + text="Hello, world! from model", + ), + model="gpt-3.5-turbo", + stopReason="endTurn", + ) + + +async def run(): + async with stdio_client(server_params) as (read, write): + async with ClientSession(read, write, sampling_callback=handle_sampling_message) as session: + # Initialize the connection + await session.initialize() + + # List available prompts + prompts = await session.list_prompts() + print(f"Available prompts: {[p.name for p in prompts.prompts]}") + + # Get a prompt (greet_user prompt from fastmcp_quickstart) + if prompts.prompts: + prompt = await session.get_prompt("greet_user", arguments={"name": "Alice", "style": "friendly"}) + print(f"Prompt result: {prompt.messages[0].content}") + + # List available resources + resources = await session.list_resources() + print(f"Available resources: {[r.uri for r in resources.resources]}") + + # List available tools + tools = await session.list_tools() + print(f"Available tools: {[t.name for t in tools.tools]}") + + # Read a resource (greeting resource from fastmcp_quickstart) + resource_content = await session.read_resource(AnyUrl("greeting://World")) + content_block = resource_content.contents[0] + if isinstance(content_block, types.TextContent): + print(f"Resource content: {content_block.text}") + + # Call a tool (add tool from fastmcp_quickstart) + result = await session.call_tool("add", arguments={"a": 5, "b": 3}) + result_unstructured = result.content[0] + if isinstance(result_unstructured, types.TextContent): + print(f"Tool result: {result_unstructured.text}") + result_structured = result.structuredContent + print(f"Structured tool result: {result_structured}") + + +def main(): + """Entry point for the client script.""" + asyncio.run(run()) + + +if __name__ == "__main__": + main() +``` + +_Full example: [examples/snippets/clients/stdio_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/stdio_client.py)_ +<!-- /snippet-source --> + +Clients can also connect using [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http): + +<!-- snippet-source examples/snippets/clients/streamable_basic.py --> +```python +""" +Run from the repository root: + uv run examples/snippets/clients/streamable_basic.py +""" + +import asyncio + +from mcp import ClientSession +from mcp.client.streamable_http import streamable_http_client + + +async def main(): + # Connect to a streamable HTTP server + async with streamable_http_client("http://localhost:8000/mcp") as ( + read_stream, + write_stream, + _, + ): + # Create a session using the client streams + async with ClientSession(read_stream, write_stream) as session: + # Initialize the connection + await session.initialize() + # List available tools + tools = await session.list_tools() + print(f"Available tools: {[tool.name for tool in tools.tools]}") + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +_Full example: [examples/snippets/clients/streamable_basic.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/streamable_basic.py)_ +<!-- /snippet-source --> + +### Client Display Utilities + +When building MCP clients, the SDK provides utilities to help display human-readable names for tools, resources, and prompts: + +<!-- snippet-source examples/snippets/clients/display_utilities.py --> +```python +""" +cd to the `examples/snippets` directory and run: + uv run display-utilities-client +""" + +import asyncio +import os + +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client +from mcp.shared.metadata_utils import get_display_name + +# Create server parameters for stdio connection +server_params = StdioServerParameters( + command="uv", # Using uv to run the server + args=["run", "server", "fastmcp_quickstart", "stdio"], + env={"UV_INDEX": os.environ.get("UV_INDEX", "")}, +) + + +async def display_tools(session: ClientSession): + """Display available tools with human-readable names""" + tools_response = await session.list_tools() + + for tool in tools_response.tools: + # get_display_name() returns the title if available, otherwise the name + display_name = get_display_name(tool) + print(f"Tool: {display_name}") + if tool.description: + print(f" {tool.description}") + + +async def display_resources(session: ClientSession): + """Display available resources with human-readable names""" + resources_response = await session.list_resources() + + for resource in resources_response.resources: + display_name = get_display_name(resource) + print(f"Resource: {display_name} ({resource.uri})") + + templates_response = await session.list_resource_templates() + for template in templates_response.resourceTemplates: + display_name = get_display_name(template) + print(f"Resource Template: {display_name}") + + +async def run(): + """Run the display utilities example.""" + async with stdio_client(server_params) as (read, write): + async with ClientSession(read, write) as session: + # Initialize the connection + await session.initialize() + + print("=== Available Tools ===") + await display_tools(session) + + print("\n=== Available Resources ===") + await display_resources(session) + + +def main(): + """Entry point for the display utilities client.""" + asyncio.run(run()) + + +if __name__ == "__main__": + main() +``` + +_Full example: [examples/snippets/clients/display_utilities.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/display_utilities.py)_ +<!-- /snippet-source --> + +The `get_display_name()` function implements the proper precedence rules for displaying names: + +- For tools: `title` > `annotations.title` > `name` +- For other objects: `title` > `name` + +This ensures your client UI shows the most user-friendly names that servers provide. + +### OAuth Authentication for Clients + +The SDK includes [authorization support](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization) for connecting to protected MCP servers: + +<!-- snippet-source examples/snippets/clients/oauth_client.py --> +```python +""" +Before running, specify running MCP RS server URL. +To spin up RS server locally, see + examples/servers/simple-auth/README.md + +cd to the `examples/snippets` directory and run: + uv run oauth-client +""" + +import asyncio +from urllib.parse import parse_qs, urlparse + +import httpx +from pydantic import AnyUrl + +from mcp import ClientSession +from mcp.client.auth import OAuthClientProvider, TokenStorage +from mcp.client.streamable_http import streamable_http_client +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken + + +class InMemoryTokenStorage(TokenStorage): + """Demo In-memory token storage implementation.""" + + def __init__(self): + self.tokens: OAuthToken | None = None + self.client_info: OAuthClientInformationFull | None = None + + async def get_tokens(self) -> OAuthToken | None: + """Get stored tokens.""" + return self.tokens + + async def set_tokens(self, tokens: OAuthToken) -> None: + """Store tokens.""" + self.tokens = tokens + + async def get_client_info(self) -> OAuthClientInformationFull | None: + """Get stored client information.""" + return self.client_info + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + """Store client information.""" + self.client_info = client_info + + +async def handle_redirect(auth_url: str) -> None: + print(f"Visit: {auth_url}") + + +async def handle_callback() -> tuple[str, str | None]: + callback_url = input("Paste callback URL: ") + params = parse_qs(urlparse(callback_url).query) + return params["code"][0], params.get("state", [None])[0] + + +async def main(): + """Run the OAuth client example.""" + oauth_auth = OAuthClientProvider( + server_url="http://localhost:8001", + client_metadata=OAuthClientMetadata( + client_name="Example MCP Client", + redirect_uris=[AnyUrl("http://localhost:3000/callback")], + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + scope="user", + ), + storage=InMemoryTokenStorage(), + redirect_handler=handle_redirect, + callback_handler=handle_callback, + ) + + async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client: + async with streamable_http_client("http://localhost:8001/mcp", http_client=custom_client) as (read, write, _): + async with ClientSession(read, write) as session: + await session.initialize() + + tools = await session.list_tools() + print(f"Available tools: {[tool.name for tool in tools.tools]}") + + resources = await session.list_resources() + print(f"Available resources: {[r.uri for r in resources.resources]}") + + +def run(): + asyncio.run(main()) + + +if __name__ == "__main__": + run() +``` + +_Full example: [examples/snippets/clients/oauth_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/oauth_client.py)_ +<!-- /snippet-source --> + +For a complete working example, see [`examples/clients/simple-auth-client/`](examples/clients/simple-auth-client/). + +### Parsing Tool Results + +When calling tools through MCP, the `CallToolResult` object contains the tool's response in a structured format. Understanding how to parse this result is essential for properly handling tool outputs. + +```python +"""examples/snippets/clients/parsing_tool_results.py""" + +import asyncio + +from mcp import ClientSession, StdioServerParameters, types +from mcp.client.stdio import stdio_client + + +async def parse_tool_results(): + """Demonstrates how to parse different types of content in CallToolResult.""" + server_params = StdioServerParameters( + command="python", args=["path/to/mcp_server.py"] + ) + + async with stdio_client(server_params) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + + # Example 1: Parsing text content + result = await session.call_tool("get_data", {"format": "text"}) + for content in result.content: + if isinstance(content, types.TextContent): + print(f"Text: {content.text}") + + # Example 2: Parsing structured content from JSON tools + result = await session.call_tool("get_user", {"id": "123"}) + if hasattr(result, "structuredContent") and result.structuredContent: + # Access structured data directly + user_data = result.structuredContent + print(f"User: {user_data.get('name')}, Age: {user_data.get('age')}") + + # Example 3: Parsing embedded resources + result = await session.call_tool("read_config", {}) + for content in result.content: + if isinstance(content, types.EmbeddedResource): + resource = content.resource + if isinstance(resource, types.TextResourceContents): + print(f"Config from {resource.uri}: {resource.text}") + elif isinstance(resource, types.BlobResourceContents): + print(f"Binary data from {resource.uri}") + + # Example 4: Parsing image content + result = await session.call_tool("generate_chart", {"data": [1, 2, 3]}) + for content in result.content: + if isinstance(content, types.ImageContent): + print(f"Image ({content.mimeType}): {len(content.data)} bytes") + + # Example 5: Handling errors + result = await session.call_tool("failing_tool", {}) + if result.isError: + print("Tool execution failed!") + for content in result.content: + if isinstance(content, types.TextContent): + print(f"Error: {content.text}") + + +async def main(): + await parse_tool_results() + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +### MCP Primitives + +The MCP protocol defines three core primitives that servers can implement: + +| Primitive | Control | Description | Example Use | +|-----------|-----------------------|-----------------------------------------------------|------------------------------| +| Prompts | User-controlled | Interactive templates invoked by user choice | Slash commands, menu options | +| Resources | Application-controlled| Contextual data managed by the client application | File contents, API responses | +| Tools | Model-controlled | Functions exposed to the LLM to take actions | API calls, data updates | + +### Server Capabilities + +MCP servers declare capabilities during initialization: + +| Capability | Feature Flag | Description | +|--------------|------------------------------|------------------------------------| +| `prompts` | `listChanged` | Prompt template management | +| `resources` | `subscribe`<br/>`listChanged`| Resource exposure and updates | +| `tools` | `listChanged` | Tool discovery and execution | +| `logging` | - | Server logging configuration | +| `completions`| - | Argument completion suggestions | + +## Documentation + +- [API Reference](https://modelcontextprotocol.github.io/python-sdk/api/) +- [Experimental Features (Tasks)](https://modelcontextprotocol.github.io/python-sdk/experimental/tasks/) +- [Model Context Protocol documentation](https://modelcontextprotocol.io) +- [Model Context Protocol specification](https://modelcontextprotocol.io/specification/latest) +- [Officially supported servers](https://github.com/modelcontextprotocol/servers) + +## Contributing + +We are passionate about supporting contributors of all levels of experience and would love to see you get involved in the project. See the [contributing guide](CONTRIBUTING.md) to get started. + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. diff --git a/README.v2.md b/README.v2.md index d0851c04e..475f6e143 100644 --- a/README.v2.md +++ b/README.v2.md @@ -1,2503 +1,2503 @@ -# MCP Python SDK - -<div align="center"> - -<strong>Python implementation of the Model Context Protocol (MCP)</strong> - -[![PyPI][pypi-badge]][pypi-url] -[![MIT licensed][mit-badge]][mit-url] -[![Python Version][python-badge]][python-url] -[![Documentation][docs-badge]][docs-url] -[![Protocol][protocol-badge]][protocol-url] -[![Specification][spec-badge]][spec-url] - -</div> - -<!-- TODO(v2): Move this content back to README.md when v2 is released --> - -> [!IMPORTANT] -> **This documents v2 of the SDK (currently in development, pre-alpha on `main`).** -> -> We anticipate a stable v2 release in Q1 2026. Until then, **v1.x remains the recommended version** for production use. v1.x will continue to receive bug fixes and security updates for at least 6 months after v2 ships to give people time to upgrade. -> -> For v1 documentation (the current stable release), see [`README.md`](README.md). - -<!-- omit in toc --> -## Table of Contents - -- [MCP Python SDK](#mcp-python-sdk) - - [Overview](#overview) - - [Installation](#installation) - - [Adding MCP to your python project](#adding-mcp-to-your-python-project) - - [Running the standalone MCP development tools](#running-the-standalone-mcp-development-tools) - - [Quickstart](#quickstart) - - [What is MCP?](#what-is-mcp) - - [Core Concepts](#core-concepts) - - [Server](#server) - - [Resources](#resources) - - [Tools](#tools) - - [Structured Output](#structured-output) - - [Prompts](#prompts) - - [Images](#images) - - [Context](#context) - - [Getting Context in Functions](#getting-context-in-functions) - - [Context Properties and Methods](#context-properties-and-methods) - - [Completions](#completions) - - [Elicitation](#elicitation) - - [Sampling](#sampling) - - [Logging and Notifications](#logging-and-notifications) - - [Authentication](#authentication) - - [MCPServer Properties](#mcpserver-properties) - - [Session Properties and Methods](#session-properties-and-methods) - - [Request Context Properties](#request-context-properties) - - [Running Your Server](#running-your-server) - - [Development Mode](#development-mode) - - [Claude Desktop Integration](#claude-desktop-integration) - - [Direct Execution](#direct-execution) - - [Streamable HTTP Transport](#streamable-http-transport) - - [CORS Configuration for Browser-Based Clients](#cors-configuration-for-browser-based-clients) - - [Mounting to an Existing ASGI Server](#mounting-to-an-existing-asgi-server) - - [StreamableHTTP servers](#streamablehttp-servers) - - [Basic mounting](#basic-mounting) - - [Host-based routing](#host-based-routing) - - [Multiple servers with path configuration](#multiple-servers-with-path-configuration) - - [Path configuration at initialization](#path-configuration-at-initialization) - - [SSE servers](#sse-servers) - - [Advanced Usage](#advanced-usage) - - [Low-Level Server](#low-level-server) - - [Structured Output Support](#structured-output-support) - - [Pagination (Advanced)](#pagination-advanced) - - [Writing MCP Clients](#writing-mcp-clients) - - [Client Display Utilities](#client-display-utilities) - - [OAuth Authentication for Clients](#oauth-authentication-for-clients) - - [Parsing Tool Results](#parsing-tool-results) - - [MCP Primitives](#mcp-primitives) - - [Server Capabilities](#server-capabilities) - - [Documentation](#documentation) - - [Contributing](#contributing) - - [License](#license) - -[pypi-badge]: https://img.shields.io/pypi/v/mcp.svg -[pypi-url]: https://pypi.org/project/mcp/ -[mit-badge]: https://img.shields.io/pypi/l/mcp.svg -[mit-url]: https://github.com/modelcontextprotocol/python-sdk/blob/main/LICENSE -[python-badge]: https://img.shields.io/pypi/pyversions/mcp.svg -[python-url]: https://www.python.org/downloads/ -[docs-badge]: https://img.shields.io/badge/docs-python--sdk-blue.svg -[docs-url]: https://modelcontextprotocol.github.io/python-sdk/ -[protocol-badge]: https://img.shields.io/badge/protocol-modelcontextprotocol.io-blue.svg -[protocol-url]: https://modelcontextprotocol.io -[spec-badge]: https://img.shields.io/badge/spec-spec.modelcontextprotocol.io-blue.svg -[spec-url]: https://modelcontextprotocol.io/specification/latest - -## Overview - -The Model Context Protocol allows applications to provide context for LLMs in a standardized way, separating the concerns of providing context from the actual LLM interaction. This Python SDK implements the full MCP specification, making it easy to: - -- Build MCP clients that can connect to any MCP server -- Create MCP servers that expose resources, prompts and tools -- Use standard transports like stdio, SSE, and Streamable HTTP -- Handle all MCP protocol messages and lifecycle events - -## Installation - -### Adding MCP to your python project - -We recommend using [uv](https://docs.astral.sh/uv/) to manage your Python projects. - -If you haven't created a uv-managed project yet, create one: - - ```bash - uv init mcp-server-demo - cd mcp-server-demo - ``` - - Then add MCP to your project dependencies: - - ```bash - uv add "mcp[cli]" - ``` - -Alternatively, for projects using pip for dependencies: - -```bash -pip install "mcp[cli]" -``` - -### Running the standalone MCP development tools - -To run the mcp command with uv: - -```bash -uv run mcp -``` - -## Quickstart - -Let's create a simple MCP server that exposes a calculator tool and some data: - -<!-- snippet-source examples/snippets/servers/mcpserver_quickstart.py --> -```python -"""MCPServer quickstart example. - -Run from the repository root: - uv run examples/snippets/servers/mcpserver_quickstart.py -""" - -from mcp.server.mcpserver import MCPServer - -# Create an MCP server -mcp = MCPServer("Demo") - - -# Add an addition tool -@mcp.tool() -def add(a: int, b: int) -> int: - """Add two numbers""" - return a + b - - -# Add a dynamic greeting resource -@mcp.resource("greeting://{name}") -def get_greeting(name: str) -> str: - """Get a personalized greeting""" - return f"Hello, {name}!" - - -# Add a prompt -@mcp.prompt() -def greet_user(name: str, style: str = "friendly") -> str: - """Generate a greeting prompt""" - styles = { - "friendly": "Please write a warm, friendly greeting", - "formal": "Please write a formal, professional greeting", - "casual": "Please write a casual, relaxed greeting", - } - - return f"{styles.get(style, styles['friendly'])} for someone named {name}." - - -# Run with streamable HTTP transport -if __name__ == "__main__": - mcp.run(transport="streamable-http", json_response=True) -``` - -_Full example: [examples/snippets/servers/mcpserver_quickstart.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/mcpserver_quickstart.py)_ -<!-- /snippet-source --> - -You can install this server in [Claude Code](https://docs.claude.com/en/docs/claude-code/mcp) and interact with it right away. First, run the server: - -```bash -uv run --with mcp examples/snippets/servers/mcpserver_quickstart.py -``` - -Then add it to Claude Code: - -```bash -claude mcp add --transport http my-server http://localhost:8000/mcp -``` - -Alternatively, you can test it with the MCP Inspector. Start the server as above, then in a separate terminal: - -```bash -npx -y @modelcontextprotocol/inspector -``` - -In the inspector UI, connect to `http://localhost:8000/mcp`. - -## What is MCP? - -The [Model Context Protocol (MCP)](https://modelcontextprotocol.io) lets you build servers that expose data and functionality to LLM applications in a secure, standardized way. Think of it like a web API, but specifically designed for LLM interactions. MCP servers can: - -- Expose data through **Resources** (think of these sort of like GET endpoints; they are used to load information into the LLM's context) -- Provide functionality through **Tools** (sort of like POST endpoints; they are used to execute code or otherwise produce a side effect) -- Define interaction patterns through **Prompts** (reusable templates for LLM interactions) -- And more! - -## Core Concepts - -### Server - -The MCPServer server is your core interface to the MCP protocol. It handles connection management, protocol compliance, and message routing: - -<!-- snippet-source examples/snippets/servers/lifespan_example.py --> -```python -"""Example showing lifespan support for startup/shutdown with strong typing.""" - -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from dataclasses import dataclass - -from mcp.server.mcpserver import Context, MCPServer - - -# Mock database class for example -class Database: - """Mock database class for example.""" - - @classmethod - async def connect(cls) -> "Database": - """Connect to database.""" - return cls() - - async def disconnect(self) -> None: - """Disconnect from database.""" - pass - - def query(self) -> str: - """Execute a query.""" - return "Query result" - - -@dataclass -class AppContext: - """Application context with typed dependencies.""" - - db: Database - - -@asynccontextmanager -async def app_lifespan(server: MCPServer) -> AsyncIterator[AppContext]: - """Manage application lifecycle with type-safe context.""" - # Initialize on startup - db = await Database.connect() - try: - yield AppContext(db=db) - finally: - # Cleanup on shutdown - await db.disconnect() - - -# Pass lifespan to server -mcp = MCPServer("My App", lifespan=app_lifespan) - - -# Access type-safe lifespan context in tools -@mcp.tool() -def query_db(ctx: Context[AppContext]) -> str: - """Tool that uses initialized resources.""" - db = ctx.request_context.lifespan_context.db - return db.query() -``` - -_Full example: [examples/snippets/servers/lifespan_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lifespan_example.py)_ -<!-- /snippet-source --> - -### Resources - -Resources are how you expose data to LLMs. They're similar to GET endpoints in a REST API - they provide data but shouldn't perform significant computation or have side effects: - -<!-- snippet-source examples/snippets/servers/basic_resource.py --> -```python -from mcp.server.mcpserver import MCPServer - -mcp = MCPServer(name="Resource Example") - - -@mcp.resource("file://documents/{name}") -def read_document(name: str) -> str: - """Read a document by name.""" - # This would normally read from disk - return f"Content of {name}" - - -@mcp.resource("config://settings") -def get_settings() -> str: - """Get application settings.""" - return """{ - "theme": "dark", - "language": "en", - "debug": false -}""" -``` - -_Full example: [examples/snippets/servers/basic_resource.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/basic_resource.py)_ -<!-- /snippet-source --> - -### Tools - -Tools let LLMs take actions through your server. Unlike resources, tools are expected to perform computation and have side effects: - -<!-- snippet-source examples/snippets/servers/basic_tool.py --> -```python -from mcp.server.mcpserver import MCPServer - -mcp = MCPServer(name="Tool Example") - - -@mcp.tool() -def sum(a: int, b: int) -> int: - """Add two numbers together.""" - return a + b - - -@mcp.tool() -def get_weather(city: str, unit: str = "celsius") -> str: - """Get weather for a city.""" - # This would normally call a weather API - return f"Weather in {city}: 22degrees{unit[0].upper()}" -``` - -_Full example: [examples/snippets/servers/basic_tool.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/basic_tool.py)_ -<!-- /snippet-source --> - -Tools can optionally receive a Context object by including a parameter with the `Context` type annotation. This context is automatically injected by the MCPServer framework and provides access to MCP capabilities: - -<!-- snippet-source examples/snippets/servers/tool_progress.py --> -```python -from mcp.server.mcpserver import Context, MCPServer - -mcp = MCPServer(name="Progress Example") - - -@mcp.tool() -async def long_running_task(task_name: str, ctx: Context, steps: int = 5) -> str: - """Execute a task with progress updates.""" - await ctx.info(f"Starting: {task_name}") - - for i in range(steps): - progress = (i + 1) / steps - await ctx.report_progress( - progress=progress, - total=1.0, - message=f"Step {i + 1}/{steps}", - ) - await ctx.debug(f"Completed step {i + 1}") - - return f"Task '{task_name}' completed" -``` - -_Full example: [examples/snippets/servers/tool_progress.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/tool_progress.py)_ -<!-- /snippet-source --> - -#### Structured Output - -Tools will return structured results by default, if their return type -annotation is compatible. Otherwise, they will return unstructured results. - -Structured output supports these return types: - -- Pydantic models (BaseModel subclasses) -- TypedDicts -- Dataclasses and other classes with type hints -- `dict[str, T]` (where T is any JSON-serializable type) -- Primitive types (str, int, float, bool, bytes, None) - wrapped in `{"result": value}` -- Generic types (list, tuple, Union, Optional, etc.) - wrapped in `{"result": value}` - -Classes without type hints cannot be serialized for structured output. Only -classes with properly annotated attributes will be converted to Pydantic models -for schema generation and validation. - -Structured results are automatically validated against the output schema -generated from the annotation. This ensures the tool returns well-typed, -validated data that clients can easily process. - -**Note:** For backward compatibility, unstructured results are also -returned. Unstructured results are provided for backward compatibility -with previous versions of the MCP specification, and are quirks-compatible -with previous versions of MCPServer in the current version of the SDK. - -**Note:** In cases where a tool function's return type annotation -causes the tool to be classified as structured _and this is undesirable_, -the classification can be suppressed by passing `structured_output=False` -to the `@tool` decorator. - -##### Advanced: Direct CallToolResult - -For full control over tool responses including the `_meta` field (for passing data to client applications without exposing it to the model), you can return `CallToolResult` directly: - -<!-- snippet-source examples/snippets/servers/direct_call_tool_result.py --> -```python -"""Example showing direct CallToolResult return for advanced control.""" - -from typing import Annotated - -from pydantic import BaseModel - -from mcp.server.mcpserver import MCPServer -from mcp.types import CallToolResult, TextContent - -mcp = MCPServer("CallToolResult Example") - - -class ValidationModel(BaseModel): - """Model for validating structured output.""" - - status: str - data: dict[str, int] - - -@mcp.tool() -def advanced_tool() -> CallToolResult: - """Return CallToolResult directly for full control including _meta field.""" - return CallToolResult( - content=[TextContent(type="text", text="Response visible to the model")], - _meta={"hidden": "data for client applications only"}, - ) - - -@mcp.tool() -def validated_tool() -> Annotated[CallToolResult, ValidationModel]: - """Return CallToolResult with structured output validation.""" - return CallToolResult( - content=[TextContent(type="text", text="Validated response")], - structured_content={"status": "success", "data": {"result": 42}}, - _meta={"internal": "metadata"}, - ) - - -@mcp.tool() -def empty_result_tool() -> CallToolResult: - """For empty results, return CallToolResult with empty content.""" - return CallToolResult(content=[]) -``` - -_Full example: [examples/snippets/servers/direct_call_tool_result.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/direct_call_tool_result.py)_ -<!-- /snippet-source --> - -**Important:** `CallToolResult` must always be returned (no `Optional` or `Union`). For empty results, use `CallToolResult(content=[])`. For optional simple types, use `str | None` without `CallToolResult`. - -<!-- snippet-source examples/snippets/servers/structured_output.py --> -```python -"""Example showing structured output with tools.""" - -from typing import TypedDict - -from pydantic import BaseModel, Field - -from mcp.server.mcpserver import MCPServer - -mcp = MCPServer("Structured Output Example") - - -# Using Pydantic models for rich structured data -class WeatherData(BaseModel): - """Weather information structure.""" - - temperature: float = Field(description="Temperature in Celsius") - humidity: float = Field(description="Humidity percentage") - condition: str - wind_speed: float - - -@mcp.tool() -def get_weather(city: str) -> WeatherData: - """Get weather for a city - returns structured data.""" - # Simulated weather data - return WeatherData( - temperature=22.5, - humidity=45.0, - condition="sunny", - wind_speed=5.2, - ) - - -# Using TypedDict for simpler structures -class LocationInfo(TypedDict): - latitude: float - longitude: float - name: str - - -@mcp.tool() -def get_location(address: str) -> LocationInfo: - """Get location coordinates""" - return LocationInfo(latitude=51.5074, longitude=-0.1278, name="London, UK") - - -# Using dict[str, Any] for flexible schemas -@mcp.tool() -def get_statistics(data_type: str) -> dict[str, float]: - """Get various statistics""" - return {"mean": 42.5, "median": 40.0, "std_dev": 5.2} - - -# Ordinary classes with type hints work for structured output -class UserProfile: - name: str - age: int - email: str | None = None - - def __init__(self, name: str, age: int, email: str | None = None): - self.name = name - self.age = age - self.email = email - - -@mcp.tool() -def get_user(user_id: str) -> UserProfile: - """Get user profile - returns structured data""" - return UserProfile(name="Alice", age=30, email="alice@example.com") - - -# Classes WITHOUT type hints cannot be used for structured output -class UntypedConfig: - def __init__(self, setting1, setting2): # type: ignore[reportMissingParameterType] - self.setting1 = setting1 - self.setting2 = setting2 - - -@mcp.tool() -def get_config() -> UntypedConfig: - """This returns unstructured output - no schema generated""" - return UntypedConfig("value1", "value2") - - -# Lists and other types are wrapped automatically -@mcp.tool() -def list_cities() -> list[str]: - """Get a list of cities""" - return ["London", "Paris", "Tokyo"] - # Returns: {"result": ["London", "Paris", "Tokyo"]} - - -@mcp.tool() -def get_temperature(city: str) -> float: - """Get temperature as a simple float""" - return 22.5 - # Returns: {"result": 22.5} -``` - -_Full example: [examples/snippets/servers/structured_output.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/structured_output.py)_ -<!-- /snippet-source --> - -### Prompts - -Prompts are reusable templates that help LLMs interact with your server effectively: - -<!-- snippet-source examples/snippets/servers/basic_prompt.py --> -```python -from mcp.server.mcpserver import MCPServer -from mcp.server.mcpserver.prompts import base - -mcp = MCPServer(name="Prompt Example") - - -@mcp.prompt(title="Code Review") -def review_code(code: str) -> str: - return f"Please review this code:\n\n{code}" - - -@mcp.prompt(title="Debug Assistant") -def debug_error(error: str) -> list[base.Message]: - return [ - base.UserMessage("I'm seeing this error:"), - base.UserMessage(error), - base.AssistantMessage("I'll help debug that. What have you tried so far?"), - ] -``` - -_Full example: [examples/snippets/servers/basic_prompt.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/basic_prompt.py)_ -<!-- /snippet-source --> - -### Icons - -MCP servers can provide icons for UI display. Icons can be added to the server implementation, tools, resources, and prompts: - -```python -from mcp.server.mcpserver import MCPServer, Icon - -# Create an icon from a file path or URL -icon = Icon( - src="icon.png", - mimeType="image/png", - sizes="64x64" -) - -# Add icons to server -mcp = MCPServer( - "My Server", - website_url="https://example.com", - icons=[icon] -) - -# Add icons to tools, resources, and prompts -@mcp.tool(icons=[icon]) -def my_tool(): - """Tool with an icon.""" - return "result" - -@mcp.resource("demo://resource", icons=[icon]) -def my_resource(): - """Resource with an icon.""" - return "content" -``` - -_Full example: [examples/mcpserver/icons_demo.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/mcpserver/icons_demo.py)_ - -### Images - -MCPServer provides an `Image` class that automatically handles image data: - -<!-- snippet-source examples/snippets/servers/images.py --> -```python -"""Example showing image handling with MCPServer.""" - -from PIL import Image as PILImage - -from mcp.server.mcpserver import Image, MCPServer - -mcp = MCPServer("Image Example") - - -@mcp.tool() -def create_thumbnail(image_path: str) -> Image: - """Create a thumbnail from an image""" - img = PILImage.open(image_path) - img.thumbnail((100, 100)) - return Image(data=img.tobytes(), format="png") -``` - -_Full example: [examples/snippets/servers/images.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/images.py)_ -<!-- /snippet-source --> - -### Context - -The Context object is automatically injected into tool and resource functions that request it via type hints. It provides access to MCP capabilities like logging, progress reporting, resource reading, user interaction, and request metadata. - -#### Getting Context in Functions - -To use context in a tool or resource function, add a parameter with the `Context` type annotation: - -```python -from mcp.server.mcpserver import Context, MCPServer - -mcp = MCPServer(name="Context Example") - - -@mcp.tool() -async def my_tool(x: int, ctx: Context) -> str: - """Tool that uses context capabilities.""" - # The context parameter can have any name as long as it's type-annotated - return await process_with_context(x, ctx) -``` - -#### Context Properties and Methods - -The Context object provides the following capabilities: - -- `ctx.request_id` - Unique ID for the current request -- `ctx.client_id` - Client ID if available -- `ctx.mcp_server` - Access to the MCPServer server instance (see [MCPServer Properties](#mcpserver-properties)) -- `ctx.session` - Access to the underlying session for advanced communication (see [Session Properties and Methods](#session-properties-and-methods)) -- `ctx.request_context` - Access to request-specific data and lifespan resources (see [Request Context Properties](#request-context-properties)) -- `await ctx.debug(data)` - Send debug log message -- `await ctx.info(data)` - Send info log message -- `await ctx.warning(data)` - Send warning log message -- `await ctx.error(data)` - Send error log message -- `await ctx.log(level, data, logger_name=None)` - Send log with custom level -- `await ctx.report_progress(progress, total=None, message=None)` - Report operation progress -- `await ctx.read_resource(uri)` - Read a resource by URI -- `await ctx.elicit(message, schema)` - Request additional information from user with validation - -<!-- snippet-source examples/snippets/servers/tool_progress.py --> -```python -from mcp.server.mcpserver import Context, MCPServer - -mcp = MCPServer(name="Progress Example") - - -@mcp.tool() -async def long_running_task(task_name: str, ctx: Context, steps: int = 5) -> str: - """Execute a task with progress updates.""" - await ctx.info(f"Starting: {task_name}") - - for i in range(steps): - progress = (i + 1) / steps - await ctx.report_progress( - progress=progress, - total=1.0, - message=f"Step {i + 1}/{steps}", - ) - await ctx.debug(f"Completed step {i + 1}") - - return f"Task '{task_name}' completed" -``` - -_Full example: [examples/snippets/servers/tool_progress.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/tool_progress.py)_ -<!-- /snippet-source --> - -### Completions - -MCP supports providing completion suggestions for prompt arguments and resource template parameters. With the context parameter, servers can provide completions based on previously resolved values: - -Client usage: - -<!-- snippet-source examples/snippets/clients/completion_client.py --> -```python -"""cd to the `examples/snippets` directory and run: -uv run completion-client -""" - -import asyncio -import os - -from mcp import ClientSession, StdioServerParameters -from mcp.client.stdio import stdio_client -from mcp.types import PromptReference, ResourceTemplateReference - -# Create server parameters for stdio connection -server_params = StdioServerParameters( - command="uv", # Using uv to run the server - args=["run", "server", "completion", "stdio"], # Server with completion support - env={"UV_INDEX": os.environ.get("UV_INDEX", "")}, -) - - -async def run(): - """Run the completion client example.""" - async with stdio_client(server_params) as (read, write): - async with ClientSession(read, write) as session: - # Initialize the connection - await session.initialize() - - # List available resource templates - templates = await session.list_resource_templates() - print("Available resource templates:") - for template in templates.resource_templates: - print(f" - {template.uri_template}") - - # List available prompts - prompts = await session.list_prompts() - print("\nAvailable prompts:") - for prompt in prompts.prompts: - print(f" - {prompt.name}") - - # Complete resource template arguments - if templates.resource_templates: - template = templates.resource_templates[0] - print(f"\nCompleting arguments for resource template: {template.uri_template}") - - # Complete without context - result = await session.complete( - ref=ResourceTemplateReference(type="ref/resource", uri=template.uri_template), - argument={"name": "owner", "value": "model"}, - ) - print(f"Completions for 'owner' starting with 'model': {result.completion.values}") - - # Complete with context - repo suggestions based on owner - result = await session.complete( - ref=ResourceTemplateReference(type="ref/resource", uri=template.uri_template), - argument={"name": "repo", "value": ""}, - context_arguments={"owner": "modelcontextprotocol"}, - ) - print(f"Completions for 'repo' with owner='modelcontextprotocol': {result.completion.values}") - - # Complete prompt arguments - if prompts.prompts: - prompt_name = prompts.prompts[0].name - print(f"\nCompleting arguments for prompt: {prompt_name}") - - result = await session.complete( - ref=PromptReference(type="ref/prompt", name=prompt_name), - argument={"name": "style", "value": ""}, - ) - print(f"Completions for 'style' argument: {result.completion.values}") - - -def main(): - """Entry point for the completion client.""" - asyncio.run(run()) - - -if __name__ == "__main__": - main() -``` - -_Full example: [examples/snippets/clients/completion_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/completion_client.py)_ -<!-- /snippet-source --> -### Elicitation - -Request additional information from users. This example shows an Elicitation during a Tool Call: - -<!-- snippet-source examples/snippets/servers/elicitation.py --> -```python -"""Elicitation examples demonstrating form and URL mode elicitation. - -Form mode elicitation collects structured, non-sensitive data through a schema. -URL mode elicitation directs users to external URLs for sensitive operations -like OAuth flows, credential collection, or payment processing. -""" - -import uuid - -from pydantic import BaseModel, Field - -from mcp.server.mcpserver import Context, MCPServer -from mcp.shared.exceptions import UrlElicitationRequiredError -from mcp.types import ElicitRequestURLParams - -mcp = MCPServer(name="Elicitation Example") - - -class BookingPreferences(BaseModel): - """Schema for collecting user preferences.""" - - checkAlternative: bool = Field(description="Would you like to check another date?") - alternativeDate: str = Field( - default="2024-12-26", - description="Alternative date (YYYY-MM-DD)", - ) - - -@mcp.tool() -async def book_table(date: str, time: str, party_size: int, ctx: Context) -> str: - """Book a table with date availability check. - - This demonstrates form mode elicitation for collecting non-sensitive user input. - """ - # Check if date is available - if date == "2024-12-25": - # Date unavailable - ask user for alternative - result = await ctx.elicit( - message=(f"No tables available for {party_size} on {date}. Would you like to try another date?"), - schema=BookingPreferences, - ) - - if result.action == "accept" and result.data: - if result.data.checkAlternative: - return f"[SUCCESS] Booked for {result.data.alternativeDate}" - return "[CANCELLED] No booking made" - return "[CANCELLED] Booking cancelled" - - # Date available - return f"[SUCCESS] Booked for {date} at {time}" - - -@mcp.tool() -async def secure_payment(amount: float, ctx: Context) -> str: - """Process a secure payment requiring URL confirmation. - - This demonstrates URL mode elicitation using ctx.elicit_url() for - operations that require out-of-band user interaction. - """ - elicitation_id = str(uuid.uuid4()) - - result = await ctx.elicit_url( - message=f"Please confirm payment of ${amount:.2f}", - url=f"https://payments.example.com/confirm?amount={amount}&id={elicitation_id}", - elicitation_id=elicitation_id, - ) - - if result.action == "accept": - # In a real app, the payment confirmation would happen out-of-band - # and you'd verify the payment status from your backend - return f"Payment of ${amount:.2f} initiated - check your browser to complete" - elif result.action == "decline": - return "Payment declined by user" - return "Payment cancelled" - - -@mcp.tool() -async def connect_service(service_name: str, ctx: Context) -> str: - """Connect to a third-party service requiring OAuth authorization. - - This demonstrates the "throw error" pattern using UrlElicitationRequiredError. - Use this pattern when the tool cannot proceed without user authorization. - """ - elicitation_id = str(uuid.uuid4()) - - # Raise UrlElicitationRequiredError to signal that the client must complete - # a URL elicitation before this request can be processed. - # The MCP framework will convert this to a -32042 error response. - raise UrlElicitationRequiredError( - [ - ElicitRequestURLParams( - mode="url", - message=f"Authorization required to connect to {service_name}", - url=f"https://{service_name}.example.com/oauth/authorize?elicit={elicitation_id}", - elicitation_id=elicitation_id, - ) - ] - ) -``` - -_Full example: [examples/snippets/servers/elicitation.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/elicitation.py)_ -<!-- /snippet-source --> - -Elicitation schemas support default values for all field types. Default values are automatically included in the JSON schema sent to clients, allowing them to pre-populate forms. - -The `elicit()` method returns an `ElicitationResult` with: - -- `action`: "accept", "decline", or "cancel" -- `data`: The validated response (only when accepted) -- `validation_error`: Any validation error message - -### Sampling - -Tools can interact with LLMs through sampling (generating text): - -<!-- snippet-source examples/snippets/servers/sampling.py --> -```python -from mcp.server.mcpserver import Context, MCPServer -from mcp.types import SamplingMessage, TextContent - -mcp = MCPServer(name="Sampling Example") - - -@mcp.tool() -async def generate_poem(topic: str, ctx: Context) -> str: - """Generate a poem using LLM sampling.""" - prompt = f"Write a short poem about {topic}" - - result = await ctx.session.create_message( - messages=[ - SamplingMessage( - role="user", - content=TextContent(type="text", text=prompt), - ) - ], - max_tokens=100, - ) - - # Since we're not passing tools param, result.content is single content - if result.content.type == "text": - return result.content.text - return str(result.content) -``` - -_Full example: [examples/snippets/servers/sampling.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/sampling.py)_ -<!-- /snippet-source --> - -### Logging and Notifications - -Tools can send logs and notifications through the context: - -<!-- snippet-source examples/snippets/servers/notifications.py --> -```python -from mcp.server.mcpserver import Context, MCPServer - -mcp = MCPServer(name="Notifications Example") - - -@mcp.tool() -async def process_data(data: str, ctx: Context) -> str: - """Process data with logging.""" - # Different log levels - await ctx.debug(f"Debug: Processing '{data}'") - await ctx.info("Info: Starting processing") - await ctx.warning("Warning: This is experimental") - await ctx.error("Error: (This is just a demo)") - - # Notify about resource changes - await ctx.session.send_resource_list_changed() - - return f"Processed: {data}" -``` - -_Full example: [examples/snippets/servers/notifications.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/notifications.py)_ -<!-- /snippet-source --> - -### Authentication - -Authentication can be used by servers that want to expose tools accessing protected resources. - -`mcp.server.auth` implements OAuth 2.1 resource server functionality, where MCP servers act as Resource Servers (RS) that validate tokens issued by separate Authorization Servers (AS). This follows the [MCP authorization specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization) and implements RFC 9728 (Protected Resource Metadata) for AS discovery. - -MCP servers can use authentication by providing an implementation of the `TokenVerifier` protocol: - -<!-- snippet-source examples/snippets/servers/oauth_server.py --> -```python -"""Run from the repository root: -uv run examples/snippets/servers/oauth_server.py -""" - -from pydantic import AnyHttpUrl - -from mcp.server.auth.provider import AccessToken, TokenVerifier -from mcp.server.auth.settings import AuthSettings -from mcp.server.mcpserver import MCPServer - - -class SimpleTokenVerifier(TokenVerifier): - """Simple token verifier for demonstration.""" - - async def verify_token(self, token: str) -> AccessToken | None: - pass # This is where you would implement actual token validation - - -# Create MCPServer instance as a Resource Server -mcp = MCPServer( - "Weather Service", - # Token verifier for authentication - token_verifier=SimpleTokenVerifier(), - # Auth settings for RFC 9728 Protected Resource Metadata - auth=AuthSettings( - issuer_url=AnyHttpUrl("https://auth.example.com"), # Authorization Server URL - resource_server_url=AnyHttpUrl("http://localhost:3001"), # This server's URL - required_scopes=["user"], - ), -) - - -@mcp.tool() -async def get_weather(city: str = "London") -> dict[str, str]: - """Get weather data for a city""" - return { - "city": city, - "temperature": "22", - "condition": "Partly cloudy", - "humidity": "65%", - } - - -if __name__ == "__main__": - mcp.run(transport="streamable-http", json_response=True) -``` - -_Full example: [examples/snippets/servers/oauth_server.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/oauth_server.py)_ -<!-- /snippet-source --> - -For a complete example with separate Authorization Server and Resource Server implementations, see [`examples/servers/simple-auth/`](examples/servers/simple-auth/). - -**Architecture:** - -- **Authorization Server (AS)**: Handles OAuth flows, user authentication, and token issuance -- **Resource Server (RS)**: Your MCP server that validates tokens and serves protected resources -- **Client**: Discovers AS through RFC 9728, obtains tokens, and uses them with the MCP server - -See [TokenVerifier](src/mcp/server/auth/provider.py) for more details on implementing token validation. - -### MCPServer Properties - -The MCPServer server instance accessible via `ctx.mcp_server` provides access to server configuration and metadata: - -- `ctx.mcp_server.name` - The server's name as defined during initialization -- `ctx.mcp_server.instructions` - Server instructions/description provided to clients -- `ctx.mcp_server.website_url` - Optional website URL for the server -- `ctx.mcp_server.icons` - Optional list of icons for UI display -- `ctx.mcp_server.settings` - Complete server configuration object containing: - - `debug` - Debug mode flag - - `log_level` - Current logging level - - `host` and `port` - Server network configuration - - `sse_path`, `streamable_http_path` - Transport paths - - `stateless_http` - Whether the server operates in stateless mode - - And other configuration options - -```python -@mcp.tool() -def server_info(ctx: Context) -> dict: - """Get information about the current server.""" - return { - "name": ctx.mcp_server.name, - "instructions": ctx.mcp_server.instructions, - "debug_mode": ctx.mcp_server.settings.debug, - "log_level": ctx.mcp_server.settings.log_level, - "host": ctx.mcp_server.settings.host, - "port": ctx.mcp_server.settings.port, - } -``` - -### Session Properties and Methods - -The session object accessible via `ctx.session` provides advanced control over client communication: - -- `ctx.session.client_params` - Client initialization parameters and declared capabilities -- `await ctx.session.send_log_message(level, data, logger)` - Send log messages with full control -- `await ctx.session.create_message(messages, max_tokens)` - Request LLM sampling/completion -- `await ctx.session.send_progress_notification(token, progress, total, message)` - Direct progress updates -- `await ctx.session.send_resource_updated(uri)` - Notify clients that a specific resource changed -- `await ctx.session.send_resource_list_changed()` - Notify clients that the resource list changed -- `await ctx.session.send_tool_list_changed()` - Notify clients that the tool list changed -- `await ctx.session.send_prompt_list_changed()` - Notify clients that the prompt list changed - -```python -@mcp.tool() -async def notify_data_update(resource_uri: str, ctx: Context) -> str: - """Update data and notify clients of the change.""" - # Perform data update logic here - - # Notify clients that this specific resource changed - await ctx.session.send_resource_updated(AnyUrl(resource_uri)) - - # If this affects the overall resource list, notify about that too - await ctx.session.send_resource_list_changed() - - return f"Updated {resource_uri} and notified clients" -``` - -### Request Context Properties - -The request context accessible via `ctx.request_context` contains request-specific information and resources: - -- `ctx.request_context.lifespan_context` - Access to resources initialized during server startup - - Database connections, configuration objects, shared services - - Type-safe access to resources defined in your server's lifespan function -- `ctx.request_context.meta` - Request metadata from the client including: - - `progressToken` - Token for progress notifications - - Other client-provided metadata -- `ctx.request_context.request` - The original MCP request object for advanced processing -- `ctx.request_context.request_id` - Unique identifier for this request - -```python -# Example with typed lifespan context -@dataclass -class AppContext: - db: Database - config: AppConfig - -@mcp.tool() -def query_with_config(query: str, ctx: Context) -> str: - """Execute a query using shared database and configuration.""" - # Access typed lifespan context - app_ctx: AppContext = ctx.request_context.lifespan_context - - # Use shared resources - connection = app_ctx.db - settings = app_ctx.config - - # Execute query with configuration - result = connection.execute(query, timeout=settings.query_timeout) - return str(result) -``` - -_Full lifespan example: [examples/snippets/servers/lifespan_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lifespan_example.py)_ - -## Running Your Server - -### Development Mode - -The fastest way to test and debug your server is with the MCP Inspector: - -```bash -uv run mcp dev server.py - -# Add dependencies -uv run mcp dev server.py --with pandas --with numpy - -# Mount local code -uv run mcp dev server.py --with-editable . -``` - -### Claude Desktop Integration - -Once your server is ready, install it in Claude Desktop: - -```bash -uv run mcp install server.py - -# Custom name -uv run mcp install server.py --name "My Analytics Server" - -# Environment variables -uv run mcp install server.py -v API_KEY=abc123 -v DB_URL=postgres://... -uv run mcp install server.py -f .env -``` - -### Direct Execution - -For advanced scenarios like custom deployments: - -<!-- snippet-source examples/snippets/servers/direct_execution.py --> -```python -"""Example showing direct execution of an MCP server. - -This is the simplest way to run an MCP server directly. -cd to the `examples/snippets` directory and run: - uv run direct-execution-server - or - python servers/direct_execution.py -""" - -from mcp.server.mcpserver import MCPServer - -mcp = MCPServer("My App") - - -@mcp.tool() -def hello(name: str = "World") -> str: - """Say hello to someone.""" - return f"Hello, {name}!" - - -def main(): - """Entry point for the direct execution server.""" - mcp.run() - - -if __name__ == "__main__": - main() -``` - -_Full example: [examples/snippets/servers/direct_execution.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/direct_execution.py)_ -<!-- /snippet-source --> - -Run it with: - -```bash -python servers/direct_execution.py -# or -uv run mcp run servers/direct_execution.py -``` - -Note that `uv run mcp run` or `uv run mcp dev` only supports server using MCPServer and not the low-level server variant. - -### Streamable HTTP Transport - -> **Note**: Streamable HTTP transport is the recommended transport for production deployments. Use `stateless_http=True` and `json_response=True` for optimal scalability. - -<!-- snippet-source examples/snippets/servers/streamable_config.py --> -```python -"""Run from the repository root: -uv run examples/snippets/servers/streamable_config.py -""" - -from mcp.server.mcpserver import MCPServer - -mcp = MCPServer("StatelessServer") - - -# Add a simple tool to demonstrate the server -@mcp.tool() -def greet(name: str = "World") -> str: - """Greet someone by name.""" - return f"Hello, {name}!" - - -# Run server with streamable_http transport -# Transport-specific options (stateless_http, json_response) are passed to run() -if __name__ == "__main__": - # Stateless server with JSON responses (recommended) - mcp.run(transport="streamable-http", stateless_http=True, json_response=True) - - # Other configuration options: - # Stateless server with SSE streaming responses - # mcp.run(transport="streamable-http", stateless_http=True) - - # Stateful server with session persistence - # mcp.run(transport="streamable-http") -``` - -_Full example: [examples/snippets/servers/streamable_config.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_config.py)_ -<!-- /snippet-source --> - -You can mount multiple MCPServer servers in a Starlette application: - -<!-- snippet-source examples/snippets/servers/streamable_starlette_mount.py --> -```python -"""Run from the repository root: -uvicorn examples.snippets.servers.streamable_starlette_mount:app --reload -""" - -import contextlib - -from starlette.applications import Starlette -from starlette.routing import Mount - -from mcp.server.mcpserver import MCPServer - -# Create the Echo server -echo_mcp = MCPServer(name="EchoServer") - - -@echo_mcp.tool() -def echo(message: str) -> str: - """A simple echo tool""" - return f"Echo: {message}" - - -# Create the Math server -math_mcp = MCPServer(name="MathServer") - - -@math_mcp.tool() -def add_two(n: int) -> int: - """Tool to add two to the input""" - return n + 2 - - -# Create a combined lifespan to manage both session managers -@contextlib.asynccontextmanager -async def lifespan(app: Starlette): - async with contextlib.AsyncExitStack() as stack: - await stack.enter_async_context(echo_mcp.session_manager.run()) - await stack.enter_async_context(math_mcp.session_manager.run()) - yield - - -# Create the Starlette app and mount the MCP servers -app = Starlette( - routes=[ - Mount("/echo", echo_mcp.streamable_http_app(stateless_http=True, json_response=True)), - Mount("/math", math_mcp.streamable_http_app(stateless_http=True, json_response=True)), - ], - lifespan=lifespan, -) - -# Note: Clients connect to http://localhost:8000/echo/mcp and http://localhost:8000/math/mcp -# To mount at the root of each path (e.g., /echo instead of /echo/mcp): -# echo_mcp.streamable_http_app(streamable_http_path="/", stateless_http=True, json_response=True) -# math_mcp.streamable_http_app(streamable_http_path="/", stateless_http=True, json_response=True) -``` - -_Full example: [examples/snippets/servers/streamable_starlette_mount.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_starlette_mount.py)_ -<!-- /snippet-source --> - -For low level server with Streamable HTTP implementations, see: - -- Stateful server: [`examples/servers/simple-streamablehttp/`](examples/servers/simple-streamablehttp/) -- Stateless server: [`examples/servers/simple-streamablehttp-stateless/`](examples/servers/simple-streamablehttp-stateless/) - -The streamable HTTP transport supports: - -- Stateful and stateless operation modes -- Resumability with event stores -- JSON or SSE response formats -- Better scalability for multi-node deployments - -#### CORS Configuration for Browser-Based Clients - -If you'd like your server to be accessible by browser-based MCP clients, you'll need to configure CORS headers. The `Mcp-Session-Id` header must be exposed for browser clients to access it: - -```python -from starlette.applications import Starlette -from starlette.middleware.cors import CORSMiddleware - -# Create your Starlette app first -starlette_app = Starlette(routes=[...]) - -# Then wrap it with CORS middleware -starlette_app = CORSMiddleware( - starlette_app, - allow_origins=["*"], # Configure appropriately for production - allow_methods=["GET", "POST", "DELETE"], # MCP streamable HTTP methods - expose_headers=["Mcp-Session-Id"], -) -``` - -This configuration is necessary because: - -- The MCP streamable HTTP transport uses the `Mcp-Session-Id` header for session management -- Browsers restrict access to response headers unless explicitly exposed via CORS -- Without this configuration, browser-based clients won't be able to read the session ID from initialization responses - -### Mounting to an Existing ASGI Server - -By default, SSE servers are mounted at `/sse` and Streamable HTTP servers are mounted at `/mcp`. You can customize these paths using the methods described below. - -For more information on mounting applications in Starlette, see the [Starlette documentation](https://www.starlette.io/routing/#submounting-routes). - -#### StreamableHTTP servers - -You can mount the StreamableHTTP server to an existing ASGI server using the `streamable_http_app` method. This allows you to integrate the StreamableHTTP server with other ASGI applications. - -##### Basic mounting - -<!-- snippet-source examples/snippets/servers/streamable_http_basic_mounting.py --> -```python -"""Basic example showing how to mount StreamableHTTP server in Starlette. - -Run from the repository root: - uvicorn examples.snippets.servers.streamable_http_basic_mounting:app --reload -""" - -import contextlib - -from starlette.applications import Starlette -from starlette.routing import Mount - -from mcp.server.mcpserver import MCPServer - -# Create MCP server -mcp = MCPServer("My App") - - -@mcp.tool() -def hello() -> str: - """A simple hello tool""" - return "Hello from MCP!" - - -# Create a lifespan context manager to run the session manager -@contextlib.asynccontextmanager -async def lifespan(app: Starlette): - async with mcp.session_manager.run(): - yield - - -# Mount the StreamableHTTP server to the existing ASGI server -# Transport-specific options are passed to streamable_http_app() -app = Starlette( - routes=[ - Mount("/", app=mcp.streamable_http_app(json_response=True)), - ], - lifespan=lifespan, -) -``` - -_Full example: [examples/snippets/servers/streamable_http_basic_mounting.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_http_basic_mounting.py)_ -<!-- /snippet-source --> - -##### Host-based routing - -<!-- snippet-source examples/snippets/servers/streamable_http_host_mounting.py --> -```python -"""Example showing how to mount StreamableHTTP server using Host-based routing. - -Run from the repository root: - uvicorn examples.snippets.servers.streamable_http_host_mounting:app --reload -""" - -import contextlib - -from starlette.applications import Starlette -from starlette.routing import Host - -from mcp.server.mcpserver import MCPServer - -# Create MCP server -mcp = MCPServer("MCP Host App") - - -@mcp.tool() -def domain_info() -> str: - """Get domain-specific information""" - return "This is served from mcp.acme.corp" - - -# Create a lifespan context manager to run the session manager -@contextlib.asynccontextmanager -async def lifespan(app: Starlette): - async with mcp.session_manager.run(): - yield - - -# Mount using Host-based routing -# Transport-specific options are passed to streamable_http_app() -app = Starlette( - routes=[ - Host("mcp.acme.corp", app=mcp.streamable_http_app(json_response=True)), - ], - lifespan=lifespan, -) -``` - -_Full example: [examples/snippets/servers/streamable_http_host_mounting.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_http_host_mounting.py)_ -<!-- /snippet-source --> - -##### Multiple servers with path configuration - -<!-- snippet-source examples/snippets/servers/streamable_http_multiple_servers.py --> -```python -"""Example showing how to mount multiple StreamableHTTP servers with path configuration. - -Run from the repository root: - uvicorn examples.snippets.servers.streamable_http_multiple_servers:app --reload -""" - -import contextlib - -from starlette.applications import Starlette -from starlette.routing import Mount - -from mcp.server.mcpserver import MCPServer - -# Create multiple MCP servers -api_mcp = MCPServer("API Server") -chat_mcp = MCPServer("Chat Server") - - -@api_mcp.tool() -def api_status() -> str: - """Get API status""" - return "API is running" - - -@chat_mcp.tool() -def send_message(message: str) -> str: - """Send a chat message""" - return f"Message sent: {message}" - - -# Create a combined lifespan to manage both session managers -@contextlib.asynccontextmanager -async def lifespan(app: Starlette): - async with contextlib.AsyncExitStack() as stack: - await stack.enter_async_context(api_mcp.session_manager.run()) - await stack.enter_async_context(chat_mcp.session_manager.run()) - yield - - -# Mount the servers with transport-specific options passed to streamable_http_app() -# streamable_http_path="/" means endpoints will be at /api and /chat instead of /api/mcp and /chat/mcp -app = Starlette( - routes=[ - Mount("/api", app=api_mcp.streamable_http_app(json_response=True, streamable_http_path="/")), - Mount("/chat", app=chat_mcp.streamable_http_app(json_response=True, streamable_http_path="/")), - ], - lifespan=lifespan, -) -``` - -_Full example: [examples/snippets/servers/streamable_http_multiple_servers.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_http_multiple_servers.py)_ -<!-- /snippet-source --> - -##### Path configuration at initialization - -<!-- snippet-source examples/snippets/servers/streamable_http_path_config.py --> -```python -"""Example showing path configuration when mounting MCPServer. - -Run from the repository root: - uvicorn examples.snippets.servers.streamable_http_path_config:app --reload -""" - -from starlette.applications import Starlette -from starlette.routing import Mount - -from mcp.server.mcpserver import MCPServer - -# Create a simple MCPServer server -mcp_at_root = MCPServer("My Server") - - -@mcp_at_root.tool() -def process_data(data: str) -> str: - """Process some data""" - return f"Processed: {data}" - - -# Mount at /process with streamable_http_path="/" so the endpoint is /process (not /process/mcp) -# Transport-specific options like json_response are passed to streamable_http_app() -app = Starlette( - routes=[ - Mount( - "/process", - app=mcp_at_root.streamable_http_app(json_response=True, streamable_http_path="/"), - ), - ] -) -``` - -_Full example: [examples/snippets/servers/streamable_http_path_config.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_http_path_config.py)_ -<!-- /snippet-source --> - -#### SSE servers - -> **Note**: SSE transport is being superseded by [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http). - -You can mount the SSE server to an existing ASGI server using the `sse_app` method. This allows you to integrate the SSE server with other ASGI applications. - -```python -from starlette.applications import Starlette -from starlette.routing import Mount, Host -from mcp.server.mcpserver import MCPServer - - -mcp = MCPServer("My App") - -# Mount the SSE server to the existing ASGI server -app = Starlette( - routes=[ - Mount('/', app=mcp.sse_app()), - ] -) - -# or dynamically mount as host -app.router.routes.append(Host('mcp.acme.corp', app=mcp.sse_app())) -``` - -You can also mount multiple MCP servers at different sub-paths. The SSE transport automatically detects the mount path via ASGI's `root_path` mechanism, so message endpoints are correctly routed: - -```python -from starlette.applications import Starlette -from starlette.routing import Mount -from mcp.server.mcpserver import MCPServer - -# Create multiple MCP servers -github_mcp = MCPServer("GitHub API") -browser_mcp = MCPServer("Browser") -search_mcp = MCPServer("Search") - -# Mount each server at its own sub-path -# The SSE transport automatically uses ASGI's root_path to construct -# the correct message endpoint (e.g., /github/messages/, /browser/messages/) -app = Starlette( - routes=[ - Mount("/github", app=github_mcp.sse_app()), - Mount("/browser", app=browser_mcp.sse_app()), - Mount("/search", app=search_mcp.sse_app()), - ] -) -``` - -For more information on mounting applications in Starlette, see the [Starlette documentation](https://www.starlette.io/routing/#submounting-routes). - -## Advanced Usage - -### Low-Level Server - -For more control, you can use the low-level server implementation directly. This gives you full access to the protocol and allows you to customize every aspect of your server, including lifecycle management through the lifespan API: - -<!-- snippet-source examples/snippets/servers/lowlevel/lifespan.py --> -```python -"""Run from the repository root: -uv run examples/snippets/servers/lowlevel/lifespan.py -""" - -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from typing import TypedDict - -import mcp.server.stdio -from mcp import types -from mcp.server import Server, ServerRequestContext - - -# Mock database class for example -class Database: - """Mock database class for example.""" - - @classmethod - async def connect(cls) -> "Database": - """Connect to database.""" - print("Database connected") - return cls() - - async def disconnect(self) -> None: - """Disconnect from database.""" - print("Database disconnected") - - async def query(self, query_str: str) -> list[dict[str, str]]: - """Execute a query.""" - # Simulate database query - return [{"id": "1", "name": "Example", "query": query_str}] - - -class AppContext(TypedDict): - db: Database - - -@asynccontextmanager -async def server_lifespan(_server: Server[AppContext]) -> AsyncIterator[AppContext]: - """Manage server startup and shutdown lifecycle.""" - db = await Database.connect() - try: - yield {"db": db} - finally: - await db.disconnect() - - -async def handle_list_tools( - ctx: ServerRequestContext[AppContext], params: types.PaginatedRequestParams | None -) -> types.ListToolsResult: - """List available tools.""" - return types.ListToolsResult( - tools=[ - types.Tool( - name="query_db", - description="Query the database", - input_schema={ - "type": "object", - "properties": {"query": {"type": "string", "description": "SQL query to execute"}}, - "required": ["query"], - }, - ) - ] - ) - - -async def handle_call_tool( - ctx: ServerRequestContext[AppContext], params: types.CallToolRequestParams -) -> types.CallToolResult: - """Handle database query tool call.""" - if params.name != "query_db": - raise ValueError(f"Unknown tool: {params.name}") - - db = ctx.lifespan_context["db"] - results = await db.query((params.arguments or {})["query"]) - - return types.CallToolResult(content=[types.TextContent(type="text", text=f"Query results: {results}")]) - - -server = Server( - "example-server", - lifespan=server_lifespan, - on_list_tools=handle_list_tools, - on_call_tool=handle_call_tool, -) - - -async def run(): - """Run the server with lifespan management.""" - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - server.create_initialization_options(), - ) - - -if __name__ == "__main__": - import asyncio - - asyncio.run(run()) -``` - -_Full example: [examples/snippets/servers/lowlevel/lifespan.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lowlevel/lifespan.py)_ -<!-- /snippet-source --> - -The lifespan API provides: - -- A way to initialize resources when the server starts and clean them up when it stops -- Access to initialized resources through the request context in handlers -- Type-safe context passing between lifespan and request handlers - -<!-- snippet-source examples/snippets/servers/lowlevel/basic.py --> -```python -"""Run from the repository root: -uv run examples/snippets/servers/lowlevel/basic.py -""" - -import asyncio - -import mcp.server.stdio -from mcp import types -from mcp.server import Server, ServerRequestContext - - -async def handle_list_prompts( - ctx: ServerRequestContext, params: types.PaginatedRequestParams | None -) -> types.ListPromptsResult: - """List available prompts.""" - return types.ListPromptsResult( - prompts=[ - types.Prompt( - name="example-prompt", - description="An example prompt template", - arguments=[types.PromptArgument(name="arg1", description="Example argument", required=True)], - ) - ] - ) - - -async def handle_get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> types.GetPromptResult: - """Get a specific prompt by name.""" - if params.name != "example-prompt": - raise ValueError(f"Unknown prompt: {params.name}") - - arg1_value = (params.arguments or {}).get("arg1", "default") - - return types.GetPromptResult( - description="Example prompt", - messages=[ - types.PromptMessage( - role="user", - content=types.TextContent(type="text", text=f"Example prompt text with argument: {arg1_value}"), - ) - ], - ) - - -server = Server( - "example-server", - on_list_prompts=handle_list_prompts, - on_get_prompt=handle_get_prompt, -) - - -async def run(): - """Run the basic low-level server.""" - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - server.create_initialization_options(), - ) - - -if __name__ == "__main__": - asyncio.run(run()) -``` - -_Full example: [examples/snippets/servers/lowlevel/basic.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lowlevel/basic.py)_ -<!-- /snippet-source --> - -Caution: The `uv run mcp run` and `uv run mcp dev` tool doesn't support low-level server. - -#### Structured Output Support - -The low-level server supports structured output for tools, allowing you to return both human-readable content and machine-readable structured data. Tools can define an `outputSchema` to validate their structured output: - -<!-- snippet-source examples/snippets/servers/lowlevel/structured_output.py --> -```python -"""Run from the repository root: -uv run examples/snippets/servers/lowlevel/structured_output.py -""" - -import asyncio -import json - -import mcp.server.stdio -from mcp import types -from mcp.server import Server, ServerRequestContext - - -async def handle_list_tools( - ctx: ServerRequestContext, params: types.PaginatedRequestParams | None -) -> types.ListToolsResult: - """List available tools with structured output schemas.""" - return types.ListToolsResult( - tools=[ - types.Tool( - name="get_weather", - description="Get current weather for a city", - input_schema={ - "type": "object", - "properties": {"city": {"type": "string", "description": "City name"}}, - "required": ["city"], - }, - output_schema={ - "type": "object", - "properties": { - "temperature": {"type": "number", "description": "Temperature in Celsius"}, - "condition": {"type": "string", "description": "Weather condition"}, - "humidity": {"type": "number", "description": "Humidity percentage"}, - "city": {"type": "string", "description": "City name"}, - }, - "required": ["temperature", "condition", "humidity", "city"], - }, - ) - ] - ) - - -async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: - """Handle tool calls with structured output.""" - if params.name == "get_weather": - city = (params.arguments or {})["city"] - - weather_data = { - "temperature": 22.5, - "condition": "partly cloudy", - "humidity": 65, - "city": city, - } - - return types.CallToolResult( - content=[types.TextContent(type="text", text=json.dumps(weather_data, indent=2))], - structured_content=weather_data, - ) - - raise ValueError(f"Unknown tool: {params.name}") - - -server = Server( - "example-server", - on_list_tools=handle_list_tools, - on_call_tool=handle_call_tool, -) - - -async def run(): - """Run the structured output server.""" - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - server.create_initialization_options(), - ) - - -if __name__ == "__main__": - asyncio.run(run()) -``` - -_Full example: [examples/snippets/servers/lowlevel/structured_output.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lowlevel/structured_output.py)_ -<!-- /snippet-source --> - -With the low-level server, handlers always return `CallToolResult` directly. You construct both the human-readable `content` and the machine-readable `structured_content` yourself, giving you full control over the response. - -##### Returning CallToolResult with `_meta` - -For passing data to client applications without exposing it to the model, use the `_meta` field on `CallToolResult`: - -<!-- snippet-source examples/snippets/servers/lowlevel/direct_call_tool_result.py --> -```python -"""Run from the repository root: -uv run examples/snippets/servers/lowlevel/direct_call_tool_result.py -""" - -import asyncio - -import mcp.server.stdio -from mcp import types -from mcp.server import Server, ServerRequestContext - - -async def handle_list_tools( - ctx: ServerRequestContext, params: types.PaginatedRequestParams | None -) -> types.ListToolsResult: - """List available tools.""" - return types.ListToolsResult( - tools=[ - types.Tool( - name="advanced_tool", - description="Tool with full control including _meta field", - input_schema={ - "type": "object", - "properties": {"message": {"type": "string"}}, - "required": ["message"], - }, - ) - ] - ) - - -async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: - """Handle tool calls by returning CallToolResult directly.""" - if params.name == "advanced_tool": - message = (params.arguments or {}).get("message", "") - return types.CallToolResult( - content=[types.TextContent(type="text", text=f"Processed: {message}")], - structured_content={"result": "success", "message": message}, - _meta={"hidden": "data for client applications only"}, - ) - - raise ValueError(f"Unknown tool: {params.name}") - - -server = Server( - "example-server", - on_list_tools=handle_list_tools, - on_call_tool=handle_call_tool, -) - - -async def run(): - """Run the server.""" - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - server.create_initialization_options(), - ) - - -if __name__ == "__main__": - asyncio.run(run()) -``` - -_Full example: [examples/snippets/servers/lowlevel/direct_call_tool_result.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lowlevel/direct_call_tool_result.py)_ -<!-- /snippet-source --> - -### Pagination (Advanced) - -For servers that need to handle large datasets, the low-level server provides paginated versions of list operations. This is an optional optimization - most servers won't need pagination unless they're dealing with hundreds or thousands of items. - -#### Server-side Implementation - -<!-- snippet-source examples/snippets/servers/pagination_example.py --> -```python -"""Example of implementing pagination with the low-level MCP server.""" - -from mcp import types -from mcp.server import Server, ServerRequestContext - -# Sample data to paginate -ITEMS = [f"Item {i}" for i in range(1, 101)] # 100 items - - -async def handle_list_resources( - ctx: ServerRequestContext, params: types.PaginatedRequestParams | None -) -> types.ListResourcesResult: - """List resources with pagination support.""" - page_size = 10 - - # Extract cursor from request params - cursor = params.cursor if params is not None else None - - # Parse cursor to get offset - start = 0 if cursor is None else int(cursor) - end = start + page_size - - # Get page of resources - page_items = [ - types.Resource(uri=f"resource://items/{item}", name=item, description=f"Description for {item}") - for item in ITEMS[start:end] - ] - - # Determine next cursor - next_cursor = str(end) if end < len(ITEMS) else None - - return types.ListResourcesResult(resources=page_items, next_cursor=next_cursor) - - -server = Server("paginated-server", on_list_resources=handle_list_resources) -``` - -_Full example: [examples/snippets/servers/pagination_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/pagination_example.py)_ -<!-- /snippet-source --> - -#### Client-side Consumption - -<!-- snippet-source examples/snippets/clients/pagination_client.py --> -```python -"""Example of consuming paginated MCP endpoints from a client.""" - -import asyncio - -from mcp.client.session import ClientSession -from mcp.client.stdio import StdioServerParameters, stdio_client -from mcp.types import PaginatedRequestParams, Resource - - -async def list_all_resources() -> None: - """Fetch all resources using pagination.""" - async with stdio_client(StdioServerParameters(command="uv", args=["run", "mcp-simple-pagination"])) as ( - read, - write, - ): - async with ClientSession(read, write) as session: - await session.initialize() - - all_resources: list[Resource] = [] - cursor = None - - while True: - # Fetch a page of resources - result = await session.list_resources(params=PaginatedRequestParams(cursor=cursor)) - all_resources.extend(result.resources) - - print(f"Fetched {len(result.resources)} resources") - - # Check if there are more pages - if result.next_cursor: - cursor = result.next_cursor - else: - break - - print(f"Total resources: {len(all_resources)}") - - -if __name__ == "__main__": - asyncio.run(list_all_resources()) -``` - -_Full example: [examples/snippets/clients/pagination_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/pagination_client.py)_ -<!-- /snippet-source --> - -#### Key Points - -- **Cursors are opaque strings** - the server defines the format (numeric offsets, timestamps, etc.) -- **Return `nextCursor=None`** when there are no more pages -- **Backward compatible** - clients that don't support pagination will still work (they'll just get the first page) -- **Flexible page sizes** - Each endpoint can define its own page size based on data characteristics - -See the [simple-pagination example](examples/servers/simple-pagination) for a complete implementation. - -### Writing MCP Clients - -The SDK provides a high-level client interface for connecting to MCP servers using various [transports](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports): - -<!-- snippet-source examples/snippets/clients/stdio_client.py --> -```python -"""cd to the `examples/snippets/clients` directory and run: -uv run client -""" - -import asyncio -import os - -from mcp import ClientSession, StdioServerParameters, types -from mcp.client.context import ClientRequestContext -from mcp.client.stdio import stdio_client - -# Create server parameters for stdio connection -server_params = StdioServerParameters( - command="uv", # Using uv to run the server - args=["run", "server", "mcpserver_quickstart", "stdio"], # We're already in snippets dir - env={"UV_INDEX": os.environ.get("UV_INDEX", "")}, -) - - -# Optional: create a sampling callback -async def handle_sampling_message( - context: ClientRequestContext, params: types.CreateMessageRequestParams -) -> types.CreateMessageResult: - print(f"Sampling request: {params.messages}") - return types.CreateMessageResult( - role="assistant", - content=types.TextContent( - type="text", - text="Hello, world! from model", - ), - model="gpt-3.5-turbo", - stop_reason="endTurn", - ) - - -async def run(): - async with stdio_client(server_params) as (read, write): - async with ClientSession(read, write, sampling_callback=handle_sampling_message) as session: - # Initialize the connection - await session.initialize() - - # List available prompts - prompts = await session.list_prompts() - print(f"Available prompts: {[p.name for p in prompts.prompts]}") - - # Get a prompt (greet_user prompt from mcpserver_quickstart) - if prompts.prompts: - prompt = await session.get_prompt("greet_user", arguments={"name": "Alice", "style": "friendly"}) - print(f"Prompt result: {prompt.messages[0].content}") - - # List available resources - resources = await session.list_resources() - print(f"Available resources: {[r.uri for r in resources.resources]}") - - # List available tools - tools = await session.list_tools() - print(f"Available tools: {[t.name for t in tools.tools]}") - - # Read a resource (greeting resource from mcpserver_quickstart) - resource_content = await session.read_resource("greeting://World") - content_block = resource_content.contents[0] - if isinstance(content_block, types.TextContent): - print(f"Resource content: {content_block.text}") - - # Call a tool (add tool from mcpserver_quickstart) - result = await session.call_tool("add", arguments={"a": 5, "b": 3}) - result_unstructured = result.content[0] - if isinstance(result_unstructured, types.TextContent): - print(f"Tool result: {result_unstructured.text}") - result_structured = result.structured_content - print(f"Structured tool result: {result_structured}") - - -def main(): - """Entry point for the client script.""" - asyncio.run(run()) - - -if __name__ == "__main__": - main() -``` - -_Full example: [examples/snippets/clients/stdio_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/stdio_client.py)_ -<!-- /snippet-source --> - -Clients can also connect using [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http): - -<!-- snippet-source examples/snippets/clients/streamable_basic.py --> -```python -"""Run from the repository root: -uv run examples/snippets/clients/streamable_basic.py -""" - -import asyncio - -from mcp import ClientSession -from mcp.client.streamable_http import streamable_http_client - - -async def main(): - # Connect to a streamable HTTP server - async with streamable_http_client("http://localhost:8000/mcp") as (read_stream, write_stream): - # Create a session using the client streams - async with ClientSession(read_stream, write_stream) as session: - # Initialize the connection - await session.initialize() - # List available tools - tools = await session.list_tools() - print(f"Available tools: {[tool.name for tool in tools.tools]}") - - -if __name__ == "__main__": - asyncio.run(main()) -``` - -_Full example: [examples/snippets/clients/streamable_basic.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/streamable_basic.py)_ -<!-- /snippet-source --> - -### Client Display Utilities - -When building MCP clients, the SDK provides utilities to help display human-readable names for tools, resources, and prompts: - -<!-- snippet-source examples/snippets/clients/display_utilities.py --> -```python -"""cd to the `examples/snippets` directory and run: -uv run display-utilities-client -""" - -import asyncio -import os - -from mcp import ClientSession, StdioServerParameters -from mcp.client.stdio import stdio_client -from mcp.shared.metadata_utils import get_display_name - -# Create server parameters for stdio connection -server_params = StdioServerParameters( - command="uv", # Using uv to run the server - args=["run", "server", "mcpserver_quickstart", "stdio"], - env={"UV_INDEX": os.environ.get("UV_INDEX", "")}, -) - - -async def display_tools(session: ClientSession): - """Display available tools with human-readable names""" - tools_response = await session.list_tools() - - for tool in tools_response.tools: - # get_display_name() returns the title if available, otherwise the name - display_name = get_display_name(tool) - print(f"Tool: {display_name}") - if tool.description: - print(f" {tool.description}") - - -async def display_resources(session: ClientSession): - """Display available resources with human-readable names""" - resources_response = await session.list_resources() - - for resource in resources_response.resources: - display_name = get_display_name(resource) - print(f"Resource: {display_name} ({resource.uri})") - - templates_response = await session.list_resource_templates() - for template in templates_response.resource_templates: - display_name = get_display_name(template) - print(f"Resource Template: {display_name}") - - -async def run(): - """Run the display utilities example.""" - async with stdio_client(server_params) as (read, write): - async with ClientSession(read, write) as session: - # Initialize the connection - await session.initialize() - - print("=== Available Tools ===") - await display_tools(session) - - print("\n=== Available Resources ===") - await display_resources(session) - - -def main(): - """Entry point for the display utilities client.""" - asyncio.run(run()) - - -if __name__ == "__main__": - main() -``` - -_Full example: [examples/snippets/clients/display_utilities.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/display_utilities.py)_ -<!-- /snippet-source --> - -The `get_display_name()` function implements the proper precedence rules for displaying names: - -- For tools: `title` > `annotations.title` > `name` -- For other objects: `title` > `name` - -This ensures your client UI shows the most user-friendly names that servers provide. - -### OAuth Authentication for Clients - -The SDK includes [authorization support](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization) for connecting to protected MCP servers: - -<!-- snippet-source examples/snippets/clients/oauth_client.py --> -```python -"""Before running, specify running MCP RS server URL. -To spin up RS server locally, see - examples/servers/simple-auth/README.md - -cd to the `examples/snippets` directory and run: - uv run oauth-client -""" - -import asyncio -from urllib.parse import parse_qs, urlparse - -import httpx -from pydantic import AnyUrl - -from mcp import ClientSession -from mcp.client.auth import OAuthClientProvider, TokenStorage -from mcp.client.streamable_http import streamable_http_client -from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken - - -class InMemoryTokenStorage(TokenStorage): - """Demo In-memory token storage implementation.""" - - def __init__(self): - self.tokens: OAuthToken | None = None - self.client_info: OAuthClientInformationFull | None = None - - async def get_tokens(self) -> OAuthToken | None: - """Get stored tokens.""" - return self.tokens - - async def set_tokens(self, tokens: OAuthToken) -> None: - """Store tokens.""" - self.tokens = tokens - - async def get_client_info(self) -> OAuthClientInformationFull | None: - """Get stored client information.""" - return self.client_info - - async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: - """Store client information.""" - self.client_info = client_info - - -async def handle_redirect(auth_url: str) -> None: - print(f"Visit: {auth_url}") - - -async def handle_callback() -> tuple[str, str | None]: - callback_url = input("Paste callback URL: ") - params = parse_qs(urlparse(callback_url).query) - return params["code"][0], params.get("state", [None])[0] - - -async def main(): - """Run the OAuth client example.""" - oauth_auth = OAuthClientProvider( - server_url="http://localhost:8001", - client_metadata=OAuthClientMetadata( - client_name="Example MCP Client", - redirect_uris=[AnyUrl("http://localhost:3000/callback")], - grant_types=["authorization_code", "refresh_token"], - response_types=["code"], - scope="user", - ), - storage=InMemoryTokenStorage(), - redirect_handler=handle_redirect, - callback_handler=handle_callback, - ) - - async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client: - async with streamable_http_client("http://localhost:8001/mcp", http_client=custom_client) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - - tools = await session.list_tools() - print(f"Available tools: {[tool.name for tool in tools.tools]}") - - resources = await session.list_resources() - print(f"Available resources: {[r.uri for r in resources.resources]}") - - -def run(): - asyncio.run(main()) - - -if __name__ == "__main__": - run() -``` - -_Full example: [examples/snippets/clients/oauth_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/oauth_client.py)_ -<!-- /snippet-source --> - -For a complete working example, see [`examples/clients/simple-auth-client/`](examples/clients/simple-auth-client/). - -### Parsing Tool Results - -When calling tools through MCP, the `CallToolResult` object contains the tool's response in a structured format. Understanding how to parse this result is essential for properly handling tool outputs. - -```python -"""examples/snippets/clients/parsing_tool_results.py""" - -import asyncio - -from mcp import ClientSession, StdioServerParameters, types -from mcp.client.stdio import stdio_client - - -async def parse_tool_results(): - """Demonstrates how to parse different types of content in CallToolResult.""" - server_params = StdioServerParameters( - command="python", args=["path/to/mcp_server.py"] - ) - - async with stdio_client(server_params) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - - # Example 1: Parsing text content - result = await session.call_tool("get_data", {"format": "text"}) - for content in result.content: - if isinstance(content, types.TextContent): - print(f"Text: {content.text}") - - # Example 2: Parsing structured content from JSON tools - result = await session.call_tool("get_user", {"id": "123"}) - if hasattr(result, "structuredContent") and result.structuredContent: - # Access structured data directly - user_data = result.structuredContent - print(f"User: {user_data.get('name')}, Age: {user_data.get('age')}") - - # Example 3: Parsing embedded resources - result = await session.call_tool("read_config", {}) - for content in result.content: - if isinstance(content, types.EmbeddedResource): - resource = content.resource - if isinstance(resource, types.TextResourceContents): - print(f"Config from {resource.uri}: {resource.text}") - elif isinstance(resource, types.BlobResourceContents): - print(f"Binary data from {resource.uri}") - - # Example 4: Parsing image content - result = await session.call_tool("generate_chart", {"data": [1, 2, 3]}) - for content in result.content: - if isinstance(content, types.ImageContent): - print(f"Image ({content.mimeType}): {len(content.data)} bytes") - - # Example 5: Handling errors - result = await session.call_tool("failing_tool", {}) - if result.isError: - print("Tool execution failed!") - for content in result.content: - if isinstance(content, types.TextContent): - print(f"Error: {content.text}") - - -async def main(): - await parse_tool_results() - - -if __name__ == "__main__": - asyncio.run(main()) -``` - -### MCP Primitives - -The MCP protocol defines three core primitives that servers can implement: - -| Primitive | Control | Description | Example Use | -|-----------|-----------------------|-----------------------------------------------------|------------------------------| -| Prompts | User-controlled | Interactive templates invoked by user choice | Slash commands, menu options | -| Resources | Application-controlled| Contextual data managed by the client application | File contents, API responses | -| Tools | Model-controlled | Functions exposed to the LLM to take actions | API calls, data updates | - -### Server Capabilities - -MCP servers declare capabilities during initialization: - -| Capability | Feature Flag | Description | -|--------------|------------------------------|------------------------------------| -| `prompts` | `listChanged` | Prompt template management | -| `resources` | `subscribe`<br/>`listChanged`| Resource exposure and updates | -| `tools` | `listChanged` | Tool discovery and execution | -| `logging` | - | Server logging configuration | -| `completions`| - | Argument completion suggestions | - -## Documentation - -- [API Reference](https://modelcontextprotocol.github.io/python-sdk/api/) -- [Experimental Features (Tasks)](https://modelcontextprotocol.github.io/python-sdk/experimental/tasks/) -- [Model Context Protocol documentation](https://modelcontextprotocol.io) -- [Model Context Protocol specification](https://modelcontextprotocol.io/specification/latest) -- [Officially supported servers](https://github.com/modelcontextprotocol/servers) - -## Contributing - -We are passionate about supporting contributors of all levels of experience and would love to see you get involved in the project. See the [contributing guide](CONTRIBUTING.md) to get started. - -## License - -This project is licensed under the MIT License - see the LICENSE file for details. +# MCP Python SDK + +<div align="center"> + +<strong>Python implementation of the Model Context Protocol (MCP)</strong> + +[![PyPI][pypi-badge]][pypi-url] +[![MIT licensed][mit-badge]][mit-url] +[![Python Version][python-badge]][python-url] +[![Documentation][docs-badge]][docs-url] +[![Protocol][protocol-badge]][protocol-url] +[![Specification][spec-badge]][spec-url] + +</div> + +<!-- TODO(v2): Move this content back to README.md when v2 is released --> + +> [!IMPORTANT] +> **This documents v2 of the SDK (currently in development, pre-alpha on `main`).** +> +> We anticipate a stable v2 release in Q1 2026. Until then, **v1.x remains the recommended version** for production use. v1.x will continue to receive bug fixes and security updates for at least 6 months after v2 ships to give people time to upgrade. +> +> For v1 documentation (the current stable release), see [`README.md`](README.md). + +<!-- omit in toc --> +## Table of Contents + +- [MCP Python SDK](#mcp-python-sdk) + - [Overview](#overview) + - [Installation](#installation) + - [Adding MCP to your python project](#adding-mcp-to-your-python-project) + - [Running the standalone MCP development tools](#running-the-standalone-mcp-development-tools) + - [Quickstart](#quickstart) + - [What is MCP?](#what-is-mcp) + - [Core Concepts](#core-concepts) + - [Server](#server) + - [Resources](#resources) + - [Tools](#tools) + - [Structured Output](#structured-output) + - [Prompts](#prompts) + - [Images](#images) + - [Context](#context) + - [Getting Context in Functions](#getting-context-in-functions) + - [Context Properties and Methods](#context-properties-and-methods) + - [Completions](#completions) + - [Elicitation](#elicitation) + - [Sampling](#sampling) + - [Logging and Notifications](#logging-and-notifications) + - [Authentication](#authentication) + - [MCPServer Properties](#mcpserver-properties) + - [Session Properties and Methods](#session-properties-and-methods) + - [Request Context Properties](#request-context-properties) + - [Running Your Server](#running-your-server) + - [Development Mode](#development-mode) + - [Claude Desktop Integration](#claude-desktop-integration) + - [Direct Execution](#direct-execution) + - [Streamable HTTP Transport](#streamable-http-transport) + - [CORS Configuration for Browser-Based Clients](#cors-configuration-for-browser-based-clients) + - [Mounting to an Existing ASGI Server](#mounting-to-an-existing-asgi-server) + - [StreamableHTTP servers](#streamablehttp-servers) + - [Basic mounting](#basic-mounting) + - [Host-based routing](#host-based-routing) + - [Multiple servers with path configuration](#multiple-servers-with-path-configuration) + - [Path configuration at initialization](#path-configuration-at-initialization) + - [SSE servers](#sse-servers) + - [Advanced Usage](#advanced-usage) + - [Low-Level Server](#low-level-server) + - [Structured Output Support](#structured-output-support) + - [Pagination (Advanced)](#pagination-advanced) + - [Writing MCP Clients](#writing-mcp-clients) + - [Client Display Utilities](#client-display-utilities) + - [OAuth Authentication for Clients](#oauth-authentication-for-clients) + - [Parsing Tool Results](#parsing-tool-results) + - [MCP Primitives](#mcp-primitives) + - [Server Capabilities](#server-capabilities) + - [Documentation](#documentation) + - [Contributing](#contributing) + - [License](#license) + +[pypi-badge]: https://img.shields.io/pypi/v/mcp.svg +[pypi-url]: https://pypi.org/project/mcp/ +[mit-badge]: https://img.shields.io/pypi/l/mcp.svg +[mit-url]: https://github.com/modelcontextprotocol/python-sdk/blob/main/LICENSE +[python-badge]: https://img.shields.io/pypi/pyversions/mcp.svg +[python-url]: https://www.python.org/downloads/ +[docs-badge]: https://img.shields.io/badge/docs-python--sdk-blue.svg +[docs-url]: https://modelcontextprotocol.github.io/python-sdk/ +[protocol-badge]: https://img.shields.io/badge/protocol-modelcontextprotocol.io-blue.svg +[protocol-url]: https://modelcontextprotocol.io +[spec-badge]: https://img.shields.io/badge/spec-spec.modelcontextprotocol.io-blue.svg +[spec-url]: https://modelcontextprotocol.io/specification/latest + +## Overview + +The Model Context Protocol allows applications to provide context for LLMs in a standardized way, separating the concerns of providing context from the actual LLM interaction. This Python SDK implements the full MCP specification, making it easy to: + +- Build MCP clients that can connect to any MCP server +- Create MCP servers that expose resources, prompts and tools +- Use standard transports like stdio, SSE, and Streamable HTTP +- Handle all MCP protocol messages and lifecycle events + +## Installation + +### Adding MCP to your python project + +We recommend using [uv](https://docs.astral.sh/uv/) to manage your Python projects. + +If you haven't created a uv-managed project yet, create one: + + ```bash + uv init mcp-server-demo + cd mcp-server-demo + ``` + + Then add MCP to your project dependencies: + + ```bash + uv add "mcp[cli]" + ``` + +Alternatively, for projects using pip for dependencies: + +```bash +pip install "mcp[cli]" +``` + +### Running the standalone MCP development tools + +To run the mcp command with uv: + +```bash +uv run mcp +``` + +## Quickstart + +Let's create a simple MCP server that exposes a calculator tool and some data: + +<!-- snippet-source examples/snippets/servers/mcpserver_quickstart.py --> +```python +"""MCPServer quickstart example. + +Run from the repository root: + uv run examples/snippets/servers/mcpserver_quickstart.py +""" + +from mcp.server.mcpserver import MCPServer + +# Create an MCP server +mcp = MCPServer("Demo") + + +# Add an addition tool +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two numbers""" + return a + b + + +# Add a dynamic greeting resource +@mcp.resource("greeting://{name}") +def get_greeting(name: str) -> str: + """Get a personalized greeting""" + return f"Hello, {name}!" + + +# Add a prompt +@mcp.prompt() +def greet_user(name: str, style: str = "friendly") -> str: + """Generate a greeting prompt""" + styles = { + "friendly": "Please write a warm, friendly greeting", + "formal": "Please write a formal, professional greeting", + "casual": "Please write a casual, relaxed greeting", + } + + return f"{styles.get(style, styles['friendly'])} for someone named {name}." + + +# Run with streamable HTTP transport +if __name__ == "__main__": + mcp.run(transport="streamable-http", json_response=True) +``` + +_Full example: [examples/snippets/servers/mcpserver_quickstart.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/mcpserver_quickstart.py)_ +<!-- /snippet-source --> + +You can install this server in [Claude Code](https://docs.claude.com/en/docs/claude-code/mcp) and interact with it right away. First, run the server: + +```bash +uv run --with mcp examples/snippets/servers/mcpserver_quickstart.py +``` + +Then add it to Claude Code: + +```bash +claude mcp add --transport http my-server http://localhost:8000/mcp +``` + +Alternatively, you can test it with the MCP Inspector. Start the server as above, then in a separate terminal: + +```bash +npx -y @modelcontextprotocol/inspector +``` + +In the inspector UI, connect to `http://localhost:8000/mcp`. + +## What is MCP? + +The [Model Context Protocol (MCP)](https://modelcontextprotocol.io) lets you build servers that expose data and functionality to LLM applications in a secure, standardized way. Think of it like a web API, but specifically designed for LLM interactions. MCP servers can: + +- Expose data through **Resources** (think of these sort of like GET endpoints; they are used to load information into the LLM's context) +- Provide functionality through **Tools** (sort of like POST endpoints; they are used to execute code or otherwise produce a side effect) +- Define interaction patterns through **Prompts** (reusable templates for LLM interactions) +- And more! + +## Core Concepts + +### Server + +The MCPServer server is your core interface to the MCP protocol. It handles connection management, protocol compliance, and message routing: + +<!-- snippet-source examples/snippets/servers/lifespan_example.py --> +```python +"""Example showing lifespan support for startup/shutdown with strong typing.""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass + +from mcp.server.mcpserver import Context, MCPServer + + +# Mock database class for example +class Database: + """Mock database class for example.""" + + @classmethod + async def connect(cls) -> "Database": + """Connect to database.""" + return cls() + + async def disconnect(self) -> None: + """Disconnect from database.""" + pass + + def query(self) -> str: + """Execute a query.""" + return "Query result" + + +@dataclass +class AppContext: + """Application context with typed dependencies.""" + + db: Database + + +@asynccontextmanager +async def app_lifespan(server: MCPServer) -> AsyncIterator[AppContext]: + """Manage application lifecycle with type-safe context.""" + # Initialize on startup + db = await Database.connect() + try: + yield AppContext(db=db) + finally: + # Cleanup on shutdown + await db.disconnect() + + +# Pass lifespan to server +mcp = MCPServer("My App", lifespan=app_lifespan) + + +# Access type-safe lifespan context in tools +@mcp.tool() +def query_db(ctx: Context[AppContext]) -> str: + """Tool that uses initialized resources.""" + db = ctx.request_context.lifespan_context.db + return db.query() +``` + +_Full example: [examples/snippets/servers/lifespan_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lifespan_example.py)_ +<!-- /snippet-source --> + +### Resources + +Resources are how you expose data to LLMs. They're similar to GET endpoints in a REST API - they provide data but shouldn't perform significant computation or have side effects: + +<!-- snippet-source examples/snippets/servers/basic_resource.py --> +```python +from mcp.server.mcpserver import MCPServer + +mcp = MCPServer(name="Resource Example") + + +@mcp.resource("file://documents/{name}") +def read_document(name: str) -> str: + """Read a document by name.""" + # This would normally read from disk + return f"Content of {name}" + + +@mcp.resource("config://settings") +def get_settings() -> str: + """Get application settings.""" + return """{ + "theme": "dark", + "language": "en", + "debug": false +}""" +``` + +_Full example: [examples/snippets/servers/basic_resource.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/basic_resource.py)_ +<!-- /snippet-source --> + +### Tools + +Tools let LLMs take actions through your server. Unlike resources, tools are expected to perform computation and have side effects: + +<!-- snippet-source examples/snippets/servers/basic_tool.py --> +```python +from mcp.server.mcpserver import MCPServer + +mcp = MCPServer(name="Tool Example") + + +@mcp.tool() +def sum(a: int, b: int) -> int: + """Add two numbers together.""" + return a + b + + +@mcp.tool() +def get_weather(city: str, unit: str = "celsius") -> str: + """Get weather for a city.""" + # This would normally call a weather API + return f"Weather in {city}: 22degrees{unit[0].upper()}" +``` + +_Full example: [examples/snippets/servers/basic_tool.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/basic_tool.py)_ +<!-- /snippet-source --> + +Tools can optionally receive a Context object by including a parameter with the `Context` type annotation. This context is automatically injected by the MCPServer framework and provides access to MCP capabilities: + +<!-- snippet-source examples/snippets/servers/tool_progress.py --> +```python +from mcp.server.mcpserver import Context, MCPServer + +mcp = MCPServer(name="Progress Example") + + +@mcp.tool() +async def long_running_task(task_name: str, ctx: Context, steps: int = 5) -> str: + """Execute a task with progress updates.""" + await ctx.info(f"Starting: {task_name}") + + for i in range(steps): + progress = (i + 1) / steps + await ctx.report_progress( + progress=progress, + total=1.0, + message=f"Step {i + 1}/{steps}", + ) + await ctx.debug(f"Completed step {i + 1}") + + return f"Task '{task_name}' completed" +``` + +_Full example: [examples/snippets/servers/tool_progress.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/tool_progress.py)_ +<!-- /snippet-source --> + +#### Structured Output + +Tools will return structured results by default, if their return type +annotation is compatible. Otherwise, they will return unstructured results. + +Structured output supports these return types: + +- Pydantic models (BaseModel subclasses) +- TypedDicts +- Dataclasses and other classes with type hints +- `dict[str, T]` (where T is any JSON-serializable type) +- Primitive types (str, int, float, bool, bytes, None) - wrapped in `{"result": value}` +- Generic types (list, tuple, Union, Optional, etc.) - wrapped in `{"result": value}` + +Classes without type hints cannot be serialized for structured output. Only +classes with properly annotated attributes will be converted to Pydantic models +for schema generation and validation. + +Structured results are automatically validated against the output schema +generated from the annotation. This ensures the tool returns well-typed, +validated data that clients can easily process. + +**Note:** For backward compatibility, unstructured results are also +returned. Unstructured results are provided for backward compatibility +with previous versions of the MCP specification, and are quirks-compatible +with previous versions of MCPServer in the current version of the SDK. + +**Note:** In cases where a tool function's return type annotation +causes the tool to be classified as structured _and this is undesirable_, +the classification can be suppressed by passing `structured_output=False` +to the `@tool` decorator. + +##### Advanced: Direct CallToolResult + +For full control over tool responses including the `_meta` field (for passing data to client applications without exposing it to the model), you can return `CallToolResult` directly: + +<!-- snippet-source examples/snippets/servers/direct_call_tool_result.py --> +```python +"""Example showing direct CallToolResult return for advanced control.""" + +from typing import Annotated + +from pydantic import BaseModel + +from mcp.server.mcpserver import MCPServer +from mcp.types import CallToolResult, TextContent + +mcp = MCPServer("CallToolResult Example") + + +class ValidationModel(BaseModel): + """Model for validating structured output.""" + + status: str + data: dict[str, int] + + +@mcp.tool() +def advanced_tool() -> CallToolResult: + """Return CallToolResult directly for full control including _meta field.""" + return CallToolResult( + content=[TextContent(type="text", text="Response visible to the model")], + _meta={"hidden": "data for client applications only"}, + ) + + +@mcp.tool() +def validated_tool() -> Annotated[CallToolResult, ValidationModel]: + """Return CallToolResult with structured output validation.""" + return CallToolResult( + content=[TextContent(type="text", text="Validated response")], + structured_content={"status": "success", "data": {"result": 42}}, + _meta={"internal": "metadata"}, + ) + + +@mcp.tool() +def empty_result_tool() -> CallToolResult: + """For empty results, return CallToolResult with empty content.""" + return CallToolResult(content=[]) +``` + +_Full example: [examples/snippets/servers/direct_call_tool_result.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/direct_call_tool_result.py)_ +<!-- /snippet-source --> + +**Important:** `CallToolResult` must always be returned (no `Optional` or `Union`). For empty results, use `CallToolResult(content=[])`. For optional simple types, use `str | None` without `CallToolResult`. + +<!-- snippet-source examples/snippets/servers/structured_output.py --> +```python +"""Example showing structured output with tools.""" + +from typing import TypedDict + +from pydantic import BaseModel, Field + +from mcp.server.mcpserver import MCPServer + +mcp = MCPServer("Structured Output Example") + + +# Using Pydantic models for rich structured data +class WeatherData(BaseModel): + """Weather information structure.""" + + temperature: float = Field(description="Temperature in Celsius") + humidity: float = Field(description="Humidity percentage") + condition: str + wind_speed: float + + +@mcp.tool() +def get_weather(city: str) -> WeatherData: + """Get weather for a city - returns structured data.""" + # Simulated weather data + return WeatherData( + temperature=22.5, + humidity=45.0, + condition="sunny", + wind_speed=5.2, + ) + + +# Using TypedDict for simpler structures +class LocationInfo(TypedDict): + latitude: float + longitude: float + name: str + + +@mcp.tool() +def get_location(address: str) -> LocationInfo: + """Get location coordinates""" + return LocationInfo(latitude=51.5074, longitude=-0.1278, name="London, UK") + + +# Using dict[str, Any] for flexible schemas +@mcp.tool() +def get_statistics(data_type: str) -> dict[str, float]: + """Get various statistics""" + return {"mean": 42.5, "median": 40.0, "std_dev": 5.2} + + +# Ordinary classes with type hints work for structured output +class UserProfile: + name: str + age: int + email: str | None = None + + def __init__(self, name: str, age: int, email: str | None = None): + self.name = name + self.age = age + self.email = email + + +@mcp.tool() +def get_user(user_id: str) -> UserProfile: + """Get user profile - returns structured data""" + return UserProfile(name="Alice", age=30, email="alice@example.com") + + +# Classes WITHOUT type hints cannot be used for structured output +class UntypedConfig: + def __init__(self, setting1, setting2): # type: ignore[reportMissingParameterType] + self.setting1 = setting1 + self.setting2 = setting2 + + +@mcp.tool() +def get_config() -> UntypedConfig: + """This returns unstructured output - no schema generated""" + return UntypedConfig("value1", "value2") + + +# Lists and other types are wrapped automatically +@mcp.tool() +def list_cities() -> list[str]: + """Get a list of cities""" + return ["London", "Paris", "Tokyo"] + # Returns: {"result": ["London", "Paris", "Tokyo"]} + + +@mcp.tool() +def get_temperature(city: str) -> float: + """Get temperature as a simple float""" + return 22.5 + # Returns: {"result": 22.5} +``` + +_Full example: [examples/snippets/servers/structured_output.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/structured_output.py)_ +<!-- /snippet-source --> + +### Prompts + +Prompts are reusable templates that help LLMs interact with your server effectively: + +<!-- snippet-source examples/snippets/servers/basic_prompt.py --> +```python +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.prompts import base + +mcp = MCPServer(name="Prompt Example") + + +@mcp.prompt(title="Code Review") +def review_code(code: str) -> str: + return f"Please review this code:\n\n{code}" + + +@mcp.prompt(title="Debug Assistant") +def debug_error(error: str) -> list[base.Message]: + return [ + base.UserMessage("I'm seeing this error:"), + base.UserMessage(error), + base.AssistantMessage("I'll help debug that. What have you tried so far?"), + ] +``` + +_Full example: [examples/snippets/servers/basic_prompt.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/basic_prompt.py)_ +<!-- /snippet-source --> + +### Icons + +MCP servers can provide icons for UI display. Icons can be added to the server implementation, tools, resources, and prompts: + +```python +from mcp.server.mcpserver import MCPServer, Icon + +# Create an icon from a file path or URL +icon = Icon( + src="icon.png", + mimeType="image/png", + sizes="64x64" +) + +# Add icons to server +mcp = MCPServer( + "My Server", + website_url="https://example.com", + icons=[icon] +) + +# Add icons to tools, resources, and prompts +@mcp.tool(icons=[icon]) +def my_tool(): + """Tool with an icon.""" + return "result" + +@mcp.resource("demo://resource", icons=[icon]) +def my_resource(): + """Resource with an icon.""" + return "content" +``` + +_Full example: [examples/mcpserver/icons_demo.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/mcpserver/icons_demo.py)_ + +### Images + +MCPServer provides an `Image` class that automatically handles image data: + +<!-- snippet-source examples/snippets/servers/images.py --> +```python +"""Example showing image handling with MCPServer.""" + +from PIL import Image as PILImage + +from mcp.server.mcpserver import Image, MCPServer + +mcp = MCPServer("Image Example") + + +@mcp.tool() +def create_thumbnail(image_path: str) -> Image: + """Create a thumbnail from an image""" + img = PILImage.open(image_path) + img.thumbnail((100, 100)) + return Image(data=img.tobytes(), format="png") +``` + +_Full example: [examples/snippets/servers/images.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/images.py)_ +<!-- /snippet-source --> + +### Context + +The Context object is automatically injected into tool and resource functions that request it via type hints. It provides access to MCP capabilities like logging, progress reporting, resource reading, user interaction, and request metadata. + +#### Getting Context in Functions + +To use context in a tool or resource function, add a parameter with the `Context` type annotation: + +```python +from mcp.server.mcpserver import Context, MCPServer + +mcp = MCPServer(name="Context Example") + + +@mcp.tool() +async def my_tool(x: int, ctx: Context) -> str: + """Tool that uses context capabilities.""" + # The context parameter can have any name as long as it's type-annotated + return await process_with_context(x, ctx) +``` + +#### Context Properties and Methods + +The Context object provides the following capabilities: + +- `ctx.request_id` - Unique ID for the current request +- `ctx.client_id` - Client ID if available +- `ctx.mcp_server` - Access to the MCPServer server instance (see [MCPServer Properties](#mcpserver-properties)) +- `ctx.session` - Access to the underlying session for advanced communication (see [Session Properties and Methods](#session-properties-and-methods)) +- `ctx.request_context` - Access to request-specific data and lifespan resources (see [Request Context Properties](#request-context-properties)) +- `await ctx.debug(data)` - Send debug log message +- `await ctx.info(data)` - Send info log message +- `await ctx.warning(data)` - Send warning log message +- `await ctx.error(data)` - Send error log message +- `await ctx.log(level, data, logger_name=None)` - Send log with custom level +- `await ctx.report_progress(progress, total=None, message=None)` - Report operation progress +- `await ctx.read_resource(uri)` - Read a resource by URI +- `await ctx.elicit(message, schema)` - Request additional information from user with validation + +<!-- snippet-source examples/snippets/servers/tool_progress.py --> +```python +from mcp.server.mcpserver import Context, MCPServer + +mcp = MCPServer(name="Progress Example") + + +@mcp.tool() +async def long_running_task(task_name: str, ctx: Context, steps: int = 5) -> str: + """Execute a task with progress updates.""" + await ctx.info(f"Starting: {task_name}") + + for i in range(steps): + progress = (i + 1) / steps + await ctx.report_progress( + progress=progress, + total=1.0, + message=f"Step {i + 1}/{steps}", + ) + await ctx.debug(f"Completed step {i + 1}") + + return f"Task '{task_name}' completed" +``` + +_Full example: [examples/snippets/servers/tool_progress.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/tool_progress.py)_ +<!-- /snippet-source --> + +### Completions + +MCP supports providing completion suggestions for prompt arguments and resource template parameters. With the context parameter, servers can provide completions based on previously resolved values: + +Client usage: + +<!-- snippet-source examples/snippets/clients/completion_client.py --> +```python +"""cd to the `examples/snippets` directory and run: +uv run completion-client +""" + +import asyncio +import os + +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client +from mcp.types import PromptReference, ResourceTemplateReference + +# Create server parameters for stdio connection +server_params = StdioServerParameters( + command="uv", # Using uv to run the server + args=["run", "server", "completion", "stdio"], # Server with completion support + env={"UV_INDEX": os.environ.get("UV_INDEX", "")}, +) + + +async def run(): + """Run the completion client example.""" + async with stdio_client(server_params) as (read, write): + async with ClientSession(read, write) as session: + # Initialize the connection + await session.initialize() + + # List available resource templates + templates = await session.list_resource_templates() + print("Available resource templates:") + for template in templates.resource_templates: + print(f" - {template.uri_template}") + + # List available prompts + prompts = await session.list_prompts() + print("\nAvailable prompts:") + for prompt in prompts.prompts: + print(f" - {prompt.name}") + + # Complete resource template arguments + if templates.resource_templates: + template = templates.resource_templates[0] + print(f"\nCompleting arguments for resource template: {template.uri_template}") + + # Complete without context + result = await session.complete( + ref=ResourceTemplateReference(type="ref/resource", uri=template.uri_template), + argument={"name": "owner", "value": "model"}, + ) + print(f"Completions for 'owner' starting with 'model': {result.completion.values}") + + # Complete with context - repo suggestions based on owner + result = await session.complete( + ref=ResourceTemplateReference(type="ref/resource", uri=template.uri_template), + argument={"name": "repo", "value": ""}, + context_arguments={"owner": "modelcontextprotocol"}, + ) + print(f"Completions for 'repo' with owner='modelcontextprotocol': {result.completion.values}") + + # Complete prompt arguments + if prompts.prompts: + prompt_name = prompts.prompts[0].name + print(f"\nCompleting arguments for prompt: {prompt_name}") + + result = await session.complete( + ref=PromptReference(type="ref/prompt", name=prompt_name), + argument={"name": "style", "value": ""}, + ) + print(f"Completions for 'style' argument: {result.completion.values}") + + +def main(): + """Entry point for the completion client.""" + asyncio.run(run()) + + +if __name__ == "__main__": + main() +``` + +_Full example: [examples/snippets/clients/completion_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/completion_client.py)_ +<!-- /snippet-source --> +### Elicitation + +Request additional information from users. This example shows an Elicitation during a Tool Call: + +<!-- snippet-source examples/snippets/servers/elicitation.py --> +```python +"""Elicitation examples demonstrating form and URL mode elicitation. + +Form mode elicitation collects structured, non-sensitive data through a schema. +URL mode elicitation directs users to external URLs for sensitive operations +like OAuth flows, credential collection, or payment processing. +""" + +import uuid + +from pydantic import BaseModel, Field + +from mcp.server.mcpserver import Context, MCPServer +from mcp.shared.exceptions import UrlElicitationRequiredError +from mcp.types import ElicitRequestURLParams + +mcp = MCPServer(name="Elicitation Example") + + +class BookingPreferences(BaseModel): + """Schema for collecting user preferences.""" + + checkAlternative: bool = Field(description="Would you like to check another date?") + alternativeDate: str = Field( + default="2024-12-26", + description="Alternative date (YYYY-MM-DD)", + ) + + +@mcp.tool() +async def book_table(date: str, time: str, party_size: int, ctx: Context) -> str: + """Book a table with date availability check. + + This demonstrates form mode elicitation for collecting non-sensitive user input. + """ + # Check if date is available + if date == "2024-12-25": + # Date unavailable - ask user for alternative + result = await ctx.elicit( + message=(f"No tables available for {party_size} on {date}. Would you like to try another date?"), + schema=BookingPreferences, + ) + + if result.action == "accept" and result.data: + if result.data.checkAlternative: + return f"[SUCCESS] Booked for {result.data.alternativeDate}" + return "[CANCELLED] No booking made" + return "[CANCELLED] Booking cancelled" + + # Date available + return f"[SUCCESS] Booked for {date} at {time}" + + +@mcp.tool() +async def secure_payment(amount: float, ctx: Context) -> str: + """Process a secure payment requiring URL confirmation. + + This demonstrates URL mode elicitation using ctx.elicit_url() for + operations that require out-of-band user interaction. + """ + elicitation_id = str(uuid.uuid4()) + + result = await ctx.elicit_url( + message=f"Please confirm payment of ${amount:.2f}", + url=f"https://payments.example.com/confirm?amount={amount}&id={elicitation_id}", + elicitation_id=elicitation_id, + ) + + if result.action == "accept": + # In a real app, the payment confirmation would happen out-of-band + # and you'd verify the payment status from your backend + return f"Payment of ${amount:.2f} initiated - check your browser to complete" + elif result.action == "decline": + return "Payment declined by user" + return "Payment cancelled" + + +@mcp.tool() +async def connect_service(service_name: str, ctx: Context) -> str: + """Connect to a third-party service requiring OAuth authorization. + + This demonstrates the "throw error" pattern using UrlElicitationRequiredError. + Use this pattern when the tool cannot proceed without user authorization. + """ + elicitation_id = str(uuid.uuid4()) + + # Raise UrlElicitationRequiredError to signal that the client must complete + # a URL elicitation before this request can be processed. + # The MCP framework will convert this to a -32042 error response. + raise UrlElicitationRequiredError( + [ + ElicitRequestURLParams( + mode="url", + message=f"Authorization required to connect to {service_name}", + url=f"https://{service_name}.example.com/oauth/authorize?elicit={elicitation_id}", + elicitation_id=elicitation_id, + ) + ] + ) +``` + +_Full example: [examples/snippets/servers/elicitation.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/elicitation.py)_ +<!-- /snippet-source --> + +Elicitation schemas support default values for all field types. Default values are automatically included in the JSON schema sent to clients, allowing them to pre-populate forms. + +The `elicit()` method returns an `ElicitationResult` with: + +- `action`: "accept", "decline", or "cancel" +- `data`: The validated response (only when accepted) +- `validation_error`: Any validation error message + +### Sampling + +Tools can interact with LLMs through sampling (generating text): + +<!-- snippet-source examples/snippets/servers/sampling.py --> +```python +from mcp.server.mcpserver import Context, MCPServer +from mcp.types import SamplingMessage, TextContent + +mcp = MCPServer(name="Sampling Example") + + +@mcp.tool() +async def generate_poem(topic: str, ctx: Context) -> str: + """Generate a poem using LLM sampling.""" + prompt = f"Write a short poem about {topic}" + + result = await ctx.session.create_message( + messages=[ + SamplingMessage( + role="user", + content=TextContent(type="text", text=prompt), + ) + ], + max_tokens=100, + ) + + # Since we're not passing tools param, result.content is single content + if result.content.type == "text": + return result.content.text + return str(result.content) +``` + +_Full example: [examples/snippets/servers/sampling.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/sampling.py)_ +<!-- /snippet-source --> + +### Logging and Notifications + +Tools can send logs and notifications through the context: + +<!-- snippet-source examples/snippets/servers/notifications.py --> +```python +from mcp.server.mcpserver import Context, MCPServer + +mcp = MCPServer(name="Notifications Example") + + +@mcp.tool() +async def process_data(data: str, ctx: Context) -> str: + """Process data with logging.""" + # Different log levels + await ctx.debug(f"Debug: Processing '{data}'") + await ctx.info("Info: Starting processing") + await ctx.warning("Warning: This is experimental") + await ctx.error("Error: (This is just a demo)") + + # Notify about resource changes + await ctx.session.send_resource_list_changed() + + return f"Processed: {data}" +``` + +_Full example: [examples/snippets/servers/notifications.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/notifications.py)_ +<!-- /snippet-source --> + +### Authentication + +Authentication can be used by servers that want to expose tools accessing protected resources. + +`mcp.server.auth` implements OAuth 2.1 resource server functionality, where MCP servers act as Resource Servers (RS) that validate tokens issued by separate Authorization Servers (AS). This follows the [MCP authorization specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization) and implements RFC 9728 (Protected Resource Metadata) for AS discovery. + +MCP servers can use authentication by providing an implementation of the `TokenVerifier` protocol: + +<!-- snippet-source examples/snippets/servers/oauth_server.py --> +```python +"""Run from the repository root: +uv run examples/snippets/servers/oauth_server.py +""" + +from pydantic import AnyHttpUrl + +from mcp.server.auth.provider import AccessToken, TokenVerifier +from mcp.server.auth.settings import AuthSettings +from mcp.server.mcpserver import MCPServer + + +class SimpleTokenVerifier(TokenVerifier): + """Simple token verifier for demonstration.""" + + async def verify_token(self, token: str) -> AccessToken | None: + pass # This is where you would implement actual token validation + + +# Create MCPServer instance as a Resource Server +mcp = MCPServer( + "Weather Service", + # Token verifier for authentication + token_verifier=SimpleTokenVerifier(), + # Auth settings for RFC 9728 Protected Resource Metadata + auth=AuthSettings( + issuer_url=AnyHttpUrl("https://auth.example.com"), # Authorization Server URL + resource_server_url=AnyHttpUrl("http://localhost:3001"), # This server's URL + required_scopes=["user"], + ), +) + + +@mcp.tool() +async def get_weather(city: str = "London") -> dict[str, str]: + """Get weather data for a city""" + return { + "city": city, + "temperature": "22", + "condition": "Partly cloudy", + "humidity": "65%", + } + + +if __name__ == "__main__": + mcp.run(transport="streamable-http", json_response=True) +``` + +_Full example: [examples/snippets/servers/oauth_server.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/oauth_server.py)_ +<!-- /snippet-source --> + +For a complete example with separate Authorization Server and Resource Server implementations, see [`examples/servers/simple-auth/`](examples/servers/simple-auth/). + +**Architecture:** + +- **Authorization Server (AS)**: Handles OAuth flows, user authentication, and token issuance +- **Resource Server (RS)**: Your MCP server that validates tokens and serves protected resources +- **Client**: Discovers AS through RFC 9728, obtains tokens, and uses them with the MCP server + +See [TokenVerifier](src/mcp/server/auth/provider.py) for more details on implementing token validation. + +### MCPServer Properties + +The MCPServer server instance accessible via `ctx.mcp_server` provides access to server configuration and metadata: + +- `ctx.mcp_server.name` - The server's name as defined during initialization +- `ctx.mcp_server.instructions` - Server instructions/description provided to clients +- `ctx.mcp_server.website_url` - Optional website URL for the server +- `ctx.mcp_server.icons` - Optional list of icons for UI display +- `ctx.mcp_server.settings` - Complete server configuration object containing: + - `debug` - Debug mode flag + - `log_level` - Current logging level + - `host` and `port` - Server network configuration + - `sse_path`, `streamable_http_path` - Transport paths + - `stateless_http` - Whether the server operates in stateless mode + - And other configuration options + +```python +@mcp.tool() +def server_info(ctx: Context) -> dict: + """Get information about the current server.""" + return { + "name": ctx.mcp_server.name, + "instructions": ctx.mcp_server.instructions, + "debug_mode": ctx.mcp_server.settings.debug, + "log_level": ctx.mcp_server.settings.log_level, + "host": ctx.mcp_server.settings.host, + "port": ctx.mcp_server.settings.port, + } +``` + +### Session Properties and Methods + +The session object accessible via `ctx.session` provides advanced control over client communication: + +- `ctx.session.client_params` - Client initialization parameters and declared capabilities +- `await ctx.session.send_log_message(level, data, logger)` - Send log messages with full control +- `await ctx.session.create_message(messages, max_tokens)` - Request LLM sampling/completion +- `await ctx.session.send_progress_notification(token, progress, total, message)` - Direct progress updates +- `await ctx.session.send_resource_updated(uri)` - Notify clients that a specific resource changed +- `await ctx.session.send_resource_list_changed()` - Notify clients that the resource list changed +- `await ctx.session.send_tool_list_changed()` - Notify clients that the tool list changed +- `await ctx.session.send_prompt_list_changed()` - Notify clients that the prompt list changed + +```python +@mcp.tool() +async def notify_data_update(resource_uri: str, ctx: Context) -> str: + """Update data and notify clients of the change.""" + # Perform data update logic here + + # Notify clients that this specific resource changed + await ctx.session.send_resource_updated(AnyUrl(resource_uri)) + + # If this affects the overall resource list, notify about that too + await ctx.session.send_resource_list_changed() + + return f"Updated {resource_uri} and notified clients" +``` + +### Request Context Properties + +The request context accessible via `ctx.request_context` contains request-specific information and resources: + +- `ctx.request_context.lifespan_context` - Access to resources initialized during server startup + - Database connections, configuration objects, shared services + - Type-safe access to resources defined in your server's lifespan function +- `ctx.request_context.meta` - Request metadata from the client including: + - `progressToken` - Token for progress notifications + - Other client-provided metadata +- `ctx.request_context.request` - The original MCP request object for advanced processing +- `ctx.request_context.request_id` - Unique identifier for this request + +```python +# Example with typed lifespan context +@dataclass +class AppContext: + db: Database + config: AppConfig + +@mcp.tool() +def query_with_config(query: str, ctx: Context) -> str: + """Execute a query using shared database and configuration.""" + # Access typed lifespan context + app_ctx: AppContext = ctx.request_context.lifespan_context + + # Use shared resources + connection = app_ctx.db + settings = app_ctx.config + + # Execute query with configuration + result = connection.execute(query, timeout=settings.query_timeout) + return str(result) +``` + +_Full lifespan example: [examples/snippets/servers/lifespan_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lifespan_example.py)_ + +## Running Your Server + +### Development Mode + +The fastest way to test and debug your server is with the MCP Inspector: + +```bash +uv run mcp dev server.py + +# Add dependencies +uv run mcp dev server.py --with pandas --with numpy + +# Mount local code +uv run mcp dev server.py --with-editable . +``` + +### Claude Desktop Integration + +Once your server is ready, install it in Claude Desktop: + +```bash +uv run mcp install server.py + +# Custom name +uv run mcp install server.py --name "My Analytics Server" + +# Environment variables +uv run mcp install server.py -v API_KEY=abc123 -v DB_URL=postgres://... +uv run mcp install server.py -f .env +``` + +### Direct Execution + +For advanced scenarios like custom deployments: + +<!-- snippet-source examples/snippets/servers/direct_execution.py --> +```python +"""Example showing direct execution of an MCP server. + +This is the simplest way to run an MCP server directly. +cd to the `examples/snippets` directory and run: + uv run direct-execution-server + or + python servers/direct_execution.py +""" + +from mcp.server.mcpserver import MCPServer + +mcp = MCPServer("My App") + + +@mcp.tool() +def hello(name: str = "World") -> str: + """Say hello to someone.""" + return f"Hello, {name}!" + + +def main(): + """Entry point for the direct execution server.""" + mcp.run() + + +if __name__ == "__main__": + main() +``` + +_Full example: [examples/snippets/servers/direct_execution.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/direct_execution.py)_ +<!-- /snippet-source --> + +Run it with: + +```bash +python servers/direct_execution.py +# or +uv run mcp run servers/direct_execution.py +``` + +Note that `uv run mcp run` or `uv run mcp dev` only supports server using MCPServer and not the low-level server variant. + +### Streamable HTTP Transport + +> **Note**: Streamable HTTP transport is the recommended transport for production deployments. Use `stateless_http=True` and `json_response=True` for optimal scalability. + +<!-- snippet-source examples/snippets/servers/streamable_config.py --> +```python +"""Run from the repository root: +uv run examples/snippets/servers/streamable_config.py +""" + +from mcp.server.mcpserver import MCPServer + +mcp = MCPServer("StatelessServer") + + +# Add a simple tool to demonstrate the server +@mcp.tool() +def greet(name: str = "World") -> str: + """Greet someone by name.""" + return f"Hello, {name}!" + + +# Run server with streamable_http transport +# Transport-specific options (stateless_http, json_response) are passed to run() +if __name__ == "__main__": + # Stateless server with JSON responses (recommended) + mcp.run(transport="streamable-http", stateless_http=True, json_response=True) + + # Other configuration options: + # Stateless server with SSE streaming responses + # mcp.run(transport="streamable-http", stateless_http=True) + + # Stateful server with session persistence + # mcp.run(transport="streamable-http") +``` + +_Full example: [examples/snippets/servers/streamable_config.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_config.py)_ +<!-- /snippet-source --> + +You can mount multiple MCPServer servers in a Starlette application: + +<!-- snippet-source examples/snippets/servers/streamable_starlette_mount.py --> +```python +"""Run from the repository root: +uvicorn examples.snippets.servers.streamable_starlette_mount:app --reload +""" + +import contextlib + +from starlette.applications import Starlette +from starlette.routing import Mount + +from mcp.server.mcpserver import MCPServer + +# Create the Echo server +echo_mcp = MCPServer(name="EchoServer") + + +@echo_mcp.tool() +def echo(message: str) -> str: + """A simple echo tool""" + return f"Echo: {message}" + + +# Create the Math server +math_mcp = MCPServer(name="MathServer") + + +@math_mcp.tool() +def add_two(n: int) -> int: + """Tool to add two to the input""" + return n + 2 + + +# Create a combined lifespan to manage both session managers +@contextlib.asynccontextmanager +async def lifespan(app: Starlette): + async with contextlib.AsyncExitStack() as stack: + await stack.enter_async_context(echo_mcp.session_manager.run()) + await stack.enter_async_context(math_mcp.session_manager.run()) + yield + + +# Create the Starlette app and mount the MCP servers +app = Starlette( + routes=[ + Mount("/echo", echo_mcp.streamable_http_app(stateless_http=True, json_response=True)), + Mount("/math", math_mcp.streamable_http_app(stateless_http=True, json_response=True)), + ], + lifespan=lifespan, +) + +# Note: Clients connect to http://localhost:8000/echo/mcp and http://localhost:8000/math/mcp +# To mount at the root of each path (e.g., /echo instead of /echo/mcp): +# echo_mcp.streamable_http_app(streamable_http_path="/", stateless_http=True, json_response=True) +# math_mcp.streamable_http_app(streamable_http_path="/", stateless_http=True, json_response=True) +``` + +_Full example: [examples/snippets/servers/streamable_starlette_mount.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_starlette_mount.py)_ +<!-- /snippet-source --> + +For low level server with Streamable HTTP implementations, see: + +- Stateful server: [`examples/servers/simple-streamablehttp/`](examples/servers/simple-streamablehttp/) +- Stateless server: [`examples/servers/simple-streamablehttp-stateless/`](examples/servers/simple-streamablehttp-stateless/) + +The streamable HTTP transport supports: + +- Stateful and stateless operation modes +- Resumability with event stores +- JSON or SSE response formats +- Better scalability for multi-node deployments + +#### CORS Configuration for Browser-Based Clients + +If you'd like your server to be accessible by browser-based MCP clients, you'll need to configure CORS headers. The `Mcp-Session-Id` header must be exposed for browser clients to access it: + +```python +from starlette.applications import Starlette +from starlette.middleware.cors import CORSMiddleware + +# Create your Starlette app first +starlette_app = Starlette(routes=[...]) + +# Then wrap it with CORS middleware +starlette_app = CORSMiddleware( + starlette_app, + allow_origins=["*"], # Configure appropriately for production + allow_methods=["GET", "POST", "DELETE"], # MCP streamable HTTP methods + expose_headers=["Mcp-Session-Id"], +) +``` + +This configuration is necessary because: + +- The MCP streamable HTTP transport uses the `Mcp-Session-Id` header for session management +- Browsers restrict access to response headers unless explicitly exposed via CORS +- Without this configuration, browser-based clients won't be able to read the session ID from initialization responses + +### Mounting to an Existing ASGI Server + +By default, SSE servers are mounted at `/sse` and Streamable HTTP servers are mounted at `/mcp`. You can customize these paths using the methods described below. + +For more information on mounting applications in Starlette, see the [Starlette documentation](https://www.starlette.io/routing/#submounting-routes). + +#### StreamableHTTP servers + +You can mount the StreamableHTTP server to an existing ASGI server using the `streamable_http_app` method. This allows you to integrate the StreamableHTTP server with other ASGI applications. + +##### Basic mounting + +<!-- snippet-source examples/snippets/servers/streamable_http_basic_mounting.py --> +```python +"""Basic example showing how to mount StreamableHTTP server in Starlette. + +Run from the repository root: + uvicorn examples.snippets.servers.streamable_http_basic_mounting:app --reload +""" + +import contextlib + +from starlette.applications import Starlette +from starlette.routing import Mount + +from mcp.server.mcpserver import MCPServer + +# Create MCP server +mcp = MCPServer("My App") + + +@mcp.tool() +def hello() -> str: + """A simple hello tool""" + return "Hello from MCP!" + + +# Create a lifespan context manager to run the session manager +@contextlib.asynccontextmanager +async def lifespan(app: Starlette): + async with mcp.session_manager.run(): + yield + + +# Mount the StreamableHTTP server to the existing ASGI server +# Transport-specific options are passed to streamable_http_app() +app = Starlette( + routes=[ + Mount("/", app=mcp.streamable_http_app(json_response=True)), + ], + lifespan=lifespan, +) +``` + +_Full example: [examples/snippets/servers/streamable_http_basic_mounting.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_http_basic_mounting.py)_ +<!-- /snippet-source --> + +##### Host-based routing + +<!-- snippet-source examples/snippets/servers/streamable_http_host_mounting.py --> +```python +"""Example showing how to mount StreamableHTTP server using Host-based routing. + +Run from the repository root: + uvicorn examples.snippets.servers.streamable_http_host_mounting:app --reload +""" + +import contextlib + +from starlette.applications import Starlette +from starlette.routing import Host + +from mcp.server.mcpserver import MCPServer + +# Create MCP server +mcp = MCPServer("MCP Host App") + + +@mcp.tool() +def domain_info() -> str: + """Get domain-specific information""" + return "This is served from mcp.acme.corp" + + +# Create a lifespan context manager to run the session manager +@contextlib.asynccontextmanager +async def lifespan(app: Starlette): + async with mcp.session_manager.run(): + yield + + +# Mount using Host-based routing +# Transport-specific options are passed to streamable_http_app() +app = Starlette( + routes=[ + Host("mcp.acme.corp", app=mcp.streamable_http_app(json_response=True)), + ], + lifespan=lifespan, +) +``` + +_Full example: [examples/snippets/servers/streamable_http_host_mounting.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_http_host_mounting.py)_ +<!-- /snippet-source --> + +##### Multiple servers with path configuration + +<!-- snippet-source examples/snippets/servers/streamable_http_multiple_servers.py --> +```python +"""Example showing how to mount multiple StreamableHTTP servers with path configuration. + +Run from the repository root: + uvicorn examples.snippets.servers.streamable_http_multiple_servers:app --reload +""" + +import contextlib + +from starlette.applications import Starlette +from starlette.routing import Mount + +from mcp.server.mcpserver import MCPServer + +# Create multiple MCP servers +api_mcp = MCPServer("API Server") +chat_mcp = MCPServer("Chat Server") + + +@api_mcp.tool() +def api_status() -> str: + """Get API status""" + return "API is running" + + +@chat_mcp.tool() +def send_message(message: str) -> str: + """Send a chat message""" + return f"Message sent: {message}" + + +# Create a combined lifespan to manage both session managers +@contextlib.asynccontextmanager +async def lifespan(app: Starlette): + async with contextlib.AsyncExitStack() as stack: + await stack.enter_async_context(api_mcp.session_manager.run()) + await stack.enter_async_context(chat_mcp.session_manager.run()) + yield + + +# Mount the servers with transport-specific options passed to streamable_http_app() +# streamable_http_path="/" means endpoints will be at /api and /chat instead of /api/mcp and /chat/mcp +app = Starlette( + routes=[ + Mount("/api", app=api_mcp.streamable_http_app(json_response=True, streamable_http_path="/")), + Mount("/chat", app=chat_mcp.streamable_http_app(json_response=True, streamable_http_path="/")), + ], + lifespan=lifespan, +) +``` + +_Full example: [examples/snippets/servers/streamable_http_multiple_servers.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_http_multiple_servers.py)_ +<!-- /snippet-source --> + +##### Path configuration at initialization + +<!-- snippet-source examples/snippets/servers/streamable_http_path_config.py --> +```python +"""Example showing path configuration when mounting MCPServer. + +Run from the repository root: + uvicorn examples.snippets.servers.streamable_http_path_config:app --reload +""" + +from starlette.applications import Starlette +from starlette.routing import Mount + +from mcp.server.mcpserver import MCPServer + +# Create a simple MCPServer server +mcp_at_root = MCPServer("My Server") + + +@mcp_at_root.tool() +def process_data(data: str) -> str: + """Process some data""" + return f"Processed: {data}" + + +# Mount at /process with streamable_http_path="/" so the endpoint is /process (not /process/mcp) +# Transport-specific options like json_response are passed to streamable_http_app() +app = Starlette( + routes=[ + Mount( + "/process", + app=mcp_at_root.streamable_http_app(json_response=True, streamable_http_path="/"), + ), + ] +) +``` + +_Full example: [examples/snippets/servers/streamable_http_path_config.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/streamable_http_path_config.py)_ +<!-- /snippet-source --> + +#### SSE servers + +> **Note**: SSE transport is being superseded by [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http). + +You can mount the SSE server to an existing ASGI server using the `sse_app` method. This allows you to integrate the SSE server with other ASGI applications. + +```python +from starlette.applications import Starlette +from starlette.routing import Mount, Host +from mcp.server.mcpserver import MCPServer + + +mcp = MCPServer("My App") + +# Mount the SSE server to the existing ASGI server +app = Starlette( + routes=[ + Mount('/', app=mcp.sse_app()), + ] +) + +# or dynamically mount as host +app.router.routes.append(Host('mcp.acme.corp', app=mcp.sse_app())) +``` + +You can also mount multiple MCP servers at different sub-paths. The SSE transport automatically detects the mount path via ASGI's `root_path` mechanism, so message endpoints are correctly routed: + +```python +from starlette.applications import Starlette +from starlette.routing import Mount +from mcp.server.mcpserver import MCPServer + +# Create multiple MCP servers +github_mcp = MCPServer("GitHub API") +browser_mcp = MCPServer("Browser") +search_mcp = MCPServer("Search") + +# Mount each server at its own sub-path +# The SSE transport automatically uses ASGI's root_path to construct +# the correct message endpoint (e.g., /github/messages/, /browser/messages/) +app = Starlette( + routes=[ + Mount("/github", app=github_mcp.sse_app()), + Mount("/browser", app=browser_mcp.sse_app()), + Mount("/search", app=search_mcp.sse_app()), + ] +) +``` + +For more information on mounting applications in Starlette, see the [Starlette documentation](https://www.starlette.io/routing/#submounting-routes). + +## Advanced Usage + +### Low-Level Server + +For more control, you can use the low-level server implementation directly. This gives you full access to the protocol and allows you to customize every aspect of your server, including lifecycle management through the lifespan API: + +<!-- snippet-source examples/snippets/servers/lowlevel/lifespan.py --> +```python +"""Run from the repository root: +uv run examples/snippets/servers/lowlevel/lifespan.py +""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import TypedDict + +import mcp.server.stdio +from mcp import types +from mcp.server import Server, ServerRequestContext + + +# Mock database class for example +class Database: + """Mock database class for example.""" + + @classmethod + async def connect(cls) -> "Database": + """Connect to database.""" + print("Database connected") + return cls() + + async def disconnect(self) -> None: + """Disconnect from database.""" + print("Database disconnected") + + async def query(self, query_str: str) -> list[dict[str, str]]: + """Execute a query.""" + # Simulate database query + return [{"id": "1", "name": "Example", "query": query_str}] + + +class AppContext(TypedDict): + db: Database + + +@asynccontextmanager +async def server_lifespan(_server: Server[AppContext]) -> AsyncIterator[AppContext]: + """Manage server startup and shutdown lifecycle.""" + db = await Database.connect() + try: + yield {"db": db} + finally: + await db.disconnect() + + +async def handle_list_tools( + ctx: ServerRequestContext[AppContext], params: types.PaginatedRequestParams | None +) -> types.ListToolsResult: + """List available tools.""" + return types.ListToolsResult( + tools=[ + types.Tool( + name="query_db", + description="Query the database", + input_schema={ + "type": "object", + "properties": {"query": {"type": "string", "description": "SQL query to execute"}}, + "required": ["query"], + }, + ) + ] + ) + + +async def handle_call_tool( + ctx: ServerRequestContext[AppContext], params: types.CallToolRequestParams +) -> types.CallToolResult: + """Handle database query tool call.""" + if params.name != "query_db": + raise ValueError(f"Unknown tool: {params.name}") + + db = ctx.lifespan_context["db"] + results = await db.query((params.arguments or {})["query"]) + + return types.CallToolResult(content=[types.TextContent(type="text", text=f"Query results: {results}")]) + + +server = Server( + "example-server", + lifespan=server_lifespan, + on_list_tools=handle_list_tools, + on_call_tool=handle_call_tool, +) + + +async def run(): + """Run the server with lifespan management.""" + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + server.create_initialization_options(), + ) + + +if __name__ == "__main__": + import asyncio + + asyncio.run(run()) +``` + +_Full example: [examples/snippets/servers/lowlevel/lifespan.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lowlevel/lifespan.py)_ +<!-- /snippet-source --> + +The lifespan API provides: + +- A way to initialize resources when the server starts and clean them up when it stops +- Access to initialized resources through the request context in handlers +- Type-safe context passing between lifespan and request handlers + +<!-- snippet-source examples/snippets/servers/lowlevel/basic.py --> +```python +"""Run from the repository root: +uv run examples/snippets/servers/lowlevel/basic.py +""" + +import asyncio + +import mcp.server.stdio +from mcp import types +from mcp.server import Server, ServerRequestContext + + +async def handle_list_prompts( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListPromptsResult: + """List available prompts.""" + return types.ListPromptsResult( + prompts=[ + types.Prompt( + name="example-prompt", + description="An example prompt template", + arguments=[types.PromptArgument(name="arg1", description="Example argument", required=True)], + ) + ] + ) + + +async def handle_get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> types.GetPromptResult: + """Get a specific prompt by name.""" + if params.name != "example-prompt": + raise ValueError(f"Unknown prompt: {params.name}") + + arg1_value = (params.arguments or {}).get("arg1", "default") + + return types.GetPromptResult( + description="Example prompt", + messages=[ + types.PromptMessage( + role="user", + content=types.TextContent(type="text", text=f"Example prompt text with argument: {arg1_value}"), + ) + ], + ) + + +server = Server( + "example-server", + on_list_prompts=handle_list_prompts, + on_get_prompt=handle_get_prompt, +) + + +async def run(): + """Run the basic low-level server.""" + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + server.create_initialization_options(), + ) + + +if __name__ == "__main__": + asyncio.run(run()) +``` + +_Full example: [examples/snippets/servers/lowlevel/basic.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lowlevel/basic.py)_ +<!-- /snippet-source --> + +Caution: The `uv run mcp run` and `uv run mcp dev` tool doesn't support low-level server. + +#### Structured Output Support + +The low-level server supports structured output for tools, allowing you to return both human-readable content and machine-readable structured data. Tools can define an `outputSchema` to validate their structured output: + +<!-- snippet-source examples/snippets/servers/lowlevel/structured_output.py --> +```python +"""Run from the repository root: +uv run examples/snippets/servers/lowlevel/structured_output.py +""" + +import asyncio +import json + +import mcp.server.stdio +from mcp import types +from mcp.server import Server, ServerRequestContext + + +async def handle_list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListToolsResult: + """List available tools with structured output schemas.""" + return types.ListToolsResult( + tools=[ + types.Tool( + name="get_weather", + description="Get current weather for a city", + input_schema={ + "type": "object", + "properties": {"city": {"type": "string", "description": "City name"}}, + "required": ["city"], + }, + output_schema={ + "type": "object", + "properties": { + "temperature": {"type": "number", "description": "Temperature in Celsius"}, + "condition": {"type": "string", "description": "Weather condition"}, + "humidity": {"type": "number", "description": "Humidity percentage"}, + "city": {"type": "string", "description": "City name"}, + }, + "required": ["temperature", "condition", "humidity", "city"], + }, + ) + ] + ) + + +async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: + """Handle tool calls with structured output.""" + if params.name == "get_weather": + city = (params.arguments or {})["city"] + + weather_data = { + "temperature": 22.5, + "condition": "partly cloudy", + "humidity": 65, + "city": city, + } + + return types.CallToolResult( + content=[types.TextContent(type="text", text=json.dumps(weather_data, indent=2))], + structured_content=weather_data, + ) + + raise ValueError(f"Unknown tool: {params.name}") + + +server = Server( + "example-server", + on_list_tools=handle_list_tools, + on_call_tool=handle_call_tool, +) + + +async def run(): + """Run the structured output server.""" + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + server.create_initialization_options(), + ) + + +if __name__ == "__main__": + asyncio.run(run()) +``` + +_Full example: [examples/snippets/servers/lowlevel/structured_output.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lowlevel/structured_output.py)_ +<!-- /snippet-source --> + +With the low-level server, handlers always return `CallToolResult` directly. You construct both the human-readable `content` and the machine-readable `structured_content` yourself, giving you full control over the response. + +##### Returning CallToolResult with `_meta` + +For passing data to client applications without exposing it to the model, use the `_meta` field on `CallToolResult`: + +<!-- snippet-source examples/snippets/servers/lowlevel/direct_call_tool_result.py --> +```python +"""Run from the repository root: +uv run examples/snippets/servers/lowlevel/direct_call_tool_result.py +""" + +import asyncio + +import mcp.server.stdio +from mcp import types +from mcp.server import Server, ServerRequestContext + + +async def handle_list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListToolsResult: + """List available tools.""" + return types.ListToolsResult( + tools=[ + types.Tool( + name="advanced_tool", + description="Tool with full control including _meta field", + input_schema={ + "type": "object", + "properties": {"message": {"type": "string"}}, + "required": ["message"], + }, + ) + ] + ) + + +async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: + """Handle tool calls by returning CallToolResult directly.""" + if params.name == "advanced_tool": + message = (params.arguments or {}).get("message", "") + return types.CallToolResult( + content=[types.TextContent(type="text", text=f"Processed: {message}")], + structured_content={"result": "success", "message": message}, + _meta={"hidden": "data for client applications only"}, + ) + + raise ValueError(f"Unknown tool: {params.name}") + + +server = Server( + "example-server", + on_list_tools=handle_list_tools, + on_call_tool=handle_call_tool, +) + + +async def run(): + """Run the server.""" + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + server.create_initialization_options(), + ) + + +if __name__ == "__main__": + asyncio.run(run()) +``` + +_Full example: [examples/snippets/servers/lowlevel/direct_call_tool_result.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/lowlevel/direct_call_tool_result.py)_ +<!-- /snippet-source --> + +### Pagination (Advanced) + +For servers that need to handle large datasets, the low-level server provides paginated versions of list operations. This is an optional optimization - most servers won't need pagination unless they're dealing with hundreds or thousands of items. + +#### Server-side Implementation + +<!-- snippet-source examples/snippets/servers/pagination_example.py --> +```python +"""Example of implementing pagination with the low-level MCP server.""" + +from mcp import types +from mcp.server import Server, ServerRequestContext + +# Sample data to paginate +ITEMS = [f"Item {i}" for i in range(1, 101)] # 100 items + + +async def handle_list_resources( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListResourcesResult: + """List resources with pagination support.""" + page_size = 10 + + # Extract cursor from request params + cursor = params.cursor if params is not None else None + + # Parse cursor to get offset + start = 0 if cursor is None else int(cursor) + end = start + page_size + + # Get page of resources + page_items = [ + types.Resource(uri=f"resource://items/{item}", name=item, description=f"Description for {item}") + for item in ITEMS[start:end] + ] + + # Determine next cursor + next_cursor = str(end) if end < len(ITEMS) else None + + return types.ListResourcesResult(resources=page_items, next_cursor=next_cursor) + + +server = Server("paginated-server", on_list_resources=handle_list_resources) +``` + +_Full example: [examples/snippets/servers/pagination_example.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/servers/pagination_example.py)_ +<!-- /snippet-source --> + +#### Client-side Consumption + +<!-- snippet-source examples/snippets/clients/pagination_client.py --> +```python +"""Example of consuming paginated MCP endpoints from a client.""" + +import asyncio + +from mcp.client.session import ClientSession +from mcp.client.stdio import StdioServerParameters, stdio_client +from mcp.types import PaginatedRequestParams, Resource + + +async def list_all_resources() -> None: + """Fetch all resources using pagination.""" + async with stdio_client(StdioServerParameters(command="uv", args=["run", "mcp-simple-pagination"])) as ( + read, + write, + ): + async with ClientSession(read, write) as session: + await session.initialize() + + all_resources: list[Resource] = [] + cursor = None + + while True: + # Fetch a page of resources + result = await session.list_resources(params=PaginatedRequestParams(cursor=cursor)) + all_resources.extend(result.resources) + + print(f"Fetched {len(result.resources)} resources") + + # Check if there are more pages + if result.next_cursor: + cursor = result.next_cursor + else: + break + + print(f"Total resources: {len(all_resources)}") + + +if __name__ == "__main__": + asyncio.run(list_all_resources()) +``` + +_Full example: [examples/snippets/clients/pagination_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/pagination_client.py)_ +<!-- /snippet-source --> + +#### Key Points + +- **Cursors are opaque strings** - the server defines the format (numeric offsets, timestamps, etc.) +- **Return `nextCursor=None`** when there are no more pages +- **Backward compatible** - clients that don't support pagination will still work (they'll just get the first page) +- **Flexible page sizes** - Each endpoint can define its own page size based on data characteristics + +See the [simple-pagination example](examples/servers/simple-pagination) for a complete implementation. + +### Writing MCP Clients + +The SDK provides a high-level client interface for connecting to MCP servers using various [transports](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports): + +<!-- snippet-source examples/snippets/clients/stdio_client.py --> +```python +"""cd to the `examples/snippets/clients` directory and run: +uv run client +""" + +import asyncio +import os + +from mcp import ClientSession, StdioServerParameters, types +from mcp.client.context import ClientRequestContext +from mcp.client.stdio import stdio_client + +# Create server parameters for stdio connection +server_params = StdioServerParameters( + command="uv", # Using uv to run the server + args=["run", "server", "mcpserver_quickstart", "stdio"], # We're already in snippets dir + env={"UV_INDEX": os.environ.get("UV_INDEX", "")}, +) + + +# Optional: create a sampling callback +async def handle_sampling_message( + context: ClientRequestContext, params: types.CreateMessageRequestParams +) -> types.CreateMessageResult: + print(f"Sampling request: {params.messages}") + return types.CreateMessageResult( + role="assistant", + content=types.TextContent( + type="text", + text="Hello, world! from model", + ), + model="gpt-3.5-turbo", + stop_reason="endTurn", + ) + + +async def run(): + async with stdio_client(server_params) as (read, write): + async with ClientSession(read, write, sampling_callback=handle_sampling_message) as session: + # Initialize the connection + await session.initialize() + + # List available prompts + prompts = await session.list_prompts() + print(f"Available prompts: {[p.name for p in prompts.prompts]}") + + # Get a prompt (greet_user prompt from mcpserver_quickstart) + if prompts.prompts: + prompt = await session.get_prompt("greet_user", arguments={"name": "Alice", "style": "friendly"}) + print(f"Prompt result: {prompt.messages[0].content}") + + # List available resources + resources = await session.list_resources() + print(f"Available resources: {[r.uri for r in resources.resources]}") + + # List available tools + tools = await session.list_tools() + print(f"Available tools: {[t.name for t in tools.tools]}") + + # Read a resource (greeting resource from mcpserver_quickstart) + resource_content = await session.read_resource("greeting://World") + content_block = resource_content.contents[0] + if isinstance(content_block, types.TextContent): + print(f"Resource content: {content_block.text}") + + # Call a tool (add tool from mcpserver_quickstart) + result = await session.call_tool("add", arguments={"a": 5, "b": 3}) + result_unstructured = result.content[0] + if isinstance(result_unstructured, types.TextContent): + print(f"Tool result: {result_unstructured.text}") + result_structured = result.structured_content + print(f"Structured tool result: {result_structured}") + + +def main(): + """Entry point for the client script.""" + asyncio.run(run()) + + +if __name__ == "__main__": + main() +``` + +_Full example: [examples/snippets/clients/stdio_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/stdio_client.py)_ +<!-- /snippet-source --> + +Clients can also connect using [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http): + +<!-- snippet-source examples/snippets/clients/streamable_basic.py --> +```python +"""Run from the repository root: +uv run examples/snippets/clients/streamable_basic.py +""" + +import asyncio + +from mcp import ClientSession +from mcp.client.streamable_http import streamable_http_client + + +async def main(): + # Connect to a streamable HTTP server + async with streamable_http_client("http://localhost:8000/mcp") as (read_stream, write_stream): + # Create a session using the client streams + async with ClientSession(read_stream, write_stream) as session: + # Initialize the connection + await session.initialize() + # List available tools + tools = await session.list_tools() + print(f"Available tools: {[tool.name for tool in tools.tools]}") + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +_Full example: [examples/snippets/clients/streamable_basic.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/streamable_basic.py)_ +<!-- /snippet-source --> + +### Client Display Utilities + +When building MCP clients, the SDK provides utilities to help display human-readable names for tools, resources, and prompts: + +<!-- snippet-source examples/snippets/clients/display_utilities.py --> +```python +"""cd to the `examples/snippets` directory and run: +uv run display-utilities-client +""" + +import asyncio +import os + +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client +from mcp.shared.metadata_utils import get_display_name + +# Create server parameters for stdio connection +server_params = StdioServerParameters( + command="uv", # Using uv to run the server + args=["run", "server", "mcpserver_quickstart", "stdio"], + env={"UV_INDEX": os.environ.get("UV_INDEX", "")}, +) + + +async def display_tools(session: ClientSession): + """Display available tools with human-readable names""" + tools_response = await session.list_tools() + + for tool in tools_response.tools: + # get_display_name() returns the title if available, otherwise the name + display_name = get_display_name(tool) + print(f"Tool: {display_name}") + if tool.description: + print(f" {tool.description}") + + +async def display_resources(session: ClientSession): + """Display available resources with human-readable names""" + resources_response = await session.list_resources() + + for resource in resources_response.resources: + display_name = get_display_name(resource) + print(f"Resource: {display_name} ({resource.uri})") + + templates_response = await session.list_resource_templates() + for template in templates_response.resource_templates: + display_name = get_display_name(template) + print(f"Resource Template: {display_name}") + + +async def run(): + """Run the display utilities example.""" + async with stdio_client(server_params) as (read, write): + async with ClientSession(read, write) as session: + # Initialize the connection + await session.initialize() + + print("=== Available Tools ===") + await display_tools(session) + + print("\n=== Available Resources ===") + await display_resources(session) + + +def main(): + """Entry point for the display utilities client.""" + asyncio.run(run()) + + +if __name__ == "__main__": + main() +``` + +_Full example: [examples/snippets/clients/display_utilities.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/display_utilities.py)_ +<!-- /snippet-source --> + +The `get_display_name()` function implements the proper precedence rules for displaying names: + +- For tools: `title` > `annotations.title` > `name` +- For other objects: `title` > `name` + +This ensures your client UI shows the most user-friendly names that servers provide. + +### OAuth Authentication for Clients + +The SDK includes [authorization support](https://modelcontextprotocol.io/specification/2025-03-26/basic/authorization) for connecting to protected MCP servers: + +<!-- snippet-source examples/snippets/clients/oauth_client.py --> +```python +"""Before running, specify running MCP RS server URL. +To spin up RS server locally, see + examples/servers/simple-auth/README.md + +cd to the `examples/snippets` directory and run: + uv run oauth-client +""" + +import asyncio +from urllib.parse import parse_qs, urlparse + +import httpx +from pydantic import AnyUrl + +from mcp import ClientSession +from mcp.client.auth import OAuthClientProvider, TokenStorage +from mcp.client.streamable_http import streamable_http_client +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken + + +class InMemoryTokenStorage(TokenStorage): + """Demo In-memory token storage implementation.""" + + def __init__(self): + self.tokens: OAuthToken | None = None + self.client_info: OAuthClientInformationFull | None = None + + async def get_tokens(self) -> OAuthToken | None: + """Get stored tokens.""" + return self.tokens + + async def set_tokens(self, tokens: OAuthToken) -> None: + """Store tokens.""" + self.tokens = tokens + + async def get_client_info(self) -> OAuthClientInformationFull | None: + """Get stored client information.""" + return self.client_info + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + """Store client information.""" + self.client_info = client_info + + +async def handle_redirect(auth_url: str) -> None: + print(f"Visit: {auth_url}") + + +async def handle_callback() -> tuple[str, str | None]: + callback_url = input("Paste callback URL: ") + params = parse_qs(urlparse(callback_url).query) + return params["code"][0], params.get("state", [None])[0] + + +async def main(): + """Run the OAuth client example.""" + oauth_auth = OAuthClientProvider( + server_url="http://localhost:8001", + client_metadata=OAuthClientMetadata( + client_name="Example MCP Client", + redirect_uris=[AnyUrl("http://localhost:3000/callback")], + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + scope="user", + ), + storage=InMemoryTokenStorage(), + redirect_handler=handle_redirect, + callback_handler=handle_callback, + ) + + async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client: + async with streamable_http_client("http://localhost:8001/mcp", http_client=custom_client) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + + tools = await session.list_tools() + print(f"Available tools: {[tool.name for tool in tools.tools]}") + + resources = await session.list_resources() + print(f"Available resources: {[r.uri for r in resources.resources]}") + + +def run(): + asyncio.run(main()) + + +if __name__ == "__main__": + run() +``` + +_Full example: [examples/snippets/clients/oauth_client.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/oauth_client.py)_ +<!-- /snippet-source --> + +For a complete working example, see [`examples/clients/simple-auth-client/`](examples/clients/simple-auth-client/). + +### Parsing Tool Results + +When calling tools through MCP, the `CallToolResult` object contains the tool's response in a structured format. Understanding how to parse this result is essential for properly handling tool outputs. + +```python +"""examples/snippets/clients/parsing_tool_results.py""" + +import asyncio + +from mcp import ClientSession, StdioServerParameters, types +from mcp.client.stdio import stdio_client + + +async def parse_tool_results(): + """Demonstrates how to parse different types of content in CallToolResult.""" + server_params = StdioServerParameters( + command="python", args=["path/to/mcp_server.py"] + ) + + async with stdio_client(server_params) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + + # Example 1: Parsing text content + result = await session.call_tool("get_data", {"format": "text"}) + for content in result.content: + if isinstance(content, types.TextContent): + print(f"Text: {content.text}") + + # Example 2: Parsing structured content from JSON tools + result = await session.call_tool("get_user", {"id": "123"}) + if hasattr(result, "structuredContent") and result.structuredContent: + # Access structured data directly + user_data = result.structuredContent + print(f"User: {user_data.get('name')}, Age: {user_data.get('age')}") + + # Example 3: Parsing embedded resources + result = await session.call_tool("read_config", {}) + for content in result.content: + if isinstance(content, types.EmbeddedResource): + resource = content.resource + if isinstance(resource, types.TextResourceContents): + print(f"Config from {resource.uri}: {resource.text}") + elif isinstance(resource, types.BlobResourceContents): + print(f"Binary data from {resource.uri}") + + # Example 4: Parsing image content + result = await session.call_tool("generate_chart", {"data": [1, 2, 3]}) + for content in result.content: + if isinstance(content, types.ImageContent): + print(f"Image ({content.mimeType}): {len(content.data)} bytes") + + # Example 5: Handling errors + result = await session.call_tool("failing_tool", {}) + if result.isError: + print("Tool execution failed!") + for content in result.content: + if isinstance(content, types.TextContent): + print(f"Error: {content.text}") + + +async def main(): + await parse_tool_results() + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +### MCP Primitives + +The MCP protocol defines three core primitives that servers can implement: + +| Primitive | Control | Description | Example Use | +|-----------|-----------------------|-----------------------------------------------------|------------------------------| +| Prompts | User-controlled | Interactive templates invoked by user choice | Slash commands, menu options | +| Resources | Application-controlled| Contextual data managed by the client application | File contents, API responses | +| Tools | Model-controlled | Functions exposed to the LLM to take actions | API calls, data updates | + +### Server Capabilities + +MCP servers declare capabilities during initialization: + +| Capability | Feature Flag | Description | +|--------------|------------------------------|------------------------------------| +| `prompts` | `listChanged` | Prompt template management | +| `resources` | `subscribe`<br/>`listChanged`| Resource exposure and updates | +| `tools` | `listChanged` | Tool discovery and execution | +| `logging` | - | Server logging configuration | +| `completions`| - | Argument completion suggestions | + +## Documentation + +- [API Reference](https://modelcontextprotocol.github.io/python-sdk/api/) +- [Experimental Features (Tasks)](https://modelcontextprotocol.github.io/python-sdk/experimental/tasks/) +- [Model Context Protocol documentation](https://modelcontextprotocol.io) +- [Model Context Protocol specification](https://modelcontextprotocol.io/specification/latest) +- [Officially supported servers](https://github.com/modelcontextprotocol/servers) + +## Contributing + +We are passionate about supporting contributors of all levels of experience and would love to see you get involved in the project. See the [contributing guide](CONTRIBUTING.md) to get started. + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. diff --git a/RELEASE.md b/RELEASE.md index 6555a1c2d..3c4f415f3 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,13 +1,13 @@ -# Release Process - -## Bumping Dependencies - -1. Change dependency version in `pyproject.toml` -2. Upgrade lock with `uv lock --resolution lowest-direct` - -## Major or Minor Release - -Create a GitHub release via UI with the tag being `vX.Y.Z` where `X.Y.Z` is the version, -and the release title being the same. Then ask someone to review the release. - -The package version will be set automatically from the tag. +# Release Process + +## Bumping Dependencies + +1. Change dependency version in `pyproject.toml` +2. Upgrade lock with `uv lock --resolution lowest-direct` + +## Major or Minor Release + +Create a GitHub release via UI with the tag being `vX.Y.Z` where `X.Y.Z` is the version, +and the release title being the same. Then ask someone to review the release. + +The package version will be set automatically from the tag. diff --git a/SECURITY.md b/SECURITY.md index 502924200..5d73c4988 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,21 +1,21 @@ -# Security Policy - -Thank you for helping keep the Model Context Protocol and its ecosystem secure. - -## Reporting Security Issues - -If you discover a security vulnerability in this repository, please report it through -the [GitHub Security Advisory process](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability) -for this repository. - -Please **do not** report security vulnerabilities through public GitHub issues, discussions, -or pull requests. - -## What to Include - -To help us triage and respond quickly, please include: - -- A description of the vulnerability -- Steps to reproduce the issue -- The potential impact -- Any suggested fixes (optional) +# Security Policy + +Thank you for helping keep the Model Context Protocol and its ecosystem secure. + +## Reporting Security Issues + +If you discover a security vulnerability in this repository, please report it through +the [GitHub Security Advisory process](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability) +for this repository. + +Please **do not** report security vulnerabilities through public GitHub issues, discussions, +or pull requests. + +## What to Include + +To help us triage and respond quickly, please include: + +- A description of the vulnerability +- Steps to reproduce the issue +- The potential impact +- Any suggested fixes (optional) diff --git a/docs/authorization.md b/docs/authorization.md index 4b6208bdf..945321925 100644 --- a/docs/authorization.md +++ b/docs/authorization.md @@ -1,5 +1,5 @@ -# Authorization - -!!! warning "Under Construction" - - This page is currently being written. Check back soon for complete documentation. +# Authorization + +!!! warning "Under Construction" + + This page is currently being written. Check back soon for complete documentation. diff --git a/docs/concepts.md b/docs/concepts.md index a2d6eb8d3..c9aeddfbb 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -1,13 +1,13 @@ -# Concepts - -!!! warning "Under Construction" - - This page is currently being written. Check back soon for complete documentation. - -<!-- - - Server vs Client - - Three primitives (tools, resources, prompts) - - Transports (stdio, SSE, streamable HTTP) - - Context and sessions - - Lifecycle and state - --> +# Concepts + +!!! warning "Under Construction" + + This page is currently being written. Check back soon for complete documentation. + +<!-- + - Server vs Client + - Three primitives (tools, resources, prompts) + - Transports (stdio, SSE, streamable HTTP) + - Context and sessions + - Lifecycle and state + --> diff --git a/docs/experimental/index.md b/docs/experimental/index.md index c97fe2a3d..dd7f9a957 100644 --- a/docs/experimental/index.md +++ b/docs/experimental/index.md @@ -1,42 +1,42 @@ -# Experimental Features - -!!! warning "Experimental APIs" - - The features in this section are experimental and may change without notice. - They track the evolving MCP specification and are not yet stable. - -This section documents experimental features in the MCP Python SDK. These features -implement draft specifications that are still being refined. - -## Available Experimental Features - -### [Tasks](tasks.md) - -Tasks enable asynchronous execution of MCP operations. Instead of waiting for a -long-running operation to complete, the server returns a task reference immediately. -Clients can then poll for status updates and retrieve results when ready. - -Tasks are useful for: - -- **Long-running computations** that would otherwise block -- **Batch operations** that process many items -- **Interactive workflows** that require user input (elicitation) or LLM assistance (sampling) - -## Using Experimental APIs - -Experimental features are accessed via the `.experimental` property: - -```python -# Server-side: enable task support (auto-registers default handlers) -server = Server(name="my-server") -server.experimental.enable_tasks() - -# Client-side -result = await session.experimental.call_tool_as_task("tool_name", {"arg": "value"}) -``` - -## Providing Feedback - -Since these features are experimental, feedback is especially valuable. If you encounter -issues or have suggestions, please open an issue on the -[python-sdk repository](https://github.com/modelcontextprotocol/python-sdk/issues). +# Experimental Features + +!!! warning "Experimental APIs" + + The features in this section are experimental and may change without notice. + They track the evolving MCP specification and are not yet stable. + +This section documents experimental features in the MCP Python SDK. These features +implement draft specifications that are still being refined. + +## Available Experimental Features + +### [Tasks](tasks.md) + +Tasks enable asynchronous execution of MCP operations. Instead of waiting for a +long-running operation to complete, the server returns a task reference immediately. +Clients can then poll for status updates and retrieve results when ready. + +Tasks are useful for: + +- **Long-running computations** that would otherwise block +- **Batch operations** that process many items +- **Interactive workflows** that require user input (elicitation) or LLM assistance (sampling) + +## Using Experimental APIs + +Experimental features are accessed via the `.experimental` property: + +```python +# Server-side: enable task support (auto-registers default handlers) +server = Server(name="my-server") +server.experimental.enable_tasks() + +# Client-side +result = await session.experimental.call_tool_as_task("tool_name", {"arg": "value"}) +``` + +## Providing Feedback + +Since these features are experimental, feedback is especially valuable. If you encounter +issues or have suggestions, please open an issue on the +[python-sdk repository](https://github.com/modelcontextprotocol/python-sdk/issues). diff --git a/docs/experimental/tasks-client.md b/docs/experimental/tasks-client.md index 0374ed86b..6823e448e 100644 --- a/docs/experimental/tasks-client.md +++ b/docs/experimental/tasks-client.md @@ -1,361 +1,361 @@ -# Client Task Usage - -!!! warning "Experimental" - - Tasks are an experimental feature. The API may change without notice. - -This guide covers calling task-augmented tools from clients, handling the `input_required` status, and advanced patterns like receiving task requests from servers. - -## Quick Start - -Call a tool as a task and poll for the result: - -```python -from mcp.client.session import ClientSession -from mcp.types import CallToolResult - -async with ClientSession(read, write) as session: - await session.initialize() - - # Call tool as task - result = await session.experimental.call_tool_as_task( - "process_data", - {"input": "hello"}, - ttl=60000, - ) - task_id = result.task.taskId - - # Poll until complete - async for status in session.experimental.poll_task(task_id): - print(f"Status: {status.status} - {status.statusMessage or ''}") - - # Get result - final = await session.experimental.get_task_result(task_id, CallToolResult) - print(f"Result: {final.content[0].text}") -``` - -## Calling Tools as Tasks - -Use `call_tool_as_task()` to invoke a tool with task augmentation: - -```python -result = await session.experimental.call_tool_as_task( - "my_tool", # Tool name - {"arg": "value"}, # Arguments - ttl=60000, # Time-to-live in milliseconds - meta={"key": "val"}, # Optional metadata -) - -task_id = result.task.taskId -print(f"Task: {task_id}, Status: {result.task.status}") -``` - -The response is a `CreateTaskResult` containing: - -- `task.taskId` - Unique identifier for polling -- `task.status` - Initial status (usually `"working"`) -- `task.pollInterval` - Suggested polling interval (milliseconds) -- `task.ttl` - Time-to-live for results -- `task.createdAt` - Creation timestamp - -## Polling with poll_task - -The `poll_task()` async iterator polls until the task reaches a terminal state: - -```python -async for status in session.experimental.poll_task(task_id): - print(f"Status: {status.status}") - if status.statusMessage: - print(f"Progress: {status.statusMessage}") -``` - -It automatically: - -- Respects the server's suggested `pollInterval` -- Stops when status is `completed`, `failed`, or `cancelled` -- Yields each status for progress display - -### Handling input_required - -When a task needs user input (elicitation), it transitions to `input_required`. You must call `get_task_result()` to receive and respond to the elicitation: - -```python -async for status in session.experimental.poll_task(task_id): - print(f"Status: {status.status}") - - if status.status == "input_required": - # This delivers the elicitation and waits for completion - final = await session.experimental.get_task_result(task_id, CallToolResult) - break -``` - -The elicitation callback (set during session creation) handles the actual user interaction. - -## Elicitation Callbacks - -To handle elicitation requests from the server, provide a callback when creating the session: - -```python -from mcp.types import ElicitRequestParams, ElicitResult - -async def handle_elicitation(context, params: ElicitRequestParams) -> ElicitResult: - # Display the message to the user - print(f"Server asks: {params.message}") - - # Collect user input (this is a simplified example) - response = input("Your response (y/n): ") - confirmed = response.lower() == "y" - - return ElicitResult( - action="accept", - content={"confirm": confirmed}, - ) - -async with ClientSession( - read, - write, - elicitation_callback=handle_elicitation, -) as session: - await session.initialize() - # ... call tasks that may require elicitation -``` - -## Sampling Callbacks - -Similarly, handle sampling requests with a callback: - -```python -from mcp.types import CreateMessageRequestParams, CreateMessageResult, TextContent - -async def handle_sampling(context, params: CreateMessageRequestParams) -> CreateMessageResult: - # In a real implementation, call your LLM here - prompt = params.messages[-1].content.text if params.messages else "" - - # Return a mock response - return CreateMessageResult( - role="assistant", - content=TextContent(type="text", text=f"Response to: {prompt}"), - model="my-model", - ) - -async with ClientSession( - read, - write, - sampling_callback=handle_sampling, -) as session: - # ... -``` - -## Retrieving Results - -Once a task completes, retrieve the result: - -```python -if status.status == "completed": - result = await session.experimental.get_task_result(task_id, CallToolResult) - for content in result.content: - if hasattr(content, "text"): - print(content.text) - -elif status.status == "failed": - print(f"Task failed: {status.statusMessage}") - -elif status.status == "cancelled": - print("Task was cancelled") -``` - -The result type matches the original request: - -- `tools/call` → `CallToolResult` -- `sampling/createMessage` → `CreateMessageResult` -- `elicitation/create` → `ElicitResult` - -## Cancellation - -Cancel a running task: - -```python -cancel_result = await session.experimental.cancel_task(task_id) -print(f"Cancelled, status: {cancel_result.status}") -``` - -Note: Cancellation is cooperative—the server must check for and handle cancellation. - -## Listing Tasks - -View all tasks on the server: - -```python -result = await session.experimental.list_tasks() -for task in result.tasks: - print(f"{task.taskId}: {task.status}") - -# Handle pagination -while result.nextCursor: - result = await session.experimental.list_tasks(cursor=result.nextCursor) - for task in result.tasks: - print(f"{task.taskId}: {task.status}") -``` - -## Advanced: Client as Task Receiver - -Servers can send task-augmented requests to clients. This is useful when the server needs the client to perform async work (like complex sampling or user interaction). - -### Declaring Client Capabilities - -Register task handlers to declare what task-augmented requests your client accepts: - -```python -from mcp.client.experimental.task_handlers import ExperimentalTaskHandlers -from mcp.types import ( - CreateTaskResult, GetTaskResult, GetTaskPayloadResult, - TaskMetadata, ElicitRequestParams, -) -from mcp.shared.experimental.tasks import InMemoryTaskStore - -# Client-side task store -client_store = InMemoryTaskStore() - -async def handle_augmented_elicitation(context, params: ElicitRequestParams, task_metadata: TaskMetadata): - """Handle task-augmented elicitation from server.""" - # Create a task for this elicitation - task = await client_store.create_task(task_metadata) - - # Start async work (e.g., show UI, wait for user) - async def complete_elicitation(): - # ... do async work ... - result = ElicitResult(action="accept", content={"confirm": True}) - await client_store.store_result(task.taskId, result) - await client_store.update_task(task.taskId, status="completed") - - context.session._task_group.start_soon(complete_elicitation) - - # Return task reference immediately - return CreateTaskResult(task=task) - -async def handle_get_task(context, params): - """Handle tasks/get from server.""" - task = await client_store.get_task(params.taskId) - return GetTaskResult( - taskId=task.taskId, - status=task.status, - statusMessage=task.statusMessage, - createdAt=task.createdAt, - lastUpdatedAt=task.lastUpdatedAt, - ttl=task.ttl, - pollInterval=100, - ) - -async def handle_get_task_result(context, params): - """Handle tasks/result from server.""" - result = await client_store.get_result(params.taskId) - return GetTaskPayloadResult.model_validate(result.model_dump()) - -task_handlers = ExperimentalTaskHandlers( - augmented_elicitation=handle_augmented_elicitation, - get_task=handle_get_task, - get_task_result=handle_get_task_result, -) - -async with ClientSession( - read, - write, - experimental_task_handlers=task_handlers, -) as session: - # Client now accepts task-augmented elicitation from server - await session.initialize() -``` - -This enables flows where: - -1. Client calls a task-augmented tool -2. Server's tool work calls `task.elicit_as_task()` -3. Client receives task-augmented elicitation -4. Client creates its own task, does async work -5. Server polls client's task -6. Eventually both tasks complete - -## Complete Example - -A client that handles all task scenarios: - -```python -import anyio -from mcp.client.session import ClientSession -from mcp.client.stdio import stdio_client -from mcp.types import CallToolResult, ElicitRequestParams, ElicitResult - - -async def elicitation_callback(context, params: ElicitRequestParams) -> ElicitResult: - print(f"\n[Elicitation] {params.message}") - response = input("Confirm? (y/n): ") - return ElicitResult(action="accept", content={"confirm": response.lower() == "y"}) - - -async def main(): - async with stdio_client(command="python", args=["server.py"]) as (read, write): - async with ClientSession( - read, - write, - elicitation_callback=elicitation_callback, - ) as session: - await session.initialize() - - # List available tools - tools = await session.list_tools() - print("Tools:", [t.name for t in tools.tools]) - - # Call a task-augmented tool - print("\nCalling task tool...") - result = await session.experimental.call_tool_as_task( - "confirm_action", - {"action": "delete files"}, - ) - task_id = result.task.taskId - print(f"Task created: {task_id}") - - # Poll and handle input_required - async for status in session.experimental.poll_task(task_id): - print(f"Status: {status.status}") - - if status.status == "input_required": - final = await session.experimental.get_task_result(task_id, CallToolResult) - print(f"Result: {final.content[0].text}") - break - - if status.status == "completed": - final = await session.experimental.get_task_result(task_id, CallToolResult) - print(f"Result: {final.content[0].text}") - - -if __name__ == "__main__": - anyio.run(main) -``` - -## Error Handling - -Handle task errors gracefully: - -```python -from mcp.shared.exceptions import MCPError - -try: - result = await session.experimental.call_tool_as_task("my_tool", args) - task_id = result.task.taskId - - async for status in session.experimental.poll_task(task_id): - if status.status == "failed": - raise RuntimeError(f"Task failed: {status.statusMessage}") - - final = await session.experimental.get_task_result(task_id, CallToolResult) - -except MCPError as e: - print(f"MCP error: {e.message}") -except Exception as e: - print(f"Error: {e}") -``` - -## Next Steps - -- [Server Implementation](tasks-server.md) - Build task-supporting servers -- [Tasks Overview](tasks.md) - Review lifecycle and concepts +# Client Task Usage + +!!! warning "Experimental" + + Tasks are an experimental feature. The API may change without notice. + +This guide covers calling task-augmented tools from clients, handling the `input_required` status, and advanced patterns like receiving task requests from servers. + +## Quick Start + +Call a tool as a task and poll for the result: + +```python +from mcp.client.session import ClientSession +from mcp.types import CallToolResult + +async with ClientSession(read, write) as session: + await session.initialize() + + # Call tool as task + result = await session.experimental.call_tool_as_task( + "process_data", + {"input": "hello"}, + ttl=60000, + ) + task_id = result.task.taskId + + # Poll until complete + async for status in session.experimental.poll_task(task_id): + print(f"Status: {status.status} - {status.statusMessage or ''}") + + # Get result + final = await session.experimental.get_task_result(task_id, CallToolResult) + print(f"Result: {final.content[0].text}") +``` + +## Calling Tools as Tasks + +Use `call_tool_as_task()` to invoke a tool with task augmentation: + +```python +result = await session.experimental.call_tool_as_task( + "my_tool", # Tool name + {"arg": "value"}, # Arguments + ttl=60000, # Time-to-live in milliseconds + meta={"key": "val"}, # Optional metadata +) + +task_id = result.task.taskId +print(f"Task: {task_id}, Status: {result.task.status}") +``` + +The response is a `CreateTaskResult` containing: + +- `task.taskId` - Unique identifier for polling +- `task.status` - Initial status (usually `"working"`) +- `task.pollInterval` - Suggested polling interval (milliseconds) +- `task.ttl` - Time-to-live for results +- `task.createdAt` - Creation timestamp + +## Polling with poll_task + +The `poll_task()` async iterator polls until the task reaches a terminal state: + +```python +async for status in session.experimental.poll_task(task_id): + print(f"Status: {status.status}") + if status.statusMessage: + print(f"Progress: {status.statusMessage}") +``` + +It automatically: + +- Respects the server's suggested `pollInterval` +- Stops when status is `completed`, `failed`, or `cancelled` +- Yields each status for progress display + +### Handling input_required + +When a task needs user input (elicitation), it transitions to `input_required`. You must call `get_task_result()` to receive and respond to the elicitation: + +```python +async for status in session.experimental.poll_task(task_id): + print(f"Status: {status.status}") + + if status.status == "input_required": + # This delivers the elicitation and waits for completion + final = await session.experimental.get_task_result(task_id, CallToolResult) + break +``` + +The elicitation callback (set during session creation) handles the actual user interaction. + +## Elicitation Callbacks + +To handle elicitation requests from the server, provide a callback when creating the session: + +```python +from mcp.types import ElicitRequestParams, ElicitResult + +async def handle_elicitation(context, params: ElicitRequestParams) -> ElicitResult: + # Display the message to the user + print(f"Server asks: {params.message}") + + # Collect user input (this is a simplified example) + response = input("Your response (y/n): ") + confirmed = response.lower() == "y" + + return ElicitResult( + action="accept", + content={"confirm": confirmed}, + ) + +async with ClientSession( + read, + write, + elicitation_callback=handle_elicitation, +) as session: + await session.initialize() + # ... call tasks that may require elicitation +``` + +## Sampling Callbacks + +Similarly, handle sampling requests with a callback: + +```python +from mcp.types import CreateMessageRequestParams, CreateMessageResult, TextContent + +async def handle_sampling(context, params: CreateMessageRequestParams) -> CreateMessageResult: + # In a real implementation, call your LLM here + prompt = params.messages[-1].content.text if params.messages else "" + + # Return a mock response + return CreateMessageResult( + role="assistant", + content=TextContent(type="text", text=f"Response to: {prompt}"), + model="my-model", + ) + +async with ClientSession( + read, + write, + sampling_callback=handle_sampling, +) as session: + # ... +``` + +## Retrieving Results + +Once a task completes, retrieve the result: + +```python +if status.status == "completed": + result = await session.experimental.get_task_result(task_id, CallToolResult) + for content in result.content: + if hasattr(content, "text"): + print(content.text) + +elif status.status == "failed": + print(f"Task failed: {status.statusMessage}") + +elif status.status == "cancelled": + print("Task was cancelled") +``` + +The result type matches the original request: + +- `tools/call` → `CallToolResult` +- `sampling/createMessage` → `CreateMessageResult` +- `elicitation/create` → `ElicitResult` + +## Cancellation + +Cancel a running task: + +```python +cancel_result = await session.experimental.cancel_task(task_id) +print(f"Cancelled, status: {cancel_result.status}") +``` + +Note: Cancellation is cooperative—the server must check for and handle cancellation. + +## Listing Tasks + +View all tasks on the server: + +```python +result = await session.experimental.list_tasks() +for task in result.tasks: + print(f"{task.taskId}: {task.status}") + +# Handle pagination +while result.nextCursor: + result = await session.experimental.list_tasks(cursor=result.nextCursor) + for task in result.tasks: + print(f"{task.taskId}: {task.status}") +``` + +## Advanced: Client as Task Receiver + +Servers can send task-augmented requests to clients. This is useful when the server needs the client to perform async work (like complex sampling or user interaction). + +### Declaring Client Capabilities + +Register task handlers to declare what task-augmented requests your client accepts: + +```python +from mcp.client.experimental.task_handlers import ExperimentalTaskHandlers +from mcp.types import ( + CreateTaskResult, GetTaskResult, GetTaskPayloadResult, + TaskMetadata, ElicitRequestParams, +) +from mcp.shared.experimental.tasks import InMemoryTaskStore + +# Client-side task store +client_store = InMemoryTaskStore() + +async def handle_augmented_elicitation(context, params: ElicitRequestParams, task_metadata: TaskMetadata): + """Handle task-augmented elicitation from server.""" + # Create a task for this elicitation + task = await client_store.create_task(task_metadata) + + # Start async work (e.g., show UI, wait for user) + async def complete_elicitation(): + # ... do async work ... + result = ElicitResult(action="accept", content={"confirm": True}) + await client_store.store_result(task.taskId, result) + await client_store.update_task(task.taskId, status="completed") + + context.session._task_group.start_soon(complete_elicitation) + + # Return task reference immediately + return CreateTaskResult(task=task) + +async def handle_get_task(context, params): + """Handle tasks/get from server.""" + task = await client_store.get_task(params.taskId) + return GetTaskResult( + taskId=task.taskId, + status=task.status, + statusMessage=task.statusMessage, + createdAt=task.createdAt, + lastUpdatedAt=task.lastUpdatedAt, + ttl=task.ttl, + pollInterval=100, + ) + +async def handle_get_task_result(context, params): + """Handle tasks/result from server.""" + result = await client_store.get_result(params.taskId) + return GetTaskPayloadResult.model_validate(result.model_dump()) + +task_handlers = ExperimentalTaskHandlers( + augmented_elicitation=handle_augmented_elicitation, + get_task=handle_get_task, + get_task_result=handle_get_task_result, +) + +async with ClientSession( + read, + write, + experimental_task_handlers=task_handlers, +) as session: + # Client now accepts task-augmented elicitation from server + await session.initialize() +``` + +This enables flows where: + +1. Client calls a task-augmented tool +2. Server's tool work calls `task.elicit_as_task()` +3. Client receives task-augmented elicitation +4. Client creates its own task, does async work +5. Server polls client's task +6. Eventually both tasks complete + +## Complete Example + +A client that handles all task scenarios: + +```python +import anyio +from mcp.client.session import ClientSession +from mcp.client.stdio import stdio_client +from mcp.types import CallToolResult, ElicitRequestParams, ElicitResult + + +async def elicitation_callback(context, params: ElicitRequestParams) -> ElicitResult: + print(f"\n[Elicitation] {params.message}") + response = input("Confirm? (y/n): ") + return ElicitResult(action="accept", content={"confirm": response.lower() == "y"}) + + +async def main(): + async with stdio_client(command="python", args=["server.py"]) as (read, write): + async with ClientSession( + read, + write, + elicitation_callback=elicitation_callback, + ) as session: + await session.initialize() + + # List available tools + tools = await session.list_tools() + print("Tools:", [t.name for t in tools.tools]) + + # Call a task-augmented tool + print("\nCalling task tool...") + result = await session.experimental.call_tool_as_task( + "confirm_action", + {"action": "delete files"}, + ) + task_id = result.task.taskId + print(f"Task created: {task_id}") + + # Poll and handle input_required + async for status in session.experimental.poll_task(task_id): + print(f"Status: {status.status}") + + if status.status == "input_required": + final = await session.experimental.get_task_result(task_id, CallToolResult) + print(f"Result: {final.content[0].text}") + break + + if status.status == "completed": + final = await session.experimental.get_task_result(task_id, CallToolResult) + print(f"Result: {final.content[0].text}") + + +if __name__ == "__main__": + anyio.run(main) +``` + +## Error Handling + +Handle task errors gracefully: + +```python +from mcp.shared.exceptions import MCPError + +try: + result = await session.experimental.call_tool_as_task("my_tool", args) + task_id = result.task.taskId + + async for status in session.experimental.poll_task(task_id): + if status.status == "failed": + raise RuntimeError(f"Task failed: {status.statusMessage}") + + final = await session.experimental.get_task_result(task_id, CallToolResult) + +except MCPError as e: + print(f"MCP error: {e.message}") +except Exception as e: + print(f"Error: {e}") +``` + +## Next Steps + +- [Server Implementation](tasks-server.md) - Build task-supporting servers +- [Tasks Overview](tasks.md) - Review lifecycle and concepts diff --git a/docs/experimental/tasks-server.md b/docs/experimental/tasks-server.md index b350ee3bb..a8701a5ad 100644 --- a/docs/experimental/tasks-server.md +++ b/docs/experimental/tasks-server.md @@ -1,577 +1,577 @@ -# Server Task Implementation - -!!! warning "Experimental" - - Tasks are an experimental feature. The API may change without notice. - -This guide covers implementing task support in MCP servers, from basic setup to advanced patterns like elicitation and sampling within tasks. - -## Quick Start - -The simplest way to add task support: - -```python -from mcp.server import Server -from mcp.server.experimental.task_context import ServerTaskContext -from mcp.types import CallToolResult, CreateTaskResult, TextContent, Tool, ToolExecution, TASK_REQUIRED - -server = Server("my-server") -server.experimental.enable_tasks() # Registers all task handlers automatically - -@server.list_tools() -async def list_tools(): - return [ - Tool( - name="process_data", - description="Process data asynchronously", - inputSchema={"type": "object", "properties": {"input": {"type": "string"}}}, - execution=ToolExecution(taskSupport=TASK_REQUIRED), - ) - ] - -@server.call_tool() -async def handle_tool(name: str, arguments: dict) -> CallToolResult | CreateTaskResult: - if name == "process_data": - return await handle_process_data(arguments) - return CallToolResult(content=[TextContent(type="text", text=f"Unknown: {name}")], isError=True) - -async def handle_process_data(arguments: dict) -> CreateTaskResult: - ctx = server.request_context - ctx.experimental.validate_task_mode(TASK_REQUIRED) - - async def work(task: ServerTaskContext) -> CallToolResult: - await task.update_status("Processing...") - result = arguments.get("input", "").upper() - return CallToolResult(content=[TextContent(type="text", text=result)]) - - return await ctx.experimental.run_task(work) -``` - -That's it. `enable_tasks()` automatically: - -- Creates an in-memory task store -- Registers handlers for `tasks/get`, `tasks/result`, `tasks/list`, `tasks/cancel` -- Updates server capabilities - -## Tool Declaration - -Tools declare task support via the `execution.taskSupport` field: - -```python -from mcp.types import Tool, ToolExecution, TASK_REQUIRED, TASK_OPTIONAL, TASK_FORBIDDEN - -Tool( - name="my_tool", - inputSchema={"type": "object"}, - execution=ToolExecution(taskSupport=TASK_REQUIRED), # or TASK_OPTIONAL, TASK_FORBIDDEN -) -``` - -| Value | Meaning | -|-------|---------| -| `TASK_REQUIRED` | Tool **must** be called as a task | -| `TASK_OPTIONAL` | Tool supports both sync and task execution | -| `TASK_FORBIDDEN` | Tool **cannot** be called as a task (default) | - -Validate the request matches your tool's requirements: - -```python -@server.call_tool() -async def handle_tool(name: str, arguments: dict): - ctx = server.request_context - - if name == "required_task_tool": - ctx.experimental.validate_task_mode(TASK_REQUIRED) # Raises if not task mode - return await handle_as_task(arguments) - - elif name == "optional_task_tool": - if ctx.experimental.is_task: - return await handle_as_task(arguments) - else: - return handle_sync(arguments) -``` - -## The run_task Pattern - -`run_task()` is the recommended way to execute task work: - -```python -async def handle_my_tool(arguments: dict) -> CreateTaskResult: - ctx = server.request_context - ctx.experimental.validate_task_mode(TASK_REQUIRED) - - async def work(task: ServerTaskContext) -> CallToolResult: - # Your work here - return CallToolResult(content=[TextContent(type="text", text="Done")]) - - return await ctx.experimental.run_task(work) -``` - -**What `run_task()` does:** - -1. Creates a task in the store -2. Spawns your work function in the background -3. Returns `CreateTaskResult` immediately -4. Auto-completes the task when your function returns -5. Auto-fails the task if your function raises - -**The `ServerTaskContext` provides:** - -- `task.task_id` - The task identifier -- `task.update_status(message)` - Update progress -- `task.complete(result)` - Explicitly complete (usually automatic) -- `task.fail(error)` - Explicitly fail -- `task.is_cancelled` - Check if cancellation requested - -## Status Updates - -Keep clients informed of progress: - -```python -async def work(task: ServerTaskContext) -> CallToolResult: - await task.update_status("Starting...") - - for i, item in enumerate(items): - await task.update_status(f"Processing {i+1}/{len(items)}") - await process_item(item) - - await task.update_status("Finalizing...") - return CallToolResult(content=[TextContent(type="text", text="Complete")]) -``` - -Status messages appear in `tasks/get` responses, letting clients show progress to users. - -## Elicitation Within Tasks - -Tasks can request user input via elicitation. This transitions the task to `input_required` status. - -### Form Elicitation - -Collect structured data from the user: - -```python -async def work(task: ServerTaskContext) -> CallToolResult: - await task.update_status("Waiting for confirmation...") - - result = await task.elicit( - message="Delete these files?", - requestedSchema={ - "type": "object", - "properties": { - "confirm": {"type": "boolean"}, - "reason": {"type": "string"}, - }, - "required": ["confirm"], - }, - ) - - if result.action == "accept" and result.content.get("confirm"): - # User confirmed - return CallToolResult(content=[TextContent(type="text", text="Files deleted")]) - else: - # User declined or cancelled - return CallToolResult(content=[TextContent(type="text", text="Cancelled")]) -``` - -### URL Elicitation - -Direct users to external URLs for OAuth, payments, or other out-of-band flows: - -```python -async def work(task: ServerTaskContext) -> CallToolResult: - await task.update_status("Waiting for OAuth...") - - result = await task.elicit_url( - message="Please authorize with GitHub", - url="https://github.com/login/oauth/authorize?client_id=...", - elicitation_id="oauth-github-123", - ) - - if result.action == "accept": - # User completed OAuth flow - return CallToolResult(content=[TextContent(type="text", text="Connected to GitHub")]) - else: - return CallToolResult(content=[TextContent(type="text", text="OAuth cancelled")]) -``` - -## Sampling Within Tasks - -Tasks can request LLM completions from the client: - -```python -from mcp.types import SamplingMessage, TextContent - -async def work(task: ServerTaskContext) -> CallToolResult: - await task.update_status("Generating response...") - - result = await task.create_message( - messages=[ - SamplingMessage( - role="user", - content=TextContent(type="text", text="Write a haiku about coding"), - ) - ], - max_tokens=100, - ) - - haiku = result.content.text if isinstance(result.content, TextContent) else "Error" - return CallToolResult(content=[TextContent(type="text", text=haiku)]) -``` - -Sampling supports additional parameters: - -```python -result = await task.create_message( - messages=[...], - max_tokens=500, - system_prompt="You are a helpful assistant", - temperature=0.7, - stop_sequences=["\n\n"], - model_preferences=ModelPreferences(hints=[ModelHint(name="claude-3")]), -) -``` - -## Cancellation Support - -Check for cancellation in long-running work: - -```python -async def work(task: ServerTaskContext) -> CallToolResult: - for i in range(1000): - if task.is_cancelled: - # Clean up and exit - return CallToolResult(content=[TextContent(type="text", text="Cancelled")]) - - await task.update_status(f"Step {i}/1000") - await process_step(i) - - return CallToolResult(content=[TextContent(type="text", text="Complete")]) -``` - -The SDK's default cancel handler updates the task status. Your work function should check `is_cancelled` periodically. - -## Custom Task Store - -For production, implement `TaskStore` with persistent storage: - -```python -from mcp.shared.experimental.tasks.store import TaskStore -from mcp.types import Task, TaskMetadata, Result - -class RedisTaskStore(TaskStore): - def __init__(self, redis_client): - self.redis = redis_client - - async def create_task(self, metadata: TaskMetadata, task_id: str | None = None) -> Task: - # Create and persist task - ... - - async def get_task(self, task_id: str) -> Task | None: - # Retrieve task from Redis - ... - - async def update_task(self, task_id: str, status: str | None = None, ...) -> Task: - # Update and persist - ... - - async def store_result(self, task_id: str, result: Result) -> None: - # Store result in Redis - ... - - async def get_result(self, task_id: str) -> Result | None: - # Retrieve result - ... - - # ... implement remaining methods -``` - -Use your custom store: - -```python -store = RedisTaskStore(redis_client) -server.experimental.enable_tasks(store=store) -``` - -## Complete Example - -A server with multiple task-supporting tools: - -```python -from mcp.server import Server -from mcp.server.experimental.task_context import ServerTaskContext -from mcp.types import ( - CallToolResult, CreateTaskResult, TextContent, Tool, ToolExecution, - SamplingMessage, TASK_REQUIRED, -) - -server = Server("task-demo") -server.experimental.enable_tasks() - - -@server.list_tools() -async def list_tools(): - return [ - Tool( - name="confirm_action", - description="Requires user confirmation", - inputSchema={"type": "object", "properties": {"action": {"type": "string"}}}, - execution=ToolExecution(taskSupport=TASK_REQUIRED), - ), - Tool( - name="generate_text", - description="Generate text via LLM", - inputSchema={"type": "object", "properties": {"prompt": {"type": "string"}}}, - execution=ToolExecution(taskSupport=TASK_REQUIRED), - ), - ] - - -async def handle_confirm_action(arguments: dict) -> CreateTaskResult: - ctx = server.request_context - ctx.experimental.validate_task_mode(TASK_REQUIRED) - - action = arguments.get("action", "unknown action") - - async def work(task: ServerTaskContext) -> CallToolResult: - result = await task.elicit( - message=f"Confirm: {action}?", - requestedSchema={ - "type": "object", - "properties": {"confirm": {"type": "boolean"}}, - "required": ["confirm"], - }, - ) - - if result.action == "accept" and result.content.get("confirm"): - return CallToolResult(content=[TextContent(type="text", text=f"Executed: {action}")]) - return CallToolResult(content=[TextContent(type="text", text="Cancelled")]) - - return await ctx.experimental.run_task(work) - - -async def handle_generate_text(arguments: dict) -> CreateTaskResult: - ctx = server.request_context - ctx.experimental.validate_task_mode(TASK_REQUIRED) - - prompt = arguments.get("prompt", "Hello") - - async def work(task: ServerTaskContext) -> CallToolResult: - await task.update_status("Generating...") - - result = await task.create_message( - messages=[SamplingMessage(role="user", content=TextContent(type="text", text=prompt))], - max_tokens=200, - ) - - text = result.content.text if isinstance(result.content, TextContent) else "Error" - return CallToolResult(content=[TextContent(type="text", text=text)]) - - return await ctx.experimental.run_task(work) - - -@server.call_tool() -async def handle_tool(name: str, arguments: dict) -> CallToolResult | CreateTaskResult: - if name == "confirm_action": - return await handle_confirm_action(arguments) - elif name == "generate_text": - return await handle_generate_text(arguments) - return CallToolResult(content=[TextContent(type="text", text=f"Unknown: {name}")], isError=True) -``` - -## Error Handling in Tasks - -Tasks handle errors automatically, but you can also fail explicitly: - -```python -async def work(task: ServerTaskContext) -> CallToolResult: - try: - result = await risky_operation() - return CallToolResult(content=[TextContent(type="text", text=result)]) - except PermissionError: - await task.fail("Access denied - insufficient permissions") - raise - except TimeoutError: - await task.fail("Operation timed out after 30 seconds") - raise -``` - -When `run_task()` catches an exception, it automatically: - -1. Marks the task as `failed` -2. Sets `statusMessage` to the exception message -3. Propagates the exception (which is caught by the task group) - -For custom error messages, call `task.fail()` before raising. - -## HTTP Transport Example - -For web applications, use the Streamable HTTP transport: - -```python -import uvicorn - -from mcp.server import Server -from mcp.server.experimental.task_context import ServerTaskContext -from mcp.types import ( - CallToolResult, CreateTaskResult, TextContent, Tool, ToolExecution, TASK_REQUIRED, -) - - -server = Server("http-task-server") -server.experimental.enable_tasks() - - -@server.list_tools() -async def list_tools(): - return [ - Tool( - name="long_operation", - description="A long-running operation", - inputSchema={"type": "object", "properties": {"duration": {"type": "number"}}}, - execution=ToolExecution(taskSupport=TASK_REQUIRED), - ) - ] - - -async def handle_long_operation(arguments: dict) -> CreateTaskResult: - ctx = server.request_context - ctx.experimental.validate_task_mode(TASK_REQUIRED) - - duration = arguments.get("duration", 5) - - async def work(task: ServerTaskContext) -> CallToolResult: - import anyio - for i in range(int(duration)): - await task.update_status(f"Step {i+1}/{int(duration)}") - await anyio.sleep(1) - return CallToolResult(content=[TextContent(type="text", text=f"Completed after {duration}s")]) - - return await ctx.experimental.run_task(work) - - -@server.call_tool() -async def handle_tool(name: str, arguments: dict) -> CallToolResult | CreateTaskResult: - if name == "long_operation": - return await handle_long_operation(arguments) - return CallToolResult(content=[TextContent(type="text", text=f"Unknown: {name}")], isError=True) - - -if __name__ == "__main__": - uvicorn.run(server.streamable_http_app(), host="127.0.0.1", port=8000) -``` - -## Testing Task Servers - -Test task functionality with the SDK's testing utilities: - -```python -import pytest -import anyio -from mcp.client.session import ClientSession -from mcp.types import CallToolResult - - -@pytest.mark.anyio -async def test_task_tool(): - server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream(10) - client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream(10) - - async def run_server(): - await server.run( - client_to_server_receive, - server_to_client_send, - server.create_initialization_options(), - ) - - async def run_client(): - async with ClientSession(server_to_client_receive, client_to_server_send) as session: - await session.initialize() - - # Call the tool as a task - result = await session.experimental.call_tool_as_task("my_tool", {"arg": "value"}) - task_id = result.task.taskId - assert result.task.status == "working" - - # Poll until complete - async for status in session.experimental.poll_task(task_id): - if status.status in ("completed", "failed"): - break - - # Get result - final = await session.experimental.get_task_result(task_id, CallToolResult) - assert len(final.content) > 0 - - async with anyio.create_task_group() as tg: - tg.start_soon(run_server) - tg.start_soon(run_client) -``` - -## Best Practices - -### Keep Work Functions Focused - -```python -# Good: focused work function -async def work(task: ServerTaskContext) -> CallToolResult: - await task.update_status("Validating...") - validate_input(arguments) - - await task.update_status("Processing...") - result = await process_data(arguments) - - return CallToolResult(content=[TextContent(type="text", text=result)]) -``` - -### Check Cancellation in Loops - -```python -async def work(task: ServerTaskContext) -> CallToolResult: - results = [] - for item in large_dataset: - if task.is_cancelled: - return CallToolResult(content=[TextContent(type="text", text="Cancelled")]) - - results.append(await process(item)) - - return CallToolResult(content=[TextContent(type="text", text=str(results))]) -``` - -### Use Meaningful Status Messages - -```python -async def work(task: ServerTaskContext) -> CallToolResult: - await task.update_status("Connecting to database...") - db = await connect() - - await task.update_status("Fetching records (0/1000)...") - for i, record in enumerate(records): - if i % 100 == 0: - await task.update_status(f"Processing records ({i}/1000)...") - await process(record) - - await task.update_status("Finalizing results...") - return CallToolResult(content=[TextContent(type="text", text="Done")]) -``` - -### Handle Elicitation Responses - -```python -async def work(task: ServerTaskContext) -> CallToolResult: - result = await task.elicit(message="Continue?", requestedSchema={...}) - - match result.action: - case "accept": - # User accepted, process content - return await process_accepted(result.content) - case "decline": - # User explicitly declined - return CallToolResult(content=[TextContent(type="text", text="User declined")]) - case "cancel": - # User cancelled the elicitation - return CallToolResult(content=[TextContent(type="text", text="Cancelled")]) -``` - -## Next Steps - -- [Client Usage](tasks-client.md) - Learn how clients interact with task servers -- [Tasks Overview](tasks.md) - Review lifecycle and concepts +# Server Task Implementation + +!!! warning "Experimental" + + Tasks are an experimental feature. The API may change without notice. + +This guide covers implementing task support in MCP servers, from basic setup to advanced patterns like elicitation and sampling within tasks. + +## Quick Start + +The simplest way to add task support: + +```python +from mcp.server import Server +from mcp.server.experimental.task_context import ServerTaskContext +from mcp.types import CallToolResult, CreateTaskResult, TextContent, Tool, ToolExecution, TASK_REQUIRED + +server = Server("my-server") +server.experimental.enable_tasks() # Registers all task handlers automatically + +@server.list_tools() +async def list_tools(): + return [ + Tool( + name="process_data", + description="Process data asynchronously", + inputSchema={"type": "object", "properties": {"input": {"type": "string"}}}, + execution=ToolExecution(taskSupport=TASK_REQUIRED), + ) + ] + +@server.call_tool() +async def handle_tool(name: str, arguments: dict) -> CallToolResult | CreateTaskResult: + if name == "process_data": + return await handle_process_data(arguments) + return CallToolResult(content=[TextContent(type="text", text=f"Unknown: {name}")], isError=True) + +async def handle_process_data(arguments: dict) -> CreateTaskResult: + ctx = server.request_context + ctx.experimental.validate_task_mode(TASK_REQUIRED) + + async def work(task: ServerTaskContext) -> CallToolResult: + await task.update_status("Processing...") + result = arguments.get("input", "").upper() + return CallToolResult(content=[TextContent(type="text", text=result)]) + + return await ctx.experimental.run_task(work) +``` + +That's it. `enable_tasks()` automatically: + +- Creates an in-memory task store +- Registers handlers for `tasks/get`, `tasks/result`, `tasks/list`, `tasks/cancel` +- Updates server capabilities + +## Tool Declaration + +Tools declare task support via the `execution.taskSupport` field: + +```python +from mcp.types import Tool, ToolExecution, TASK_REQUIRED, TASK_OPTIONAL, TASK_FORBIDDEN + +Tool( + name="my_tool", + inputSchema={"type": "object"}, + execution=ToolExecution(taskSupport=TASK_REQUIRED), # or TASK_OPTIONAL, TASK_FORBIDDEN +) +``` + +| Value | Meaning | +|-------|---------| +| `TASK_REQUIRED` | Tool **must** be called as a task | +| `TASK_OPTIONAL` | Tool supports both sync and task execution | +| `TASK_FORBIDDEN` | Tool **cannot** be called as a task (default) | + +Validate the request matches your tool's requirements: + +```python +@server.call_tool() +async def handle_tool(name: str, arguments: dict): + ctx = server.request_context + + if name == "required_task_tool": + ctx.experimental.validate_task_mode(TASK_REQUIRED) # Raises if not task mode + return await handle_as_task(arguments) + + elif name == "optional_task_tool": + if ctx.experimental.is_task: + return await handle_as_task(arguments) + else: + return handle_sync(arguments) +``` + +## The run_task Pattern + +`run_task()` is the recommended way to execute task work: + +```python +async def handle_my_tool(arguments: dict) -> CreateTaskResult: + ctx = server.request_context + ctx.experimental.validate_task_mode(TASK_REQUIRED) + + async def work(task: ServerTaskContext) -> CallToolResult: + # Your work here + return CallToolResult(content=[TextContent(type="text", text="Done")]) + + return await ctx.experimental.run_task(work) +``` + +**What `run_task()` does:** + +1. Creates a task in the store +2. Spawns your work function in the background +3. Returns `CreateTaskResult` immediately +4. Auto-completes the task when your function returns +5. Auto-fails the task if your function raises + +**The `ServerTaskContext` provides:** + +- `task.task_id` - The task identifier +- `task.update_status(message)` - Update progress +- `task.complete(result)` - Explicitly complete (usually automatic) +- `task.fail(error)` - Explicitly fail +- `task.is_cancelled` - Check if cancellation requested + +## Status Updates + +Keep clients informed of progress: + +```python +async def work(task: ServerTaskContext) -> CallToolResult: + await task.update_status("Starting...") + + for i, item in enumerate(items): + await task.update_status(f"Processing {i+1}/{len(items)}") + await process_item(item) + + await task.update_status("Finalizing...") + return CallToolResult(content=[TextContent(type="text", text="Complete")]) +``` + +Status messages appear in `tasks/get` responses, letting clients show progress to users. + +## Elicitation Within Tasks + +Tasks can request user input via elicitation. This transitions the task to `input_required` status. + +### Form Elicitation + +Collect structured data from the user: + +```python +async def work(task: ServerTaskContext) -> CallToolResult: + await task.update_status("Waiting for confirmation...") + + result = await task.elicit( + message="Delete these files?", + requestedSchema={ + "type": "object", + "properties": { + "confirm": {"type": "boolean"}, + "reason": {"type": "string"}, + }, + "required": ["confirm"], + }, + ) + + if result.action == "accept" and result.content.get("confirm"): + # User confirmed + return CallToolResult(content=[TextContent(type="text", text="Files deleted")]) + else: + # User declined or cancelled + return CallToolResult(content=[TextContent(type="text", text="Cancelled")]) +``` + +### URL Elicitation + +Direct users to external URLs for OAuth, payments, or other out-of-band flows: + +```python +async def work(task: ServerTaskContext) -> CallToolResult: + await task.update_status("Waiting for OAuth...") + + result = await task.elicit_url( + message="Please authorize with GitHub", + url="https://github.com/login/oauth/authorize?client_id=...", + elicitation_id="oauth-github-123", + ) + + if result.action == "accept": + # User completed OAuth flow + return CallToolResult(content=[TextContent(type="text", text="Connected to GitHub")]) + else: + return CallToolResult(content=[TextContent(type="text", text="OAuth cancelled")]) +``` + +## Sampling Within Tasks + +Tasks can request LLM completions from the client: + +```python +from mcp.types import SamplingMessage, TextContent + +async def work(task: ServerTaskContext) -> CallToolResult: + await task.update_status("Generating response...") + + result = await task.create_message( + messages=[ + SamplingMessage( + role="user", + content=TextContent(type="text", text="Write a haiku about coding"), + ) + ], + max_tokens=100, + ) + + haiku = result.content.text if isinstance(result.content, TextContent) else "Error" + return CallToolResult(content=[TextContent(type="text", text=haiku)]) +``` + +Sampling supports additional parameters: + +```python +result = await task.create_message( + messages=[...], + max_tokens=500, + system_prompt="You are a helpful assistant", + temperature=0.7, + stop_sequences=["\n\n"], + model_preferences=ModelPreferences(hints=[ModelHint(name="claude-3")]), +) +``` + +## Cancellation Support + +Check for cancellation in long-running work: + +```python +async def work(task: ServerTaskContext) -> CallToolResult: + for i in range(1000): + if task.is_cancelled: + # Clean up and exit + return CallToolResult(content=[TextContent(type="text", text="Cancelled")]) + + await task.update_status(f"Step {i}/1000") + await process_step(i) + + return CallToolResult(content=[TextContent(type="text", text="Complete")]) +``` + +The SDK's default cancel handler updates the task status. Your work function should check `is_cancelled` periodically. + +## Custom Task Store + +For production, implement `TaskStore` with persistent storage: + +```python +from mcp.shared.experimental.tasks.store import TaskStore +from mcp.types import Task, TaskMetadata, Result + +class RedisTaskStore(TaskStore): + def __init__(self, redis_client): + self.redis = redis_client + + async def create_task(self, metadata: TaskMetadata, task_id: str | None = None) -> Task: + # Create and persist task + ... + + async def get_task(self, task_id: str) -> Task | None: + # Retrieve task from Redis + ... + + async def update_task(self, task_id: str, status: str | None = None, ...) -> Task: + # Update and persist + ... + + async def store_result(self, task_id: str, result: Result) -> None: + # Store result in Redis + ... + + async def get_result(self, task_id: str) -> Result | None: + # Retrieve result + ... + + # ... implement remaining methods +``` + +Use your custom store: + +```python +store = RedisTaskStore(redis_client) +server.experimental.enable_tasks(store=store) +``` + +## Complete Example + +A server with multiple task-supporting tools: + +```python +from mcp.server import Server +from mcp.server.experimental.task_context import ServerTaskContext +from mcp.types import ( + CallToolResult, CreateTaskResult, TextContent, Tool, ToolExecution, + SamplingMessage, TASK_REQUIRED, +) + +server = Server("task-demo") +server.experimental.enable_tasks() + + +@server.list_tools() +async def list_tools(): + return [ + Tool( + name="confirm_action", + description="Requires user confirmation", + inputSchema={"type": "object", "properties": {"action": {"type": "string"}}}, + execution=ToolExecution(taskSupport=TASK_REQUIRED), + ), + Tool( + name="generate_text", + description="Generate text via LLM", + inputSchema={"type": "object", "properties": {"prompt": {"type": "string"}}}, + execution=ToolExecution(taskSupport=TASK_REQUIRED), + ), + ] + + +async def handle_confirm_action(arguments: dict) -> CreateTaskResult: + ctx = server.request_context + ctx.experimental.validate_task_mode(TASK_REQUIRED) + + action = arguments.get("action", "unknown action") + + async def work(task: ServerTaskContext) -> CallToolResult: + result = await task.elicit( + message=f"Confirm: {action}?", + requestedSchema={ + "type": "object", + "properties": {"confirm": {"type": "boolean"}}, + "required": ["confirm"], + }, + ) + + if result.action == "accept" and result.content.get("confirm"): + return CallToolResult(content=[TextContent(type="text", text=f"Executed: {action}")]) + return CallToolResult(content=[TextContent(type="text", text="Cancelled")]) + + return await ctx.experimental.run_task(work) + + +async def handle_generate_text(arguments: dict) -> CreateTaskResult: + ctx = server.request_context + ctx.experimental.validate_task_mode(TASK_REQUIRED) + + prompt = arguments.get("prompt", "Hello") + + async def work(task: ServerTaskContext) -> CallToolResult: + await task.update_status("Generating...") + + result = await task.create_message( + messages=[SamplingMessage(role="user", content=TextContent(type="text", text=prompt))], + max_tokens=200, + ) + + text = result.content.text if isinstance(result.content, TextContent) else "Error" + return CallToolResult(content=[TextContent(type="text", text=text)]) + + return await ctx.experimental.run_task(work) + + +@server.call_tool() +async def handle_tool(name: str, arguments: dict) -> CallToolResult | CreateTaskResult: + if name == "confirm_action": + return await handle_confirm_action(arguments) + elif name == "generate_text": + return await handle_generate_text(arguments) + return CallToolResult(content=[TextContent(type="text", text=f"Unknown: {name}")], isError=True) +``` + +## Error Handling in Tasks + +Tasks handle errors automatically, but you can also fail explicitly: + +```python +async def work(task: ServerTaskContext) -> CallToolResult: + try: + result = await risky_operation() + return CallToolResult(content=[TextContent(type="text", text=result)]) + except PermissionError: + await task.fail("Access denied - insufficient permissions") + raise + except TimeoutError: + await task.fail("Operation timed out after 30 seconds") + raise +``` + +When `run_task()` catches an exception, it automatically: + +1. Marks the task as `failed` +2. Sets `statusMessage` to the exception message +3. Propagates the exception (which is caught by the task group) + +For custom error messages, call `task.fail()` before raising. + +## HTTP Transport Example + +For web applications, use the Streamable HTTP transport: + +```python +import uvicorn + +from mcp.server import Server +from mcp.server.experimental.task_context import ServerTaskContext +from mcp.types import ( + CallToolResult, CreateTaskResult, TextContent, Tool, ToolExecution, TASK_REQUIRED, +) + + +server = Server("http-task-server") +server.experimental.enable_tasks() + + +@server.list_tools() +async def list_tools(): + return [ + Tool( + name="long_operation", + description="A long-running operation", + inputSchema={"type": "object", "properties": {"duration": {"type": "number"}}}, + execution=ToolExecution(taskSupport=TASK_REQUIRED), + ) + ] + + +async def handle_long_operation(arguments: dict) -> CreateTaskResult: + ctx = server.request_context + ctx.experimental.validate_task_mode(TASK_REQUIRED) + + duration = arguments.get("duration", 5) + + async def work(task: ServerTaskContext) -> CallToolResult: + import anyio + for i in range(int(duration)): + await task.update_status(f"Step {i+1}/{int(duration)}") + await anyio.sleep(1) + return CallToolResult(content=[TextContent(type="text", text=f"Completed after {duration}s")]) + + return await ctx.experimental.run_task(work) + + +@server.call_tool() +async def handle_tool(name: str, arguments: dict) -> CallToolResult | CreateTaskResult: + if name == "long_operation": + return await handle_long_operation(arguments) + return CallToolResult(content=[TextContent(type="text", text=f"Unknown: {name}")], isError=True) + + +if __name__ == "__main__": + uvicorn.run(server.streamable_http_app(), host="127.0.0.1", port=8000) +``` + +## Testing Task Servers + +Test task functionality with the SDK's testing utilities: + +```python +import pytest +import anyio +from mcp.client.session import ClientSession +from mcp.types import CallToolResult + + +@pytest.mark.anyio +async def test_task_tool(): + server_to_client_send, server_to_client_receive = anyio.create_memory_object_stream(10) + client_to_server_send, client_to_server_receive = anyio.create_memory_object_stream(10) + + async def run_server(): + await server.run( + client_to_server_receive, + server_to_client_send, + server.create_initialization_options(), + ) + + async def run_client(): + async with ClientSession(server_to_client_receive, client_to_server_send) as session: + await session.initialize() + + # Call the tool as a task + result = await session.experimental.call_tool_as_task("my_tool", {"arg": "value"}) + task_id = result.task.taskId + assert result.task.status == "working" + + # Poll until complete + async for status in session.experimental.poll_task(task_id): + if status.status in ("completed", "failed"): + break + + # Get result + final = await session.experimental.get_task_result(task_id, CallToolResult) + assert len(final.content) > 0 + + async with anyio.create_task_group() as tg: + tg.start_soon(run_server) + tg.start_soon(run_client) +``` + +## Best Practices + +### Keep Work Functions Focused + +```python +# Good: focused work function +async def work(task: ServerTaskContext) -> CallToolResult: + await task.update_status("Validating...") + validate_input(arguments) + + await task.update_status("Processing...") + result = await process_data(arguments) + + return CallToolResult(content=[TextContent(type="text", text=result)]) +``` + +### Check Cancellation in Loops + +```python +async def work(task: ServerTaskContext) -> CallToolResult: + results = [] + for item in large_dataset: + if task.is_cancelled: + return CallToolResult(content=[TextContent(type="text", text="Cancelled")]) + + results.append(await process(item)) + + return CallToolResult(content=[TextContent(type="text", text=str(results))]) +``` + +### Use Meaningful Status Messages + +```python +async def work(task: ServerTaskContext) -> CallToolResult: + await task.update_status("Connecting to database...") + db = await connect() + + await task.update_status("Fetching records (0/1000)...") + for i, record in enumerate(records): + if i % 100 == 0: + await task.update_status(f"Processing records ({i}/1000)...") + await process(record) + + await task.update_status("Finalizing results...") + return CallToolResult(content=[TextContent(type="text", text="Done")]) +``` + +### Handle Elicitation Responses + +```python +async def work(task: ServerTaskContext) -> CallToolResult: + result = await task.elicit(message="Continue?", requestedSchema={...}) + + match result.action: + case "accept": + # User accepted, process content + return await process_accepted(result.content) + case "decline": + # User explicitly declined + return CallToolResult(content=[TextContent(type="text", text="User declined")]) + case "cancel": + # User cancelled the elicitation + return CallToolResult(content=[TextContent(type="text", text="Cancelled")]) +``` + +## Next Steps + +- [Client Usage](tasks-client.md) - Learn how clients interact with task servers +- [Tasks Overview](tasks.md) - Review lifecycle and concepts diff --git a/docs/experimental/tasks.md b/docs/experimental/tasks.md index 2d4d06a02..3db464393 100644 --- a/docs/experimental/tasks.md +++ b/docs/experimental/tasks.md @@ -1,188 +1,188 @@ -# Tasks - -!!! warning "Experimental" - - Tasks are an experimental feature tracking the draft MCP specification. - The API may change without notice. - -Tasks enable asynchronous request handling in MCP. Instead of blocking until an operation completes, the receiver creates a task, returns immediately, and the requestor polls for the result. - -## When to Use Tasks - -Tasks are designed for operations that: - -- Take significant time (seconds to minutes) -- Need progress updates during execution -- Require user input mid-execution (elicitation, sampling) -- Should run without blocking the requestor - -Common use cases: - -- Long-running data processing -- Multi-step workflows with user confirmation -- LLM-powered operations requiring sampling -- OAuth flows requiring user browser interaction - -## Task Lifecycle - -```text - ┌─────────────┐ - │ working │ - └──────┬──────┘ - │ - ┌────────────┼────────────┐ - │ │ │ - ▼ ▼ ▼ - ┌────────────┐ ┌───────────┐ ┌───────────┐ - │ completed │ │ failed │ │ cancelled │ - └────────────┘ └───────────┘ └───────────┘ - ▲ - │ - ┌────────┴────────┐ - │ input_required │◄──────┐ - └────────┬────────┘ │ - │ │ - └────────────────┘ -``` - -| Status | Description | -|--------|-------------| -| `working` | Task is being processed | -| `input_required` | Receiver needs input from requestor (elicitation/sampling) | -| `completed` | Task finished successfully | -| `failed` | Task encountered an error | -| `cancelled` | Task was cancelled by requestor | - -Terminal states (`completed`, `failed`, `cancelled`) are final—tasks cannot transition out of them. - -## Bidirectional Flow - -Tasks work in both directions: - -**Client → Server** (most common): - -```text -Client Server - │ │ - │── tools/call (task) ──────────────>│ Creates task - │<── CreateTaskResult ───────────────│ - │ │ - │── tasks/get ──────────────────────>│ - │<── status: working ────────────────│ - │ │ ... work continues ... - │── tasks/get ──────────────────────>│ - │<── status: completed ──────────────│ - │ │ - │── tasks/result ───────────────────>│ - │<── CallToolResult ─────────────────│ -``` - -**Server → Client** (for elicitation/sampling): - -```text -Server Client - │ │ - │── elicitation/create (task) ──────>│ Creates task - │<── CreateTaskResult ───────────────│ - │ │ - │── tasks/get ──────────────────────>│ - │<── status: working ────────────────│ - │ │ ... user interaction ... - │── tasks/get ──────────────────────>│ - │<── status: completed ──────────────│ - │ │ - │── tasks/result ───────────────────>│ - │<── ElicitResult ───────────────────│ -``` - -## Key Concepts - -### Task Metadata - -When augmenting a request with task execution, include `TaskMetadata`: - -```python -from mcp.types import TaskMetadata - -task = TaskMetadata(ttl=60000) # TTL in milliseconds -``` - -The `ttl` (time-to-live) specifies how long the task and result are retained after completion. - -### Task Store - -Servers persist task state in a `TaskStore`. The SDK provides `InMemoryTaskStore` for development: - -```python -from mcp.shared.experimental.tasks import InMemoryTaskStore - -store = InMemoryTaskStore() -``` - -For production, implement `TaskStore` with a database or distributed cache. - -### Capabilities - -Both servers and clients declare task support through capabilities: - -**Server capabilities:** - -- `tasks.requests.tools.call` - Server accepts task-augmented tool calls - -**Client capabilities:** - -- `tasks.requests.sampling.createMessage` - Client accepts task-augmented sampling -- `tasks.requests.elicitation.create` - Client accepts task-augmented elicitation - -The SDK manages these automatically when you enable task support. - -## Quick Example - -**Server** (simplified API): - -```python -from mcp.server import Server -from mcp.server.experimental.task_context import ServerTaskContext -from mcp.types import CallToolResult, TextContent, TASK_REQUIRED - -server = Server("my-server") -server.experimental.enable_tasks() # One-line setup - -@server.call_tool() -async def handle_tool(name: str, arguments: dict): - ctx = server.request_context - ctx.experimental.validate_task_mode(TASK_REQUIRED) - - async def work(task: ServerTaskContext): - await task.update_status("Processing...") - # ... do work ... - return CallToolResult(content=[TextContent(type="text", text="Done!")]) - - return await ctx.experimental.run_task(work) -``` - -**Client:** - -```python -from mcp.client.session import ClientSession -from mcp.types import CallToolResult - -async with ClientSession(read, write) as session: - await session.initialize() - - # Call tool as task - result = await session.experimental.call_tool_as_task("my_tool", {"arg": "value"}) - task_id = result.task.taskId - - # Poll until done - async for status in session.experimental.poll_task(task_id): - print(f"Status: {status.status}") - - # Get result - final = await session.experimental.get_task_result(task_id, CallToolResult) -``` - -## Next Steps - -- [Server Implementation](tasks-server.md) - Build task-supporting servers -- [Client Usage](tasks-client.md) - Call and poll tasks from clients +# Tasks + +!!! warning "Experimental" + + Tasks are an experimental feature tracking the draft MCP specification. + The API may change without notice. + +Tasks enable asynchronous request handling in MCP. Instead of blocking until an operation completes, the receiver creates a task, returns immediately, and the requestor polls for the result. + +## When to Use Tasks + +Tasks are designed for operations that: + +- Take significant time (seconds to minutes) +- Need progress updates during execution +- Require user input mid-execution (elicitation, sampling) +- Should run without blocking the requestor + +Common use cases: + +- Long-running data processing +- Multi-step workflows with user confirmation +- LLM-powered operations requiring sampling +- OAuth flows requiring user browser interaction + +## Task Lifecycle + +```text + ┌─────────────┐ + │ working │ + └──────┬──────┘ + │ + ┌────────────┼────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌────────────┐ ┌───────────┐ ┌───────────┐ + │ completed │ │ failed │ │ cancelled │ + └────────────┘ └───────────┘ └───────────┘ + ▲ + │ + ┌────────┴────────┐ + │ input_required │◄──────┐ + └────────┬────────┘ │ + │ │ + └────────────────┘ +``` + +| Status | Description | +|--------|-------------| +| `working` | Task is being processed | +| `input_required` | Receiver needs input from requestor (elicitation/sampling) | +| `completed` | Task finished successfully | +| `failed` | Task encountered an error | +| `cancelled` | Task was cancelled by requestor | + +Terminal states (`completed`, `failed`, `cancelled`) are final—tasks cannot transition out of them. + +## Bidirectional Flow + +Tasks work in both directions: + +**Client → Server** (most common): + +```text +Client Server + │ │ + │── tools/call (task) ──────────────>│ Creates task + │<── CreateTaskResult ───────────────│ + │ │ + │── tasks/get ──────────────────────>│ + │<── status: working ────────────────│ + │ │ ... work continues ... + │── tasks/get ──────────────────────>│ + │<── status: completed ──────────────│ + │ │ + │── tasks/result ───────────────────>│ + │<── CallToolResult ─────────────────│ +``` + +**Server → Client** (for elicitation/sampling): + +```text +Server Client + │ │ + │── elicitation/create (task) ──────>│ Creates task + │<── CreateTaskResult ───────────────│ + │ │ + │── tasks/get ──────────────────────>│ + │<── status: working ────────────────│ + │ │ ... user interaction ... + │── tasks/get ──────────────────────>│ + │<── status: completed ──────────────│ + │ │ + │── tasks/result ───────────────────>│ + │<── ElicitResult ───────────────────│ +``` + +## Key Concepts + +### Task Metadata + +When augmenting a request with task execution, include `TaskMetadata`: + +```python +from mcp.types import TaskMetadata + +task = TaskMetadata(ttl=60000) # TTL in milliseconds +``` + +The `ttl` (time-to-live) specifies how long the task and result are retained after completion. + +### Task Store + +Servers persist task state in a `TaskStore`. The SDK provides `InMemoryTaskStore` for development: + +```python +from mcp.shared.experimental.tasks import InMemoryTaskStore + +store = InMemoryTaskStore() +``` + +For production, implement `TaskStore` with a database or distributed cache. + +### Capabilities + +Both servers and clients declare task support through capabilities: + +**Server capabilities:** + +- `tasks.requests.tools.call` - Server accepts task-augmented tool calls + +**Client capabilities:** + +- `tasks.requests.sampling.createMessage` - Client accepts task-augmented sampling +- `tasks.requests.elicitation.create` - Client accepts task-augmented elicitation + +The SDK manages these automatically when you enable task support. + +## Quick Example + +**Server** (simplified API): + +```python +from mcp.server import Server +from mcp.server.experimental.task_context import ServerTaskContext +from mcp.types import CallToolResult, TextContent, TASK_REQUIRED + +server = Server("my-server") +server.experimental.enable_tasks() # One-line setup + +@server.call_tool() +async def handle_tool(name: str, arguments: dict): + ctx = server.request_context + ctx.experimental.validate_task_mode(TASK_REQUIRED) + + async def work(task: ServerTaskContext): + await task.update_status("Processing...") + # ... do work ... + return CallToolResult(content=[TextContent(type="text", text="Done!")]) + + return await ctx.experimental.run_task(work) +``` + +**Client:** + +```python +from mcp.client.session import ClientSession +from mcp.types import CallToolResult + +async with ClientSession(read, write) as session: + await session.initialize() + + # Call tool as task + result = await session.experimental.call_tool_as_task("my_tool", {"arg": "value"}) + task_id = result.task.taskId + + # Poll until done + async for status in session.experimental.poll_task(task_id): + print(f"Status: {status.status}") + + # Get result + final = await session.experimental.get_task_result(task_id, CallToolResult) +``` + +## Next Steps + +- [Server Implementation](tasks-server.md) - Build task-supporting servers +- [Client Usage](tasks-client.md) - Call and poll tasks from clients diff --git a/docs/hooks/gen_ref_pages.py b/docs/hooks/gen_ref_pages.py index ad8c19b45..5584b9332 100644 --- a/docs/hooks/gen_ref_pages.py +++ b/docs/hooks/gen_ref_pages.py @@ -1,35 +1,35 @@ -"""Generate the code reference pages and navigation.""" - -from pathlib import Path - -import mkdocs_gen_files - -nav = mkdocs_gen_files.Nav() - -root = Path(__file__).parent.parent.parent -src = root / "src" - -for path in sorted(src.rglob("*.py")): - module_path = path.relative_to(src).with_suffix("") - doc_path = path.relative_to(src).with_suffix(".md") - full_doc_path = Path("api", doc_path) - - parts = tuple(module_path.parts) - - if parts[-1] == "__init__": - parts = parts[:-1] - doc_path = doc_path.with_name("index.md") - full_doc_path = full_doc_path.with_name("index.md") - elif parts[-1].startswith("_"): - continue - - nav[parts] = doc_path.as_posix() - - with mkdocs_gen_files.open(full_doc_path, "w") as fd: - ident = ".".join(parts) - fd.write(f"::: {ident}") - - mkdocs_gen_files.set_edit_path(full_doc_path, path.relative_to(root)) - -with mkdocs_gen_files.open("api/SUMMARY.md", "w") as nav_file: - nav_file.writelines(nav.build_literate_nav()) +"""Generate the code reference pages and navigation.""" + +from pathlib import Path + +import mkdocs_gen_files + +nav = mkdocs_gen_files.Nav() + +root = Path(__file__).parent.parent.parent +src = root / "src" + +for path in sorted(src.rglob("*.py")): + module_path = path.relative_to(src).with_suffix("") + doc_path = path.relative_to(src).with_suffix(".md") + full_doc_path = Path("api", doc_path) + + parts = tuple(module_path.parts) + + if parts[-1] == "__init__": + parts = parts[:-1] + doc_path = doc_path.with_name("index.md") + full_doc_path = full_doc_path.with_name("index.md") + elif parts[-1].startswith("_"): + continue + + nav[parts] = doc_path.as_posix() + + with mkdocs_gen_files.open(full_doc_path, "w") as fd: + ident = ".".join(parts) + fd.write(f"::: {ident}") + + mkdocs_gen_files.set_edit_path(full_doc_path, path.relative_to(root)) + +with mkdocs_gen_files.open("api/SUMMARY.md", "w") as nav_file: + nav_file.writelines(nav.build_literate_nav()) diff --git a/docs/index.md b/docs/index.md index 436d1c8fc..7e8b7df36 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,67 +1,67 @@ -# MCP Python SDK - -The **Model Context Protocol (MCP)** allows applications to provide context for LLMs in a standardized way, separating the concerns of providing context from the actual LLM interaction. - -This Python SDK implements the full MCP specification, making it easy to: - -- **Build MCP servers** that expose resources, prompts, and tools -- **Create MCP clients** that can connect to any MCP server -- **Use standard transports** like stdio, SSE, and Streamable HTTP - -If you want to read more about the specification, please visit the [MCP documentation](https://modelcontextprotocol.io). - -## Quick Example - -Here's a simple MCP server that exposes a tool, resource, and prompt: - -```python title="server.py" -from mcp.server.mcpserver import MCPServer - -mcp = MCPServer("Test Server", json_response=True) - - -@mcp.tool() -def add(a: int, b: int) -> int: - """Add two numbers""" - return a + b - - -@mcp.resource("greeting://{name}") -def get_greeting(name: str) -> str: - """Get a personalized greeting""" - return f"Hello, {name}!" - - -@mcp.prompt() -def greet_user(name: str, style: str = "friendly") -> str: - """Generate a greeting prompt""" - return f"Write a {style} greeting for someone named {name}." - - -if __name__ == "__main__": - mcp.run(transport="streamable-http") -``` - -Run the server: - -```bash -uv run --with mcp server.py -``` - -Then open the [MCP Inspector](https://github.com/modelcontextprotocol/inspector) and connect to `http://localhost:8000/mcp`: - -```bash -npx -y @modelcontextprotocol/inspector -``` - -## Getting Started - -<!-- TODO(Marcelo): automatically generate the follow references with a header on each of those files. --> -1. **[Install](installation.md)** the MCP SDK -2. **[Learn concepts](concepts.md)** - understand the three primitives and architecture -3. **[Explore authorization](authorization.md)** - add security to your servers -4. **[Use low-level APIs](low-level-server.md)** - for advanced customization - -## API Reference - -Full API documentation is available in the [API Reference](api/mcp/index.md). +# MCP Python SDK + +The **Model Context Protocol (MCP)** allows applications to provide context for LLMs in a standardized way, separating the concerns of providing context from the actual LLM interaction. + +This Python SDK implements the full MCP specification, making it easy to: + +- **Build MCP servers** that expose resources, prompts, and tools +- **Create MCP clients** that can connect to any MCP server +- **Use standard transports** like stdio, SSE, and Streamable HTTP + +If you want to read more about the specification, please visit the [MCP documentation](https://modelcontextprotocol.io). + +## Quick Example + +Here's a simple MCP server that exposes a tool, resource, and prompt: + +```python title="server.py" +from mcp.server.mcpserver import MCPServer + +mcp = MCPServer("Test Server", json_response=True) + + +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two numbers""" + return a + b + + +@mcp.resource("greeting://{name}") +def get_greeting(name: str) -> str: + """Get a personalized greeting""" + return f"Hello, {name}!" + + +@mcp.prompt() +def greet_user(name: str, style: str = "friendly") -> str: + """Generate a greeting prompt""" + return f"Write a {style} greeting for someone named {name}." + + +if __name__ == "__main__": + mcp.run(transport="streamable-http") +``` + +Run the server: + +```bash +uv run --with mcp server.py +``` + +Then open the [MCP Inspector](https://github.com/modelcontextprotocol/inspector) and connect to `http://localhost:8000/mcp`: + +```bash +npx -y @modelcontextprotocol/inspector +``` + +## Getting Started + +<!-- TODO(Marcelo): automatically generate the follow references with a header on each of those files. --> +1. **[Install](installation.md)** the MCP SDK +2. **[Learn concepts](concepts.md)** - understand the three primitives and architecture +3. **[Explore authorization](authorization.md)** - add security to your servers +4. **[Use low-level APIs](low-level-server.md)** - for advanced customization + +## API Reference + +Full API documentation is available in the [API Reference](api/mcp/index.md). diff --git a/docs/installation.md b/docs/installation.md index f39846235..0cd6b2692 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,31 +1,31 @@ -# Installation - -The Python SDK is available on PyPI as [`mcp`](https://pypi.org/project/mcp/) so installation is as simple as: - -=== "pip" - - ```bash - pip install mcp - ``` -=== "uv" - - ```bash - uv add mcp - ``` - -The following dependencies are automatically installed: - -- [`httpx`](https://pypi.org/project/httpx/): HTTP client to handle HTTP Streamable and SSE transports. -- [`httpx-sse`](https://pypi.org/project/httpx-sse/): HTTP client to handle SSE transport. -- [`pydantic`](https://pypi.org/project/pydantic/): Types, JSON schema generation, data validation, and [more](https://docs.pydantic.dev/latest/). -- [`starlette`](https://pypi.org/project/starlette/): Web framework used to build the HTTP transport endpoints. -- [`python-multipart`](https://pypi.org/project/python-multipart/): Handle HTTP body parsing. -- [`sse-starlette`](https://pypi.org/project/sse-starlette/): Server-Sent Events for Starlette, used to build the SSE transport endpoint. -- [`pydantic-settings`](https://pypi.org/project/pydantic-settings/): Settings management used in MCPServer. -- [`uvicorn`](https://pypi.org/project/uvicorn/): ASGI server used to run the HTTP transport endpoints. -- [`jsonschema`](https://pypi.org/project/jsonschema/): JSON schema validation. -- [`pywin32`](https://pypi.org/project/pywin32/): Windows specific dependencies for the CLI tools. - -This package has the following optional groups: - -- `cli`: Installs `typer` and `python-dotenv` for the MCP CLI tools. +# Installation + +The Python SDK is available on PyPI as [`mcp`](https://pypi.org/project/mcp/) so installation is as simple as: + +=== "pip" + + ```bash + pip install mcp + ``` +=== "uv" + + ```bash + uv add mcp + ``` + +The following dependencies are automatically installed: + +- [`httpx`](https://pypi.org/project/httpx/): HTTP client to handle HTTP Streamable and SSE transports. +- [`httpx-sse`](https://pypi.org/project/httpx-sse/): HTTP client to handle SSE transport. +- [`pydantic`](https://pypi.org/project/pydantic/): Types, JSON schema generation, data validation, and [more](https://docs.pydantic.dev/latest/). +- [`starlette`](https://pypi.org/project/starlette/): Web framework used to build the HTTP transport endpoints. +- [`python-multipart`](https://pypi.org/project/python-multipart/): Handle HTTP body parsing. +- [`sse-starlette`](https://pypi.org/project/sse-starlette/): Server-Sent Events for Starlette, used to build the SSE transport endpoint. +- [`pydantic-settings`](https://pypi.org/project/pydantic-settings/): Settings management used in MCPServer. +- [`uvicorn`](https://pypi.org/project/uvicorn/): ASGI server used to run the HTTP transport endpoints. +- [`jsonschema`](https://pypi.org/project/jsonschema/): JSON schema validation. +- [`pywin32`](https://pypi.org/project/pywin32/): Windows specific dependencies for the CLI tools. + +This package has the following optional groups: + +- `cli`: Installs `typer` and `python-dotenv` for the MCP CLI tools. diff --git a/docs/low-level-server.md b/docs/low-level-server.md index a5b4f3df3..92f235032 100644 --- a/docs/low-level-server.md +++ b/docs/low-level-server.md @@ -1,5 +1,5 @@ -# Low-Level Server - -!!! warning "Under Construction" - - This page is currently being written. Check back soon for complete documentation. +# Low-Level Server + +!!! warning "Under Construction" + + This page is currently being written. Check back soon for complete documentation. diff --git a/docs/migration.md b/docs/migration.md index 8b70885e8..d04816aa5 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1,1138 +1,1138 @@ -# Migration Guide: v1 to v2 - -This guide covers the breaking changes introduced in v2 of the MCP Python SDK and how to update your code. - -## Overview - -Version 2 of the MCP Python SDK introduces several breaking changes to improve the API, align with the MCP specification, and provide better type safety. - -## Breaking Changes - -### `streamablehttp_client` removed - -The deprecated `streamablehttp_client` function has been removed. Use `streamable_http_client` instead. - -**Before (v1):** - -```python -from mcp.client.streamable_http import streamablehttp_client - -async with streamablehttp_client( - url="http://localhost:8000/mcp", - headers={"Authorization": "Bearer token"}, - timeout=30, - sse_read_timeout=300, - auth=my_auth, -) as (read_stream, write_stream, get_session_id): - ... -``` - -**After (v2):** - -```python -import httpx -from mcp.client.streamable_http import streamable_http_client - -# Configure headers, timeout, and auth on the httpx.AsyncClient -http_client = httpx.AsyncClient( - headers={"Authorization": "Bearer token"}, - timeout=httpx.Timeout(30, read=300), - auth=my_auth, - follow_redirects=True, -) - -async with http_client: - async with streamable_http_client( - url="http://localhost:8000/mcp", - http_client=http_client, - ) as (read_stream, write_stream): - ... -``` - -v1's internal client set `follow_redirects=True`; set it explicitly when supplying your own `httpx.AsyncClient` to preserve that behavior. - -### `get_session_id` callback removed from `streamable_http_client` - -The `get_session_id` callback (third element of the returned tuple) has been removed from `streamable_http_client`. The function now returns a 2-tuple `(read_stream, write_stream)` instead of a 3-tuple. - -If you need to capture the session ID (e.g., for session resumption testing), you can use httpx event hooks to capture it from the response headers: - -**Before (v1):** - -```python -from mcp.client.streamable_http import streamable_http_client - -async with streamable_http_client(url) as (read_stream, write_stream, get_session_id): - async with ClientSession(read_stream, write_stream) as session: - await session.initialize() - session_id = get_session_id() # Get session ID via callback -``` - -**After (v2):** - -```python -import httpx -from mcp.client.streamable_http import streamable_http_client - -# Option 1: Simply ignore if you don't need the session ID -async with streamable_http_client(url) as (read_stream, write_stream): - async with ClientSession(read_stream, write_stream) as session: - await session.initialize() - -# Option 2: Capture session ID via httpx event hooks if needed -captured_session_ids: list[str] = [] - -async def capture_session_id(response: httpx.Response) -> None: - session_id = response.headers.get("mcp-session-id") - if session_id: - captured_session_ids.append(session_id) - -http_client = httpx.AsyncClient( - event_hooks={"response": [capture_session_id]}, - follow_redirects=True, -) - -async with http_client: - async with streamable_http_client(url, http_client=http_client) as (read_stream, write_stream): - async with ClientSession(read_stream, write_stream) as session: - await session.initialize() - session_id = captured_session_ids[0] if captured_session_ids else None -``` - -### `StreamableHTTPTransport` parameters removed - -The `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters have been removed from `StreamableHTTPTransport`. Configure these on the `httpx.AsyncClient` instead (see example above). - -Note: `sse_client` retains its `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters — only the streamable HTTP transport changed. - -### Removed type aliases and classes - -The following deprecated type aliases and classes have been removed from `mcp.types`: - -| Removed | Replacement | -|---------|-------------| -| `Content` | `ContentBlock` | -| `ResourceReference` | `ResourceTemplateReference` | -| `Cursor` | Use `str` directly | -| `MethodT` | Internal TypeVar, not intended for public use | -| `RequestParamsT` | Internal TypeVar, not intended for public use | -| `NotificationParamsT` | Internal TypeVar, not intended for public use | - -**Before (v1):** - -```python -from mcp.types import Content, ResourceReference, Cursor -``` - -**After (v2):** - -```python -from mcp.types import ContentBlock, ResourceTemplateReference -# Use `str` instead of `Cursor` for pagination cursors -``` - -### Field names changed from camelCase to snake_case - -All Pydantic model fields in `mcp.types` now use snake_case names for Python attribute access. The JSON wire format is unchanged — serialization still uses camelCase via Pydantic aliases. - -**Before (v1):** - -```python -result = await session.call_tool("my_tool", {"x": 1}) -if result.isError: - ... - -tools = await session.list_tools() -cursor = tools.nextCursor -schema = tools.tools[0].inputSchema -``` - -**After (v2):** - -```python -result = await session.call_tool("my_tool", {"x": 1}) -if result.is_error: - ... - -tools = await session.list_tools() -cursor = tools.next_cursor -schema = tools.tools[0].input_schema -``` - -Common renames: - -| v1 (camelCase) | v2 (snake_case) | -|----------------|-----------------| -| `inputSchema` | `input_schema` | -| `outputSchema` | `output_schema` | -| `isError` | `is_error` | -| `nextCursor` | `next_cursor` | -| `mimeType` | `mime_type` | -| `structuredContent` | `structured_content` | -| `serverInfo` | `server_info` | -| `protocolVersion` | `protocol_version` | -| `uriTemplate` | `uri_template` | -| `listChanged` | `list_changed` | -| `progressToken` | `progress_token` | - -Because `populate_by_name=True` is set, the old camelCase names still work as constructor kwargs (e.g., `Tool(inputSchema={...})` is accepted), but attribute access must use snake_case (`tool.input_schema`). - -### `args` parameter removed from `ClientSessionGroup.call_tool()` - -The deprecated `args` parameter has been removed from `ClientSessionGroup.call_tool()`. Use `arguments` instead. - -**Before (v1):** - -```python -result = await session_group.call_tool("my_tool", args={"key": "value"}) -``` - -**After (v2):** - -```python -result = await session_group.call_tool("my_tool", arguments={"key": "value"}) -``` - -### `cursor` parameter removed from `ClientSession` list methods - -The deprecated `cursor` parameter has been removed from the following `ClientSession` methods: - -- `list_resources()` -- `list_resource_templates()` -- `list_prompts()` -- `list_tools()` - -Use `params=PaginatedRequestParams(cursor=...)` instead. - -**Before (v1):** - -```python -result = await session.list_resources(cursor="next_page_token") -result = await session.list_tools(cursor="next_page_token") -``` - -**After (v2):** - -```python -from mcp.types import PaginatedRequestParams - -result = await session.list_resources(params=PaginatedRequestParams(cursor="next_page_token")) -result = await session.list_tools(params=PaginatedRequestParams(cursor="next_page_token")) -``` - -### `ClientSession.get_server_capabilities()` replaced by `initialize_result` property - -`ClientSession` now stores the full `InitializeResult` via an `initialize_result` property. This provides access to `server_info`, `capabilities`, `instructions`, and the negotiated `protocol_version` through a single property. The `get_server_capabilities()` method has been removed. - -**Before (v1):** - -```python -capabilities = session.get_server_capabilities() -# server_info, instructions, protocol_version were not stored — had to capture initialize() return value -``` - -**After (v2):** - -```python -result = session.initialize_result -if result is not None: - capabilities = result.capabilities - server_info = result.server_info - instructions = result.instructions - version = result.protocol_version -``` - -The high-level `Client.initialize_result` returns the same `InitializeResult` but is non-nullable — initialization is guaranteed inside the context manager, so no `None` check is needed. This replaces v1's `Client.server_capabilities`; use `client.initialize_result.capabilities` instead. - -### `McpError` renamed to `MCPError` - -The `McpError` exception class has been renamed to `MCPError` for consistent naming with the MCP acronym style used throughout the SDK. - -**Before (v1):** - -```python -from mcp.shared.exceptions import McpError - -try: - result = await session.call_tool("my_tool") -except McpError as e: - print(f"Error: {e.error.message}") -``` - -**After (v2):** - -```python -from mcp.shared.exceptions import MCPError - -try: - result = await session.call_tool("my_tool") -except MCPError as e: - print(f"Error: {e.message}") -``` - -`MCPError` is also exported from the top-level `mcp` package: - -```python -from mcp import MCPError -``` - -The constructor signature also changed — it now takes `code`, `message`, and optional `data` directly instead of wrapping an `ErrorData`: - -**Before (v1):** - -```python -from mcp.shared.exceptions import McpError -from mcp.types import ErrorData, INVALID_REQUEST - -raise McpError(ErrorData(code=INVALID_REQUEST, message="bad input")) -``` - -**After (v2):** - -```python -from mcp.shared.exceptions import MCPError -from mcp.types import INVALID_REQUEST - -raise MCPError(INVALID_REQUEST, "bad input") -# or, if you already have an ErrorData: -raise MCPError.from_error_data(error_data) -``` - -### `FastMCP` renamed to `MCPServer` - -The `FastMCP` class has been renamed to `MCPServer` to better reflect its role as the main server class in the SDK. This is a simple rename with no functional changes to the class itself. - -**Before (v1):** - -```python -from mcp.server.fastmcp import FastMCP - -mcp = FastMCP("Demo") -``` - -**After (v2):** - -```python -from mcp.server.mcpserver import MCPServer, Context - -mcp = MCPServer("Demo") -``` - -`Context` is the type annotation for the `ctx` parameter injected into tools, resources, and prompts (see [`get_context()` removed](#mcpserverget_context-removed) below). - -All submodules under `mcp.server.fastmcp.*` are now under `mcp.server.mcpserver.*` with the same structure. Common imports: - -- `Image`, `Audio` — from `mcp.server.mcpserver` (or `.utilities.types`) -- `UserMessage`, `AssistantMessage` — from `mcp.server.mcpserver.prompts.base` -- `ToolError`, `ResourceError` — from `mcp.server.mcpserver.exceptions` - -### `mount_path` parameter removed from MCPServer - -The `mount_path` parameter has been removed from `MCPServer.__init__()`, `MCPServer.run()`, `MCPServer.run_sse_async()`, and `MCPServer.sse_app()`. It was also removed from the `Settings` class. - -This parameter was redundant because the SSE transport already handles sub-path mounting via ASGI's standard `root_path` mechanism. When using Starlette's `Mount("/path", app=mcp.sse_app())`, Starlette automatically sets `root_path` in the ASGI scope, and the `SseServerTransport` uses this to construct the correct message endpoint path. - -### Transport-specific parameters moved from MCPServer constructor to run()/app methods - -Transport-specific parameters have been moved from the `MCPServer` constructor to the `run()`, `sse_app()`, and `streamable_http_app()` methods. This provides better separation of concerns - the constructor now only handles server identity and authentication, while transport configuration is passed when starting the server. - -**Parameters moved:** - -- `host`, `port` - HTTP server binding -- `sse_path`, `message_path` - SSE transport paths -- `streamable_http_path` - StreamableHTTP endpoint path -- `json_response`, `stateless_http` - StreamableHTTP behavior -- `event_store`, `retry_interval` - StreamableHTTP event handling -- `transport_security` - DNS rebinding protection - -**Before (v1):** - -```python -from mcp.server.fastmcp import FastMCP - -# Transport params in constructor -mcp = FastMCP("Demo", json_response=True, stateless_http=True) -mcp.run(transport="streamable-http") - -# Or for SSE -mcp = FastMCP("Server", host="0.0.0.0", port=9000, sse_path="/events") -mcp.run(transport="sse") -``` - -**After (v2):** - -```python -from mcp.server.mcpserver import MCPServer - -# Transport params passed to run() -mcp = MCPServer("Demo") -mcp.run(transport="streamable-http", json_response=True, stateless_http=True) - -# Or for SSE -mcp = MCPServer("Server") -mcp.run(transport="sse", host="0.0.0.0", port=9000, sse_path="/events") -``` - -**For mounted apps:** - -When mounting in a Starlette app, pass transport params to the app methods: - -```python -# Before (v1) -from mcp.server.fastmcp import FastMCP - -mcp = FastMCP("App", json_response=True) -app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())]) - -# After (v2) -from mcp.server.mcpserver import MCPServer - -mcp = MCPServer("App") -app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app(json_response=True))]) -``` - -**Note:** DNS rebinding protection is automatically enabled when `host` is `127.0.0.1`, `localhost`, or `::1`. This now happens in `sse_app()` and `streamable_http_app()` instead of the constructor. - -If you were mutating these via `mcp.settings` after construction (e.g., `mcp.settings.port = 9000`), pass them to `run()` / `sse_app()` / `streamable_http_app()` instead — these fields no longer exist on `Settings`. The `debug` and `log_level` parameters remain on the constructor. - -### `MCPServer.get_context()` removed - -`MCPServer.get_context()` has been removed. Context is now injected by the framework and passed explicitly — there is no ambient ContextVar to read from. - -**If you were calling `get_context()` from inside a tool/resource/prompt:** use the `ctx: Context` parameter injection instead. - -**Before (v1):** - -```python -@mcp.tool() -async def my_tool(x: int) -> str: - ctx = mcp.get_context() - await ctx.info("Processing...") - return str(x) -``` - -**After (v2):** - -```python -from mcp.server.mcpserver import Context - -@mcp.tool() -async def my_tool(x: int, ctx: Context) -> str: - await ctx.info("Processing...") - return str(x) -``` - -### `MCPServer.call_tool()`, `read_resource()`, `get_prompt()` now accept a `context` parameter - -`MCPServer.call_tool()`, `MCPServer.read_resource()`, and `MCPServer.get_prompt()` now accept an optional `context: Context | None = None` parameter. The framework passes this automatically during normal request handling. If you call these methods directly and omit `context`, a Context with no active request is constructed for you — tools that don't use `ctx` work normally, but any attempt to use `ctx.session`, `ctx.request_id`, etc. will raise. - -The internal layers (`ToolManager.call_tool`, `Tool.run`, `Prompt.render`, `ResourceTemplate.create_resource`, etc.) now require `context` as a positional argument. - -### Registering lowlevel handlers on `MCPServer` (workaround) - -`MCPServer` does not expose public APIs for `subscribe_resource`, `unsubscribe_resource`, or `set_logging_level` handlers. In v1, the workaround was to reach into the private lowlevel server and use its decorator methods: - -**Before (v1):** - -```python -@mcp._mcp_server.set_logging_level() # pyright: ignore[reportPrivateUsage] -async def handle_set_logging_level(level: str) -> None: - ... - -mcp._mcp_server.subscribe_resource()(handle_subscribe) # pyright: ignore[reportPrivateUsage] -``` - -In v2, the lowlevel `Server` no longer has decorator methods (handlers are constructor-only), so the equivalent workaround is `_add_request_handler`: - -**After (v2):** - -```python -from mcp.server import ServerRequestContext -from mcp.types import EmptyResult, SetLevelRequestParams, SubscribeRequestParams - - -async def handle_set_logging_level(ctx: ServerRequestContext, params: SetLevelRequestParams) -> EmptyResult: - ... - return EmptyResult() - - -async def handle_subscribe(ctx: ServerRequestContext, params: SubscribeRequestParams) -> EmptyResult: - ... - return EmptyResult() - - -mcp._lowlevel_server._add_request_handler("logging/setLevel", handle_set_logging_level) # pyright: ignore[reportPrivateUsage] -mcp._lowlevel_server._add_request_handler("resources/subscribe", handle_subscribe) # pyright: ignore[reportPrivateUsage] -``` - -This is a private API and may change. A public way to register these handlers on `MCPServer` is planned; until then, use this workaround or use the lowlevel `Server` directly. - -### `MCPServer`'s `Context` logging: `message` renamed to `data`, `extra` removed - -On the high-level `Context` object (`mcp.server.mcpserver.Context`), `log()`, `.debug()`, `.info()`, `.warning()`, and `.error()` now take `data: Any` instead of `message: str`, matching the MCP spec's `LoggingMessageNotificationParams.data` field which allows any JSON-serializable value. The `extra` parameter has been removed — pass structured data directly as `data`. - -The lowlevel `ServerSession.send_log_message(data: Any)` already accepted arbitrary data and is unchanged. - -`Context.log()` also now accepts all eight RFC-5424 log levels (`debug`, `info`, `notice`, `warning`, `error`, `critical`, `alert`, `emergency`) via the `LoggingLevel` type, not just the four it previously allowed. - -```python -# Before -await ctx.info("Connection failed", extra={"host": "localhost", "port": 5432}) -await ctx.log(level="info", message="hello") - -# After -await ctx.info({"message": "Connection failed", "host": "localhost", "port": 5432}) -await ctx.log(level="info", data="hello") -``` - -Positional calls (`await ctx.info("hello")`) are unaffected. - -### Replace `RootModel` by union types with `TypeAdapter` validation - -The following union types are no longer `RootModel` subclasses: - -- `ClientRequest` -- `ServerRequest` -- `ClientNotification` -- `ServerNotification` -- `ClientResult` -- `ServerResult` -- `JSONRPCMessage` - -This means you can no longer access `.root` on these types or use `model_validate()` directly on them. Instead, use the provided `TypeAdapter` instances for validation. - -**Before (v1):** - -```python -from mcp.types import ClientRequest, ServerNotification - -# Using RootModel.model_validate() -request = ClientRequest.model_validate(data) -actual_request = request.root # Accessing the wrapped value - -notification = ServerNotification.model_validate(data) -actual_notification = notification.root -``` - -**After (v2):** - -```python -from mcp.types import client_request_adapter, server_notification_adapter - -# Using TypeAdapter.validate_python() -request = client_request_adapter.validate_python(data) -# No .root access needed - request is the actual type - -notification = server_notification_adapter.validate_python(data) -# No .root access needed - notification is the actual type -``` - -The same applies when constructing values — the wrapper call is no longer needed: - -**Before (v1):** - -```python -await session.send_notification(ClientNotification(InitializedNotification())) -await session.send_request(ClientRequest(PingRequest()), EmptyResult) -``` - -**After (v2):** - -```python -await session.send_notification(InitializedNotification()) -await session.send_request(PingRequest(), EmptyResult) -``` - -**Available adapters:** - -| Union Type | Adapter | -|------------|---------| -| `ClientRequest` | `client_request_adapter` | -| `ServerRequest` | `server_request_adapter` | -| `ClientNotification` | `client_notification_adapter` | -| `ServerNotification` | `server_notification_adapter` | -| `ClientResult` | `client_result_adapter` | -| `ServerResult` | `server_result_adapter` | -| `JSONRPCMessage` | `jsonrpc_message_adapter` | - -All adapters are exported from `mcp.types`. - -### `RequestParams.Meta` replaced with `RequestParamsMeta` TypedDict - -The nested `RequestParams.Meta` Pydantic model class has been replaced with a top-level `RequestParamsMeta` TypedDict. This affects the `ctx.meta` field in request handlers and any code that imports or references this type. - -**Key changes:** - -- `RequestParams.Meta` (Pydantic model) → `RequestParamsMeta` (TypedDict) -- Attribute access (`meta.progress_token`) → Dictionary access (`meta.get("progress_token")`) -- `progress_token` field changed from `ProgressToken | None = None` to `NotRequired[ProgressToken]` - -**In request context handlers:** - -```python -# Before (v1) -@server.call_tool() -async def handle_tool(name: str, arguments: dict) -> list[TextContent]: - ctx = server.request_context - if ctx.meta and ctx.meta.progress_token: - await ctx.session.send_progress_notification(ctx.meta.progress_token, 0.5, 100) - -# After (v2) -async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: - if ctx.meta and "progress_token" in ctx.meta: - await ctx.session.send_progress_notification(ctx.meta["progress_token"], 0.5, 100) - ... - -server = Server("my-server", on_call_tool=handle_call_tool) -``` - -### `RequestContext` type parameters simplified - -The `mcp.shared.context` module has been removed. `RequestContext` is now split into `ClientRequestContext` (in `mcp.client.context`) and `ServerRequestContext` (in `mcp.server.context`). - -The `RequestContext` class has been split to separate shared fields from server-specific fields. The shared `RequestContext` now only takes 1 type parameter (the session type) instead of 3. - -**`RequestContext` changes:** - -- Type parameters reduced from `RequestContext[SessionT, LifespanContextT, RequestT]` to `RequestContext[SessionT]` -- Server-specific fields (`lifespan_context`, `experimental`, `request`, `close_sse_stream`, `close_standalone_sse_stream`) moved to new `ServerRequestContext` class in `mcp.server.context` - -**Before (v1):** - -```python -from mcp.client.session import ClientSession -from mcp.shared.context import RequestContext, LifespanContextT, RequestT - -# RequestContext with 3 type parameters -ctx: RequestContext[ClientSession, LifespanContextT, RequestT] -``` - -**After (v2):** - -```python -from mcp.client.context import ClientRequestContext -from mcp.server.context import ServerRequestContext, LifespanContextT, RequestT - -# For client-side context (sampling, elicitation, list_roots callbacks) -ctx: ClientRequestContext - -# For server-specific context with lifespan and request types -server_ctx: ServerRequestContext[LifespanContextT, RequestT] -``` - -The high-level `Context` class (injected into `@mcp.tool()` etc.) similarly dropped its `ServerSessionT` parameter: `Context[ServerSessionT, LifespanContextT, RequestT]` → `Context[LifespanContextT, RequestT]`. Both remaining parameters have defaults, so bare `Context` is usually sufficient: - -**Before (v1):** - -```python -async def my_tool(ctx: Context[ServerSession, None]) -> str: ... -``` - -**After (v2):** - -```python -async def my_tool(ctx: Context) -> str: ... -# or, with an explicit lifespan type: -async def my_tool(ctx: Context[MyLifespanState]) -> str: ... -``` - -### `ProgressContext` and `progress()` context manager removed - -The `mcp.shared.progress` module (`ProgressContext`, `Progress`, and the `progress()` context manager) has been removed. This module had no real-world adoption — all users send progress notifications via `Context.report_progress()` or `session.send_progress_notification()` directly. - -**Before (v1):** - -```python -from mcp.shared.progress import progress - -with progress(ctx, total=100) as p: - await p.progress(25) -``` - -**After — use `Context.report_progress()` (recommended):** - -```python -@server.tool() -async def my_tool(x: int, ctx: Context) -> str: - await ctx.report_progress(25, 100) - return "done" -``` - -**After — use `session.send_progress_notification()` (low-level):** - -```python -await session.send_progress_notification( - progress_token=progress_token, - progress=25, - total=100, -) -``` - -### `create_connected_server_and_client_session` removed - -The `create_connected_server_and_client_session` helper in `mcp.shared.memory` has been removed. Use `mcp.client.Client` instead — it accepts a `Server` or `MCPServer` instance directly and handles the in-memory transport and session setup for you. - -**Before (v1):** - -```python -from mcp.shared.memory import create_connected_server_and_client_session - -async with create_connected_server_and_client_session(server) as session: - result = await session.call_tool("my_tool", {"x": 1}) -``` - -**After (v2):** - -```python -from mcp.client import Client - -async with Client(server) as client: - result = await client.call_tool("my_tool", {"x": 1}) -``` - -`Client` accepts the same callback parameters the old helper did (`sampling_callback`, `list_roots_callback`, `logging_callback`, `message_handler`, `elicitation_callback`, `client_info`) plus `raise_exceptions` to surface server-side errors. - -If you need direct access to the underlying `ClientSession` and memory streams (e.g., for low-level transport testing), `create_client_server_memory_streams` is still available in `mcp.shared.memory`: - -```python -import anyio -from mcp.client.session import ClientSession -from mcp.shared.memory import create_client_server_memory_streams - -async with create_client_server_memory_streams() as (client_streams, server_streams): - async with anyio.create_task_group() as tg: - tg.start_soon(lambda: server.run(*server_streams, server.create_initialization_options())) - async with ClientSession(*client_streams) as session: - await session.initialize() - ... - tg.cancel_scope.cancel() -``` - -### Resource URI type changed from `AnyUrl` to `str` - -The `uri` field on resource-related types now uses `str` instead of Pydantic's `AnyUrl`. This aligns with the [MCP specification schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.ts) which defines URIs as plain strings (`uri: string`) without strict URL validation. This change allows relative paths like `users/me` that were previously rejected. - -**Before (v1):** - -```python -from pydantic import AnyUrl -from mcp.types import Resource - -# Required wrapping in AnyUrl -resource = Resource(name="test", uri=AnyUrl("users/me")) # Would fail validation -``` - -**After (v2):** - -```python -from mcp.types import Resource - -# Plain strings accepted -resource = Resource(name="test", uri="users/me") # Works -resource = Resource(name="test", uri="custom://scheme") # Works -resource = Resource(name="test", uri="https://example.com") # Works -``` - -If your code passes `AnyUrl` objects to URI fields, convert them to strings: - -```python -# If you have an AnyUrl from elsewhere -uri = str(my_any_url) # Convert to string -``` - -Affected types: - -- `Resource.uri` -- `ReadResourceRequestParams.uri` -- `ResourceContents.uri` (and subclasses `TextResourceContents`, `BlobResourceContents`) -- `SubscribeRequestParams.uri` -- `UnsubscribeRequestParams.uri` -- `ResourceUpdatedNotificationParams.uri` - -The `Client` and `ClientSession` methods `read_resource()`, `subscribe_resource()`, and `unsubscribe_resource()` now only accept `str` for the `uri` parameter. If you were passing `AnyUrl` objects, convert them to strings: - -```python -# Before (v1) -from pydantic import AnyUrl - -await client.read_resource(AnyUrl("test://resource")) - -# After (v2) -await client.read_resource("test://resource") -# Or if you have an AnyUrl from elsewhere: -await client.read_resource(str(my_any_url)) -``` - -### Lowlevel `Server`: constructor parameters are now keyword-only - -All parameters after `name` are now keyword-only. If you were passing `version` or other parameters positionally, use keyword arguments instead: - -```python -# Before (v1) -server = Server("my-server", "1.0") - -# After (v2) -server = Server("my-server", version="1.0") -``` - -### Lowlevel `Server`: type parameter reduced from 2 to 1 - -The `Server` class previously had two type parameters: `Server[LifespanResultT, RequestT]`. The `RequestT` parameter has been removed — handlers now receive typed params directly rather than a generic request type. - -```python -# Before (v1) -from typing import Any - -from mcp.server.lowlevel.server import Server - -server: Server[dict[str, Any], Any] = Server(...) - -# After (v2) -from typing import Any - -from mcp.server import Server - -server: Server[dict[str, Any]] = Server(...) -``` - -### Lowlevel `Server`: `request_handlers` and `notification_handlers` attributes removed - -The public `server.request_handlers` and `server.notification_handlers` dictionaries have been removed. Handler registration is now done exclusively through constructor `on_*` keyword arguments. There is no public API to register handlers after construction. - -```python -# Before (v1) — direct dict access -from mcp.types import ListToolsRequest - -if ListToolsRequest in server.request_handlers: - ... - -# After (v2) — no public access to handler dicts -# Use the on_* constructor params to register handlers -server = Server("my-server", on_list_tools=handle_list_tools) -``` - -If you need to check whether a handler is registered, track this yourself — there is currently no public introspection API. - -### Lowlevel `Server`: decorator-based handlers replaced with constructor `on_*` params - -The lowlevel `Server` class no longer uses decorator methods for handler registration. Instead, handlers are passed as `on_*` keyword arguments to the constructor. - -**Before (v1):** - -```python -from mcp.server.lowlevel.server import Server - -server = Server("my-server") - -@server.list_tools() -async def handle_list_tools(): - return [types.Tool(name="my_tool", description="A tool", inputSchema={})] - -@server.call_tool() -async def handle_call_tool(name: str, arguments: dict): - return [types.TextContent(type="text", text=f"Called {name}")] -``` - -**After (v2):** - -```python -from mcp.server import Server, ServerRequestContext -from mcp.types import ( - CallToolRequestParams, - CallToolResult, - ListToolsResult, - PaginatedRequestParams, - TextContent, - Tool, -) - -async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: - return ListToolsResult(tools=[Tool(name="my_tool", description="A tool", input_schema={})]) - - -async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: - return CallToolResult( - content=[TextContent(type="text", text=f"Called {params.name}")], - is_error=False, - ) - -server = Server("my-server", on_list_tools=handle_list_tools, on_call_tool=handle_call_tool) -``` - -**Key differences:** - -- Handlers receive `(ctx, params)` instead of the full request object or unpacked arguments. `ctx` is a `ServerRequestContext` with `session`, `lifespan_context`, and `experimental` fields (plus `request_id`, `meta`, etc. for request handlers). `params` is the typed request params object. -- Handlers return the full result type (e.g. `ListToolsResult`) rather than unwrapped values (e.g. `list[Tool]`). -- The automatic `jsonschema` input/output validation that the old `call_tool()` decorator performed has been removed. There is no built-in replacement — if you relied on schema validation in the lowlevel server, you will need to validate inputs yourself in your handler. - -**Complete handler reference:** - -All handlers receive `ctx: ServerRequestContext` as the first argument. The second argument and return type are: - -| v1 decorator | v2 constructor kwarg | `params` type | return type | -|---|---|---|---| -| `@server.list_tools()` | `on_list_tools` | `PaginatedRequestParams \| None` | `ListToolsResult` | -| `@server.call_tool()` | `on_call_tool` | `CallToolRequestParams` | `CallToolResult \| CreateTaskResult` | -| `@server.list_resources()` | `on_list_resources` | `PaginatedRequestParams \| None` | `ListResourcesResult` | -| `@server.list_resource_templates()` | `on_list_resource_templates` | `PaginatedRequestParams \| None` | `ListResourceTemplatesResult` | -| `@server.read_resource()` | `on_read_resource` | `ReadResourceRequestParams` | `ReadResourceResult` | -| `@server.subscribe_resource()` | `on_subscribe_resource` | `SubscribeRequestParams` | `EmptyResult` | -| `@server.unsubscribe_resource()` | `on_unsubscribe_resource` | `UnsubscribeRequestParams` | `EmptyResult` | -| `@server.list_prompts()` | `on_list_prompts` | `PaginatedRequestParams \| None` | `ListPromptsResult` | -| `@server.get_prompt()` | `on_get_prompt` | `GetPromptRequestParams` | `GetPromptResult` | -| `@server.completion()` | `on_completion` | `CompleteRequestParams` | `CompleteResult` | -| `@server.set_logging_level()` | `on_set_logging_level` | `SetLevelRequestParams` | `EmptyResult` | -| — | `on_ping` | `RequestParams \| None` | `EmptyResult` | -| `@server.progress_notification()` | `on_progress` | `ProgressNotificationParams` | `None` | -| — | `on_roots_list_changed` | `NotificationParams \| None` | `None` | - -All `params` and return types are importable from `mcp.types`. - -**Notification handlers:** - -```python -from mcp.server import Server, ServerRequestContext -from mcp.types import ProgressNotificationParams - - -async def handle_progress(ctx: ServerRequestContext, params: ProgressNotificationParams) -> None: - print(f"Progress: {params.progress}/{params.total}") - -server = Server("my-server", on_progress=handle_progress) -``` - -### Lowlevel `Server`: automatic return value wrapping removed - -The old decorator-based handlers performed significant automatic wrapping of return values. This magic has been removed — handlers now return fully constructed result types. If you want these conveniences, use `MCPServer` (previously `FastMCP`) instead of the lowlevel `Server`. - -**`call_tool()` — structured output wrapping removed:** - -The old decorator accepted several return types and auto-wrapped them into `CallToolResult`: - -```python -# Before (v1) — returning a dict auto-wrapped into structured_content + JSON TextContent -@server.call_tool() -async def handle(name: str, arguments: dict) -> dict: - return {"temperature": 22.5, "city": "London"} - -# Before (v1) — returning a list auto-wrapped into CallToolResult.content -@server.call_tool() -async def handle(name: str, arguments: dict) -> list[TextContent]: - return [TextContent(type="text", text="Done")] -``` - -```python -# After (v2) — construct the full result yourself -import json - -async def handle(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: - data = {"temperature": 22.5, "city": "London"} - return CallToolResult( - content=[TextContent(type="text", text=json.dumps(data, indent=2))], - structured_content=data, - ) -``` - -Note: `params.arguments` can be `None` (the old decorator defaulted it to `{}`). Use `params.arguments or {}` to preserve the old behavior. - -**`read_resource()` — content type wrapping removed:** - -The old decorator auto-wrapped `Iterable[ReadResourceContents]` (and the deprecated `str`/`bytes` shorthand) into `TextResourceContents`/`BlobResourceContents`, handling base64 encoding and mime-type defaulting: - -```python -# Before (v1) — Iterable[ReadResourceContents] auto-wrapped -from mcp.server.lowlevel.helper_types import ReadResourceContents - -@server.read_resource() -async def handle(uri: AnyUrl) -> Iterable[ReadResourceContents]: - return [ReadResourceContents(content="file contents", mime_type="text/plain")] - -# Before (v1) — str/bytes shorthand (already deprecated in v1) -@server.read_resource() -async def handle(uri: str) -> str: - return "file contents" - -@server.read_resource() -async def handle(uri: str) -> bytes: - return b"\x89PNG..." -``` - -```python -# After (v2) — construct TextResourceContents or BlobResourceContents yourself -import base64 - -async def handle_read(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult: - # Text content - return ReadResourceResult( - contents=[TextResourceContents(uri=str(params.uri), text="file contents", mime_type="text/plain")] - ) - -async def handle_read(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult: - # Binary content — you must base64-encode it yourself - return ReadResourceResult( - contents=[BlobResourceContents( - uri=str(params.uri), - blob=base64.b64encode(b"\x89PNG...").decode("utf-8"), - mime_type="image/png", - )] - ) -``` - -**`list_tools()`, `list_resources()`, `list_prompts()` — list wrapping removed:** - -The old decorators accepted bare lists and wrapped them into the result type: - -```python -# Before (v1) -@server.list_tools() -async def handle() -> list[Tool]: - return [Tool(name="my_tool", ...)] - -# After (v2) -async def handle(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: - return ListToolsResult(tools=[Tool(name="my_tool", ...)]) -``` - -**Using `MCPServer` instead:** - -If you prefer the convenience of automatic wrapping, use `MCPServer` which still provides these features through its `@mcp.tool()`, `@mcp.resource()`, and `@mcp.prompt()` decorators. The lowlevel `Server` is intentionally minimal — it provides no magic and gives you full control over the MCP protocol types. - -### Lowlevel `Server`: `request_context` property removed - -The `server.request_context` property has been removed. Request context is now passed directly to handlers as the first argument (`ctx`). The `request_ctx` module-level contextvar has been removed entirely. - -**Before (v1):** - -```python -from mcp.server.lowlevel.server import request_ctx - -@server.call_tool() -async def handle_call_tool(name: str, arguments: dict): - ctx = server.request_context # or request_ctx.get() - await ctx.session.send_log_message(level="info", data="Processing...") - return [types.TextContent(type="text", text="Done")] -``` - -**After (v2):** - -```python -from mcp.server import ServerRequestContext -from mcp.types import CallToolRequestParams, CallToolResult, TextContent - - -async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: - await ctx.session.send_log_message(level="info", data="Processing...") - return CallToolResult( - content=[TextContent(type="text", text="Done")], - is_error=False, - ) -``` - -### `RequestContext`: request-specific fields are now optional - -The `RequestContext` class now uses optional fields for request-specific data (`request_id`, `meta`, etc.) so it can be used for both request and notification handlers. In notification handlers, these fields are `None`. - -```python -from mcp.server import ServerRequestContext - -# request_id, meta, etc. are available in request handlers -# but None in notification handlers -``` - -### Experimental: task handler decorators removed - -The experimental decorator methods on `ExperimentalHandlers` (`@server.experimental.list_tasks()`, `@server.experimental.get_task()`, etc.) have been removed. - -Default task handlers are still registered automatically via `server.experimental.enable_tasks()`. Custom handlers can be passed as `on_*` kwargs to override specific defaults. - -**Before (v1):** - -```python -server = Server("my-server") -server.experimental.enable_tasks() - -@server.experimental.get_task() -async def custom_get_task(request: GetTaskRequest) -> GetTaskResult: - ... -``` - -**After (v2):** - -```python -from mcp.server import Server, ServerRequestContext -from mcp.types import GetTaskRequestParams, GetTaskResult - - -async def custom_get_task(ctx: ServerRequestContext, params: GetTaskRequestParams) -> GetTaskResult: - ... - - -server = Server("my-server") -server.experimental.enable_tasks(on_get_task=custom_get_task) -``` - -## Deprecations - -<!-- Add deprecations below --> - -## Bug Fixes - -### Lowlevel `Server`: `subscribe` capability now correctly reported - -Previously, the lowlevel `Server` hardcoded `subscribe=False` in resource capabilities even when a `subscribe_resource()` handler was registered. The `subscribe` capability is now dynamically set to `True` when an `on_subscribe_resource` handler is provided. Clients that previously didn't see `subscribe: true` in capabilities will now see it when a handler is registered, which may change client behavior. - -### Extra fields no longer allowed on top-level MCP types - -MCP protocol types no longer accept arbitrary extra fields at the top level. This matches the MCP specification which only allows extra fields within `_meta` objects, not on the types themselves. - -```python -# This will now raise a validation error -from mcp.types import CallToolRequestParams - -params = CallToolRequestParams( - name="my_tool", - arguments={}, - unknown_field="value", # ValidationError: extra fields not permitted -) - -# Extra fields are still allowed in _meta -params = CallToolRequestParams( - name="my_tool", - arguments={}, - _meta={"my_custom_key": "value", "another": 123}, # OK -) -``` - -## New Features - -### `streamable_http_app()` available on lowlevel Server - -The `streamable_http_app()` method is now available directly on the lowlevel `Server` class, not just `MCPServer`. This allows using the streamable HTTP transport without the MCPServer wrapper. - -```python -from mcp.server import Server, ServerRequestContext -from mcp.types import ListToolsResult, PaginatedRequestParams - - -async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: - return ListToolsResult(tools=[...]) - - -server = Server("my-server", on_list_tools=handle_list_tools) - -app = server.streamable_http_app( - streamable_http_path="/mcp", - json_response=False, - stateless_http=False, -) -``` - -The lowlevel `Server` also now exposes a `session_manager` property to access the `StreamableHTTPSessionManager` after calling `streamable_http_app()`. - -## Need Help? - -If you encounter issues during migration: - -1. Check the [API Reference](api/mcp/index.md) for updated method signatures -2. Review the [examples](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples) for updated usage patterns -3. Open an issue on [GitHub](https://github.com/modelcontextprotocol/python-sdk/issues) if you find a bug or need further assistance +# Migration Guide: v1 to v2 + +This guide covers the breaking changes introduced in v2 of the MCP Python SDK and how to update your code. + +## Overview + +Version 2 of the MCP Python SDK introduces several breaking changes to improve the API, align with the MCP specification, and provide better type safety. + +## Breaking Changes + +### `streamablehttp_client` removed + +The deprecated `streamablehttp_client` function has been removed. Use `streamable_http_client` instead. + +**Before (v1):** + +```python +from mcp.client.streamable_http import streamablehttp_client + +async with streamablehttp_client( + url="http://localhost:8000/mcp", + headers={"Authorization": "Bearer token"}, + timeout=30, + sse_read_timeout=300, + auth=my_auth, +) as (read_stream, write_stream, get_session_id): + ... +``` + +**After (v2):** + +```python +import httpx +from mcp.client.streamable_http import streamable_http_client + +# Configure headers, timeout, and auth on the httpx.AsyncClient +http_client = httpx.AsyncClient( + headers={"Authorization": "Bearer token"}, + timeout=httpx.Timeout(30, read=300), + auth=my_auth, + follow_redirects=True, +) + +async with http_client: + async with streamable_http_client( + url="http://localhost:8000/mcp", + http_client=http_client, + ) as (read_stream, write_stream): + ... +``` + +v1's internal client set `follow_redirects=True`; set it explicitly when supplying your own `httpx.AsyncClient` to preserve that behavior. + +### `get_session_id` callback removed from `streamable_http_client` + +The `get_session_id` callback (third element of the returned tuple) has been removed from `streamable_http_client`. The function now returns a 2-tuple `(read_stream, write_stream)` instead of a 3-tuple. + +If you need to capture the session ID (e.g., for session resumption testing), you can use httpx event hooks to capture it from the response headers: + +**Before (v1):** + +```python +from mcp.client.streamable_http import streamable_http_client + +async with streamable_http_client(url) as (read_stream, write_stream, get_session_id): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + session_id = get_session_id() # Get session ID via callback +``` + +**After (v2):** + +```python +import httpx +from mcp.client.streamable_http import streamable_http_client + +# Option 1: Simply ignore if you don't need the session ID +async with streamable_http_client(url) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + +# Option 2: Capture session ID via httpx event hooks if needed +captured_session_ids: list[str] = [] + +async def capture_session_id(response: httpx.Response) -> None: + session_id = response.headers.get("mcp-session-id") + if session_id: + captured_session_ids.append(session_id) + +http_client = httpx.AsyncClient( + event_hooks={"response": [capture_session_id]}, + follow_redirects=True, +) + +async with http_client: + async with streamable_http_client(url, http_client=http_client) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + session_id = captured_session_ids[0] if captured_session_ids else None +``` + +### `StreamableHTTPTransport` parameters removed + +The `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters have been removed from `StreamableHTTPTransport`. Configure these on the `httpx.AsyncClient` instead (see example above). + +Note: `sse_client` retains its `headers`, `timeout`, `sse_read_timeout`, and `auth` parameters — only the streamable HTTP transport changed. + +### Removed type aliases and classes + +The following deprecated type aliases and classes have been removed from `mcp.types`: + +| Removed | Replacement | +|---------|-------------| +| `Content` | `ContentBlock` | +| `ResourceReference` | `ResourceTemplateReference` | +| `Cursor` | Use `str` directly | +| `MethodT` | Internal TypeVar, not intended for public use | +| `RequestParamsT` | Internal TypeVar, not intended for public use | +| `NotificationParamsT` | Internal TypeVar, not intended for public use | + +**Before (v1):** + +```python +from mcp.types import Content, ResourceReference, Cursor +``` + +**After (v2):** + +```python +from mcp.types import ContentBlock, ResourceTemplateReference +# Use `str` instead of `Cursor` for pagination cursors +``` + +### Field names changed from camelCase to snake_case + +All Pydantic model fields in `mcp.types` now use snake_case names for Python attribute access. The JSON wire format is unchanged — serialization still uses camelCase via Pydantic aliases. + +**Before (v1):** + +```python +result = await session.call_tool("my_tool", {"x": 1}) +if result.isError: + ... + +tools = await session.list_tools() +cursor = tools.nextCursor +schema = tools.tools[0].inputSchema +``` + +**After (v2):** + +```python +result = await session.call_tool("my_tool", {"x": 1}) +if result.is_error: + ... + +tools = await session.list_tools() +cursor = tools.next_cursor +schema = tools.tools[0].input_schema +``` + +Common renames: + +| v1 (camelCase) | v2 (snake_case) | +|----------------|-----------------| +| `inputSchema` | `input_schema` | +| `outputSchema` | `output_schema` | +| `isError` | `is_error` | +| `nextCursor` | `next_cursor` | +| `mimeType` | `mime_type` | +| `structuredContent` | `structured_content` | +| `serverInfo` | `server_info` | +| `protocolVersion` | `protocol_version` | +| `uriTemplate` | `uri_template` | +| `listChanged` | `list_changed` | +| `progressToken` | `progress_token` | + +Because `populate_by_name=True` is set, the old camelCase names still work as constructor kwargs (e.g., `Tool(inputSchema={...})` is accepted), but attribute access must use snake_case (`tool.input_schema`). + +### `args` parameter removed from `ClientSessionGroup.call_tool()` + +The deprecated `args` parameter has been removed from `ClientSessionGroup.call_tool()`. Use `arguments` instead. + +**Before (v1):** + +```python +result = await session_group.call_tool("my_tool", args={"key": "value"}) +``` + +**After (v2):** + +```python +result = await session_group.call_tool("my_tool", arguments={"key": "value"}) +``` + +### `cursor` parameter removed from `ClientSession` list methods + +The deprecated `cursor` parameter has been removed from the following `ClientSession` methods: + +- `list_resources()` +- `list_resource_templates()` +- `list_prompts()` +- `list_tools()` + +Use `params=PaginatedRequestParams(cursor=...)` instead. + +**Before (v1):** + +```python +result = await session.list_resources(cursor="next_page_token") +result = await session.list_tools(cursor="next_page_token") +``` + +**After (v2):** + +```python +from mcp.types import PaginatedRequestParams + +result = await session.list_resources(params=PaginatedRequestParams(cursor="next_page_token")) +result = await session.list_tools(params=PaginatedRequestParams(cursor="next_page_token")) +``` + +### `ClientSession.get_server_capabilities()` replaced by `initialize_result` property + +`ClientSession` now stores the full `InitializeResult` via an `initialize_result` property. This provides access to `server_info`, `capabilities`, `instructions`, and the negotiated `protocol_version` through a single property. The `get_server_capabilities()` method has been removed. + +**Before (v1):** + +```python +capabilities = session.get_server_capabilities() +# server_info, instructions, protocol_version were not stored — had to capture initialize() return value +``` + +**After (v2):** + +```python +result = session.initialize_result +if result is not None: + capabilities = result.capabilities + server_info = result.server_info + instructions = result.instructions + version = result.protocol_version +``` + +The high-level `Client.initialize_result` returns the same `InitializeResult` but is non-nullable — initialization is guaranteed inside the context manager, so no `None` check is needed. This replaces v1's `Client.server_capabilities`; use `client.initialize_result.capabilities` instead. + +### `McpError` renamed to `MCPError` + +The `McpError` exception class has been renamed to `MCPError` for consistent naming with the MCP acronym style used throughout the SDK. + +**Before (v1):** + +```python +from mcp.shared.exceptions import McpError + +try: + result = await session.call_tool("my_tool") +except McpError as e: + print(f"Error: {e.error.message}") +``` + +**After (v2):** + +```python +from mcp.shared.exceptions import MCPError + +try: + result = await session.call_tool("my_tool") +except MCPError as e: + print(f"Error: {e.message}") +``` + +`MCPError` is also exported from the top-level `mcp` package: + +```python +from mcp import MCPError +``` + +The constructor signature also changed — it now takes `code`, `message`, and optional `data` directly instead of wrapping an `ErrorData`: + +**Before (v1):** + +```python +from mcp.shared.exceptions import McpError +from mcp.types import ErrorData, INVALID_REQUEST + +raise McpError(ErrorData(code=INVALID_REQUEST, message="bad input")) +``` + +**After (v2):** + +```python +from mcp.shared.exceptions import MCPError +from mcp.types import INVALID_REQUEST + +raise MCPError(INVALID_REQUEST, "bad input") +# or, if you already have an ErrorData: +raise MCPError.from_error_data(error_data) +``` + +### `FastMCP` renamed to `MCPServer` + +The `FastMCP` class has been renamed to `MCPServer` to better reflect its role as the main server class in the SDK. This is a simple rename with no functional changes to the class itself. + +**Before (v1):** + +```python +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("Demo") +``` + +**After (v2):** + +```python +from mcp.server.mcpserver import MCPServer, Context + +mcp = MCPServer("Demo") +``` + +`Context` is the type annotation for the `ctx` parameter injected into tools, resources, and prompts (see [`get_context()` removed](#mcpserverget_context-removed) below). + +All submodules under `mcp.server.fastmcp.*` are now under `mcp.server.mcpserver.*` with the same structure. Common imports: + +- `Image`, `Audio` — from `mcp.server.mcpserver` (or `.utilities.types`) +- `UserMessage`, `AssistantMessage` — from `mcp.server.mcpserver.prompts.base` +- `ToolError`, `ResourceError` — from `mcp.server.mcpserver.exceptions` + +### `mount_path` parameter removed from MCPServer + +The `mount_path` parameter has been removed from `MCPServer.__init__()`, `MCPServer.run()`, `MCPServer.run_sse_async()`, and `MCPServer.sse_app()`. It was also removed from the `Settings` class. + +This parameter was redundant because the SSE transport already handles sub-path mounting via ASGI's standard `root_path` mechanism. When using Starlette's `Mount("/path", app=mcp.sse_app())`, Starlette automatically sets `root_path` in the ASGI scope, and the `SseServerTransport` uses this to construct the correct message endpoint path. + +### Transport-specific parameters moved from MCPServer constructor to run()/app methods + +Transport-specific parameters have been moved from the `MCPServer` constructor to the `run()`, `sse_app()`, and `streamable_http_app()` methods. This provides better separation of concerns - the constructor now only handles server identity and authentication, while transport configuration is passed when starting the server. + +**Parameters moved:** + +- `host`, `port` - HTTP server binding +- `sse_path`, `message_path` - SSE transport paths +- `streamable_http_path` - StreamableHTTP endpoint path +- `json_response`, `stateless_http` - StreamableHTTP behavior +- `event_store`, `retry_interval` - StreamableHTTP event handling +- `transport_security` - DNS rebinding protection + +**Before (v1):** + +```python +from mcp.server.fastmcp import FastMCP + +# Transport params in constructor +mcp = FastMCP("Demo", json_response=True, stateless_http=True) +mcp.run(transport="streamable-http") + +# Or for SSE +mcp = FastMCP("Server", host="0.0.0.0", port=9000, sse_path="/events") +mcp.run(transport="sse") +``` + +**After (v2):** + +```python +from mcp.server.mcpserver import MCPServer + +# Transport params passed to run() +mcp = MCPServer("Demo") +mcp.run(transport="streamable-http", json_response=True, stateless_http=True) + +# Or for SSE +mcp = MCPServer("Server") +mcp.run(transport="sse", host="0.0.0.0", port=9000, sse_path="/events") +``` + +**For mounted apps:** + +When mounting in a Starlette app, pass transport params to the app methods: + +```python +# Before (v1) +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("App", json_response=True) +app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app())]) + +# After (v2) +from mcp.server.mcpserver import MCPServer + +mcp = MCPServer("App") +app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app(json_response=True))]) +``` + +**Note:** DNS rebinding protection is automatically enabled when `host` is `127.0.0.1`, `localhost`, or `::1`. This now happens in `sse_app()` and `streamable_http_app()` instead of the constructor. + +If you were mutating these via `mcp.settings` after construction (e.g., `mcp.settings.port = 9000`), pass them to `run()` / `sse_app()` / `streamable_http_app()` instead — these fields no longer exist on `Settings`. The `debug` and `log_level` parameters remain on the constructor. + +### `MCPServer.get_context()` removed + +`MCPServer.get_context()` has been removed. Context is now injected by the framework and passed explicitly — there is no ambient ContextVar to read from. + +**If you were calling `get_context()` from inside a tool/resource/prompt:** use the `ctx: Context` parameter injection instead. + +**Before (v1):** + +```python +@mcp.tool() +async def my_tool(x: int) -> str: + ctx = mcp.get_context() + await ctx.info("Processing...") + return str(x) +``` + +**After (v2):** + +```python +from mcp.server.mcpserver import Context + +@mcp.tool() +async def my_tool(x: int, ctx: Context) -> str: + await ctx.info("Processing...") + return str(x) +``` + +### `MCPServer.call_tool()`, `read_resource()`, `get_prompt()` now accept a `context` parameter + +`MCPServer.call_tool()`, `MCPServer.read_resource()`, and `MCPServer.get_prompt()` now accept an optional `context: Context | None = None` parameter. The framework passes this automatically during normal request handling. If you call these methods directly and omit `context`, a Context with no active request is constructed for you — tools that don't use `ctx` work normally, but any attempt to use `ctx.session`, `ctx.request_id`, etc. will raise. + +The internal layers (`ToolManager.call_tool`, `Tool.run`, `Prompt.render`, `ResourceTemplate.create_resource`, etc.) now require `context` as a positional argument. + +### Registering lowlevel handlers on `MCPServer` (workaround) + +`MCPServer` does not expose public APIs for `subscribe_resource`, `unsubscribe_resource`, or `set_logging_level` handlers. In v1, the workaround was to reach into the private lowlevel server and use its decorator methods: + +**Before (v1):** + +```python +@mcp._mcp_server.set_logging_level() # pyright: ignore[reportPrivateUsage] +async def handle_set_logging_level(level: str) -> None: + ... + +mcp._mcp_server.subscribe_resource()(handle_subscribe) # pyright: ignore[reportPrivateUsage] +``` + +In v2, the lowlevel `Server` no longer has decorator methods (handlers are constructor-only), so the equivalent workaround is `_add_request_handler`: + +**After (v2):** + +```python +from mcp.server import ServerRequestContext +from mcp.types import EmptyResult, SetLevelRequestParams, SubscribeRequestParams + + +async def handle_set_logging_level(ctx: ServerRequestContext, params: SetLevelRequestParams) -> EmptyResult: + ... + return EmptyResult() + + +async def handle_subscribe(ctx: ServerRequestContext, params: SubscribeRequestParams) -> EmptyResult: + ... + return EmptyResult() + + +mcp._lowlevel_server._add_request_handler("logging/setLevel", handle_set_logging_level) # pyright: ignore[reportPrivateUsage] +mcp._lowlevel_server._add_request_handler("resources/subscribe", handle_subscribe) # pyright: ignore[reportPrivateUsage] +``` + +This is a private API and may change. A public way to register these handlers on `MCPServer` is planned; until then, use this workaround or use the lowlevel `Server` directly. + +### `MCPServer`'s `Context` logging: `message` renamed to `data`, `extra` removed + +On the high-level `Context` object (`mcp.server.mcpserver.Context`), `log()`, `.debug()`, `.info()`, `.warning()`, and `.error()` now take `data: Any` instead of `message: str`, matching the MCP spec's `LoggingMessageNotificationParams.data` field which allows any JSON-serializable value. The `extra` parameter has been removed — pass structured data directly as `data`. + +The lowlevel `ServerSession.send_log_message(data: Any)` already accepted arbitrary data and is unchanged. + +`Context.log()` also now accepts all eight RFC-5424 log levels (`debug`, `info`, `notice`, `warning`, `error`, `critical`, `alert`, `emergency`) via the `LoggingLevel` type, not just the four it previously allowed. + +```python +# Before +await ctx.info("Connection failed", extra={"host": "localhost", "port": 5432}) +await ctx.log(level="info", message="hello") + +# After +await ctx.info({"message": "Connection failed", "host": "localhost", "port": 5432}) +await ctx.log(level="info", data="hello") +``` + +Positional calls (`await ctx.info("hello")`) are unaffected. + +### Replace `RootModel` by union types with `TypeAdapter` validation + +The following union types are no longer `RootModel` subclasses: + +- `ClientRequest` +- `ServerRequest` +- `ClientNotification` +- `ServerNotification` +- `ClientResult` +- `ServerResult` +- `JSONRPCMessage` + +This means you can no longer access `.root` on these types or use `model_validate()` directly on them. Instead, use the provided `TypeAdapter` instances for validation. + +**Before (v1):** + +```python +from mcp.types import ClientRequest, ServerNotification + +# Using RootModel.model_validate() +request = ClientRequest.model_validate(data) +actual_request = request.root # Accessing the wrapped value + +notification = ServerNotification.model_validate(data) +actual_notification = notification.root +``` + +**After (v2):** + +```python +from mcp.types import client_request_adapter, server_notification_adapter + +# Using TypeAdapter.validate_python() +request = client_request_adapter.validate_python(data) +# No .root access needed - request is the actual type + +notification = server_notification_adapter.validate_python(data) +# No .root access needed - notification is the actual type +``` + +The same applies when constructing values — the wrapper call is no longer needed: + +**Before (v1):** + +```python +await session.send_notification(ClientNotification(InitializedNotification())) +await session.send_request(ClientRequest(PingRequest()), EmptyResult) +``` + +**After (v2):** + +```python +await session.send_notification(InitializedNotification()) +await session.send_request(PingRequest(), EmptyResult) +``` + +**Available adapters:** + +| Union Type | Adapter | +|------------|---------| +| `ClientRequest` | `client_request_adapter` | +| `ServerRequest` | `server_request_adapter` | +| `ClientNotification` | `client_notification_adapter` | +| `ServerNotification` | `server_notification_adapter` | +| `ClientResult` | `client_result_adapter` | +| `ServerResult` | `server_result_adapter` | +| `JSONRPCMessage` | `jsonrpc_message_adapter` | + +All adapters are exported from `mcp.types`. + +### `RequestParams.Meta` replaced with `RequestParamsMeta` TypedDict + +The nested `RequestParams.Meta` Pydantic model class has been replaced with a top-level `RequestParamsMeta` TypedDict. This affects the `ctx.meta` field in request handlers and any code that imports or references this type. + +**Key changes:** + +- `RequestParams.Meta` (Pydantic model) → `RequestParamsMeta` (TypedDict) +- Attribute access (`meta.progress_token`) → Dictionary access (`meta.get("progress_token")`) +- `progress_token` field changed from `ProgressToken | None = None` to `NotRequired[ProgressToken]` + +**In request context handlers:** + +```python +# Before (v1) +@server.call_tool() +async def handle_tool(name: str, arguments: dict) -> list[TextContent]: + ctx = server.request_context + if ctx.meta and ctx.meta.progress_token: + await ctx.session.send_progress_notification(ctx.meta.progress_token, 0.5, 100) + +# After (v2) +async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + if ctx.meta and "progress_token" in ctx.meta: + await ctx.session.send_progress_notification(ctx.meta["progress_token"], 0.5, 100) + ... + +server = Server("my-server", on_call_tool=handle_call_tool) +``` + +### `RequestContext` type parameters simplified + +The `mcp.shared.context` module has been removed. `RequestContext` is now split into `ClientRequestContext` (in `mcp.client.context`) and `ServerRequestContext` (in `mcp.server.context`). + +The `RequestContext` class has been split to separate shared fields from server-specific fields. The shared `RequestContext` now only takes 1 type parameter (the session type) instead of 3. + +**`RequestContext` changes:** + +- Type parameters reduced from `RequestContext[SessionT, LifespanContextT, RequestT]` to `RequestContext[SessionT]` +- Server-specific fields (`lifespan_context`, `experimental`, `request`, `close_sse_stream`, `close_standalone_sse_stream`) moved to new `ServerRequestContext` class in `mcp.server.context` + +**Before (v1):** + +```python +from mcp.client.session import ClientSession +from mcp.shared.context import RequestContext, LifespanContextT, RequestT + +# RequestContext with 3 type parameters +ctx: RequestContext[ClientSession, LifespanContextT, RequestT] +``` + +**After (v2):** + +```python +from mcp.client.context import ClientRequestContext +from mcp.server.context import ServerRequestContext, LifespanContextT, RequestT + +# For client-side context (sampling, elicitation, list_roots callbacks) +ctx: ClientRequestContext + +# For server-specific context with lifespan and request types +server_ctx: ServerRequestContext[LifespanContextT, RequestT] +``` + +The high-level `Context` class (injected into `@mcp.tool()` etc.) similarly dropped its `ServerSessionT` parameter: `Context[ServerSessionT, LifespanContextT, RequestT]` → `Context[LifespanContextT, RequestT]`. Both remaining parameters have defaults, so bare `Context` is usually sufficient: + +**Before (v1):** + +```python +async def my_tool(ctx: Context[ServerSession, None]) -> str: ... +``` + +**After (v2):** + +```python +async def my_tool(ctx: Context) -> str: ... +# or, with an explicit lifespan type: +async def my_tool(ctx: Context[MyLifespanState]) -> str: ... +``` + +### `ProgressContext` and `progress()` context manager removed + +The `mcp.shared.progress` module (`ProgressContext`, `Progress`, and the `progress()` context manager) has been removed. This module had no real-world adoption — all users send progress notifications via `Context.report_progress()` or `session.send_progress_notification()` directly. + +**Before (v1):** + +```python +from mcp.shared.progress import progress + +with progress(ctx, total=100) as p: + await p.progress(25) +``` + +**After — use `Context.report_progress()` (recommended):** + +```python +@server.tool() +async def my_tool(x: int, ctx: Context) -> str: + await ctx.report_progress(25, 100) + return "done" +``` + +**After — use `session.send_progress_notification()` (low-level):** + +```python +await session.send_progress_notification( + progress_token=progress_token, + progress=25, + total=100, +) +``` + +### `create_connected_server_and_client_session` removed + +The `create_connected_server_and_client_session` helper in `mcp.shared.memory` has been removed. Use `mcp.client.Client` instead — it accepts a `Server` or `MCPServer` instance directly and handles the in-memory transport and session setup for you. + +**Before (v1):** + +```python +from mcp.shared.memory import create_connected_server_and_client_session + +async with create_connected_server_and_client_session(server) as session: + result = await session.call_tool("my_tool", {"x": 1}) +``` + +**After (v2):** + +```python +from mcp.client import Client + +async with Client(server) as client: + result = await client.call_tool("my_tool", {"x": 1}) +``` + +`Client` accepts the same callback parameters the old helper did (`sampling_callback`, `list_roots_callback`, `logging_callback`, `message_handler`, `elicitation_callback`, `client_info`) plus `raise_exceptions` to surface server-side errors. + +If you need direct access to the underlying `ClientSession` and memory streams (e.g., for low-level transport testing), `create_client_server_memory_streams` is still available in `mcp.shared.memory`: + +```python +import anyio +from mcp.client.session import ClientSession +from mcp.shared.memory import create_client_server_memory_streams + +async with create_client_server_memory_streams() as (client_streams, server_streams): + async with anyio.create_task_group() as tg: + tg.start_soon(lambda: server.run(*server_streams, server.create_initialization_options())) + async with ClientSession(*client_streams) as session: + await session.initialize() + ... + tg.cancel_scope.cancel() +``` + +### Resource URI type changed from `AnyUrl` to `str` + +The `uri` field on resource-related types now uses `str` instead of Pydantic's `AnyUrl`. This aligns with the [MCP specification schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.ts) which defines URIs as plain strings (`uri: string`) without strict URL validation. This change allows relative paths like `users/me` that were previously rejected. + +**Before (v1):** + +```python +from pydantic import AnyUrl +from mcp.types import Resource + +# Required wrapping in AnyUrl +resource = Resource(name="test", uri=AnyUrl("users/me")) # Would fail validation +``` + +**After (v2):** + +```python +from mcp.types import Resource + +# Plain strings accepted +resource = Resource(name="test", uri="users/me") # Works +resource = Resource(name="test", uri="custom://scheme") # Works +resource = Resource(name="test", uri="https://example.com") # Works +``` + +If your code passes `AnyUrl` objects to URI fields, convert them to strings: + +```python +# If you have an AnyUrl from elsewhere +uri = str(my_any_url) # Convert to string +``` + +Affected types: + +- `Resource.uri` +- `ReadResourceRequestParams.uri` +- `ResourceContents.uri` (and subclasses `TextResourceContents`, `BlobResourceContents`) +- `SubscribeRequestParams.uri` +- `UnsubscribeRequestParams.uri` +- `ResourceUpdatedNotificationParams.uri` + +The `Client` and `ClientSession` methods `read_resource()`, `subscribe_resource()`, and `unsubscribe_resource()` now only accept `str` for the `uri` parameter. If you were passing `AnyUrl` objects, convert them to strings: + +```python +# Before (v1) +from pydantic import AnyUrl + +await client.read_resource(AnyUrl("test://resource")) + +# After (v2) +await client.read_resource("test://resource") +# Or if you have an AnyUrl from elsewhere: +await client.read_resource(str(my_any_url)) +``` + +### Lowlevel `Server`: constructor parameters are now keyword-only + +All parameters after `name` are now keyword-only. If you were passing `version` or other parameters positionally, use keyword arguments instead: + +```python +# Before (v1) +server = Server("my-server", "1.0") + +# After (v2) +server = Server("my-server", version="1.0") +``` + +### Lowlevel `Server`: type parameter reduced from 2 to 1 + +The `Server` class previously had two type parameters: `Server[LifespanResultT, RequestT]`. The `RequestT` parameter has been removed — handlers now receive typed params directly rather than a generic request type. + +```python +# Before (v1) +from typing import Any + +from mcp.server.lowlevel.server import Server + +server: Server[dict[str, Any], Any] = Server(...) + +# After (v2) +from typing import Any + +from mcp.server import Server + +server: Server[dict[str, Any]] = Server(...) +``` + +### Lowlevel `Server`: `request_handlers` and `notification_handlers` attributes removed + +The public `server.request_handlers` and `server.notification_handlers` dictionaries have been removed. Handler registration is now done exclusively through constructor `on_*` keyword arguments. There is no public API to register handlers after construction. + +```python +# Before (v1) — direct dict access +from mcp.types import ListToolsRequest + +if ListToolsRequest in server.request_handlers: + ... + +# After (v2) — no public access to handler dicts +# Use the on_* constructor params to register handlers +server = Server("my-server", on_list_tools=handle_list_tools) +``` + +If you need to check whether a handler is registered, track this yourself — there is currently no public introspection API. + +### Lowlevel `Server`: decorator-based handlers replaced with constructor `on_*` params + +The lowlevel `Server` class no longer uses decorator methods for handler registration. Instead, handlers are passed as `on_*` keyword arguments to the constructor. + +**Before (v1):** + +```python +from mcp.server.lowlevel.server import Server + +server = Server("my-server") + +@server.list_tools() +async def handle_list_tools(): + return [types.Tool(name="my_tool", description="A tool", inputSchema={})] + +@server.call_tool() +async def handle_call_tool(name: str, arguments: dict): + return [types.TextContent(type="text", text=f"Called {name}")] +``` + +**After (v2):** + +```python +from mcp.server import Server, ServerRequestContext +from mcp.types import ( + CallToolRequestParams, + CallToolResult, + ListToolsResult, + PaginatedRequestParams, + TextContent, + Tool, +) + +async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[Tool(name="my_tool", description="A tool", input_schema={})]) + + +async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + return CallToolResult( + content=[TextContent(type="text", text=f"Called {params.name}")], + is_error=False, + ) + +server = Server("my-server", on_list_tools=handle_list_tools, on_call_tool=handle_call_tool) +``` + +**Key differences:** + +- Handlers receive `(ctx, params)` instead of the full request object or unpacked arguments. `ctx` is a `ServerRequestContext` with `session`, `lifespan_context`, and `experimental` fields (plus `request_id`, `meta`, etc. for request handlers). `params` is the typed request params object. +- Handlers return the full result type (e.g. `ListToolsResult`) rather than unwrapped values (e.g. `list[Tool]`). +- The automatic `jsonschema` input/output validation that the old `call_tool()` decorator performed has been removed. There is no built-in replacement — if you relied on schema validation in the lowlevel server, you will need to validate inputs yourself in your handler. + +**Complete handler reference:** + +All handlers receive `ctx: ServerRequestContext` as the first argument. The second argument and return type are: + +| v1 decorator | v2 constructor kwarg | `params` type | return type | +|---|---|---|---| +| `@server.list_tools()` | `on_list_tools` | `PaginatedRequestParams \| None` | `ListToolsResult` | +| `@server.call_tool()` | `on_call_tool` | `CallToolRequestParams` | `CallToolResult \| CreateTaskResult` | +| `@server.list_resources()` | `on_list_resources` | `PaginatedRequestParams \| None` | `ListResourcesResult` | +| `@server.list_resource_templates()` | `on_list_resource_templates` | `PaginatedRequestParams \| None` | `ListResourceTemplatesResult` | +| `@server.read_resource()` | `on_read_resource` | `ReadResourceRequestParams` | `ReadResourceResult` | +| `@server.subscribe_resource()` | `on_subscribe_resource` | `SubscribeRequestParams` | `EmptyResult` | +| `@server.unsubscribe_resource()` | `on_unsubscribe_resource` | `UnsubscribeRequestParams` | `EmptyResult` | +| `@server.list_prompts()` | `on_list_prompts` | `PaginatedRequestParams \| None` | `ListPromptsResult` | +| `@server.get_prompt()` | `on_get_prompt` | `GetPromptRequestParams` | `GetPromptResult` | +| `@server.completion()` | `on_completion` | `CompleteRequestParams` | `CompleteResult` | +| `@server.set_logging_level()` | `on_set_logging_level` | `SetLevelRequestParams` | `EmptyResult` | +| — | `on_ping` | `RequestParams \| None` | `EmptyResult` | +| `@server.progress_notification()` | `on_progress` | `ProgressNotificationParams` | `None` | +| — | `on_roots_list_changed` | `NotificationParams \| None` | `None` | + +All `params` and return types are importable from `mcp.types`. + +**Notification handlers:** + +```python +from mcp.server import Server, ServerRequestContext +from mcp.types import ProgressNotificationParams + + +async def handle_progress(ctx: ServerRequestContext, params: ProgressNotificationParams) -> None: + print(f"Progress: {params.progress}/{params.total}") + +server = Server("my-server", on_progress=handle_progress) +``` + +### Lowlevel `Server`: automatic return value wrapping removed + +The old decorator-based handlers performed significant automatic wrapping of return values. This magic has been removed — handlers now return fully constructed result types. If you want these conveniences, use `MCPServer` (previously `FastMCP`) instead of the lowlevel `Server`. + +**`call_tool()` — structured output wrapping removed:** + +The old decorator accepted several return types and auto-wrapped them into `CallToolResult`: + +```python +# Before (v1) — returning a dict auto-wrapped into structured_content + JSON TextContent +@server.call_tool() +async def handle(name: str, arguments: dict) -> dict: + return {"temperature": 22.5, "city": "London"} + +# Before (v1) — returning a list auto-wrapped into CallToolResult.content +@server.call_tool() +async def handle(name: str, arguments: dict) -> list[TextContent]: + return [TextContent(type="text", text="Done")] +``` + +```python +# After (v2) — construct the full result yourself +import json + +async def handle(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + data = {"temperature": 22.5, "city": "London"} + return CallToolResult( + content=[TextContent(type="text", text=json.dumps(data, indent=2))], + structured_content=data, + ) +``` + +Note: `params.arguments` can be `None` (the old decorator defaulted it to `{}`). Use `params.arguments or {}` to preserve the old behavior. + +**`read_resource()` — content type wrapping removed:** + +The old decorator auto-wrapped `Iterable[ReadResourceContents]` (and the deprecated `str`/`bytes` shorthand) into `TextResourceContents`/`BlobResourceContents`, handling base64 encoding and mime-type defaulting: + +```python +# Before (v1) — Iterable[ReadResourceContents] auto-wrapped +from mcp.server.lowlevel.helper_types import ReadResourceContents + +@server.read_resource() +async def handle(uri: AnyUrl) -> Iterable[ReadResourceContents]: + return [ReadResourceContents(content="file contents", mime_type="text/plain")] + +# Before (v1) — str/bytes shorthand (already deprecated in v1) +@server.read_resource() +async def handle(uri: str) -> str: + return "file contents" + +@server.read_resource() +async def handle(uri: str) -> bytes: + return b"\x89PNG..." +``` + +```python +# After (v2) — construct TextResourceContents or BlobResourceContents yourself +import base64 + +async def handle_read(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult: + # Text content + return ReadResourceResult( + contents=[TextResourceContents(uri=str(params.uri), text="file contents", mime_type="text/plain")] + ) + +async def handle_read(ctx: ServerRequestContext, params: ReadResourceRequestParams) -> ReadResourceResult: + # Binary content — you must base64-encode it yourself + return ReadResourceResult( + contents=[BlobResourceContents( + uri=str(params.uri), + blob=base64.b64encode(b"\x89PNG...").decode("utf-8"), + mime_type="image/png", + )] + ) +``` + +**`list_tools()`, `list_resources()`, `list_prompts()` — list wrapping removed:** + +The old decorators accepted bare lists and wrapped them into the result type: + +```python +# Before (v1) +@server.list_tools() +async def handle() -> list[Tool]: + return [Tool(name="my_tool", ...)] + +# After (v2) +async def handle(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[Tool(name="my_tool", ...)]) +``` + +**Using `MCPServer` instead:** + +If you prefer the convenience of automatic wrapping, use `MCPServer` which still provides these features through its `@mcp.tool()`, `@mcp.resource()`, and `@mcp.prompt()` decorators. The lowlevel `Server` is intentionally minimal — it provides no magic and gives you full control over the MCP protocol types. + +### Lowlevel `Server`: `request_context` property removed + +The `server.request_context` property has been removed. Request context is now passed directly to handlers as the first argument (`ctx`). The `request_ctx` module-level contextvar has been removed entirely. + +**Before (v1):** + +```python +from mcp.server.lowlevel.server import request_ctx + +@server.call_tool() +async def handle_call_tool(name: str, arguments: dict): + ctx = server.request_context # or request_ctx.get() + await ctx.session.send_log_message(level="info", data="Processing...") + return [types.TextContent(type="text", text="Done")] +``` + +**After (v2):** + +```python +from mcp.server import ServerRequestContext +from mcp.types import CallToolRequestParams, CallToolResult, TextContent + + +async def handle_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: + await ctx.session.send_log_message(level="info", data="Processing...") + return CallToolResult( + content=[TextContent(type="text", text="Done")], + is_error=False, + ) +``` + +### `RequestContext`: request-specific fields are now optional + +The `RequestContext` class now uses optional fields for request-specific data (`request_id`, `meta`, etc.) so it can be used for both request and notification handlers. In notification handlers, these fields are `None`. + +```python +from mcp.server import ServerRequestContext + +# request_id, meta, etc. are available in request handlers +# but None in notification handlers +``` + +### Experimental: task handler decorators removed + +The experimental decorator methods on `ExperimentalHandlers` (`@server.experimental.list_tasks()`, `@server.experimental.get_task()`, etc.) have been removed. + +Default task handlers are still registered automatically via `server.experimental.enable_tasks()`. Custom handlers can be passed as `on_*` kwargs to override specific defaults. + +**Before (v1):** + +```python +server = Server("my-server") +server.experimental.enable_tasks() + +@server.experimental.get_task() +async def custom_get_task(request: GetTaskRequest) -> GetTaskResult: + ... +``` + +**After (v2):** + +```python +from mcp.server import Server, ServerRequestContext +from mcp.types import GetTaskRequestParams, GetTaskResult + + +async def custom_get_task(ctx: ServerRequestContext, params: GetTaskRequestParams) -> GetTaskResult: + ... + + +server = Server("my-server") +server.experimental.enable_tasks(on_get_task=custom_get_task) +``` + +## Deprecations + +<!-- Add deprecations below --> + +## Bug Fixes + +### Lowlevel `Server`: `subscribe` capability now correctly reported + +Previously, the lowlevel `Server` hardcoded `subscribe=False` in resource capabilities even when a `subscribe_resource()` handler was registered. The `subscribe` capability is now dynamically set to `True` when an `on_subscribe_resource` handler is provided. Clients that previously didn't see `subscribe: true` in capabilities will now see it when a handler is registered, which may change client behavior. + +### Extra fields no longer allowed on top-level MCP types + +MCP protocol types no longer accept arbitrary extra fields at the top level. This matches the MCP specification which only allows extra fields within `_meta` objects, not on the types themselves. + +```python +# This will now raise a validation error +from mcp.types import CallToolRequestParams + +params = CallToolRequestParams( + name="my_tool", + arguments={}, + unknown_field="value", # ValidationError: extra fields not permitted +) + +# Extra fields are still allowed in _meta +params = CallToolRequestParams( + name="my_tool", + arguments={}, + _meta={"my_custom_key": "value", "another": 123}, # OK +) +``` + +## New Features + +### `streamable_http_app()` available on lowlevel Server + +The `streamable_http_app()` method is now available directly on the lowlevel `Server` class, not just `MCPServer`. This allows using the streamable HTTP transport without the MCPServer wrapper. + +```python +from mcp.server import Server, ServerRequestContext +from mcp.types import ListToolsResult, PaginatedRequestParams + + +async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: + return ListToolsResult(tools=[...]) + + +server = Server("my-server", on_list_tools=handle_list_tools) + +app = server.streamable_http_app( + streamable_http_path="/mcp", + json_response=False, + stateless_http=False, +) +``` + +The lowlevel `Server` also now exposes a `session_manager` property to access the `StreamableHTTPSessionManager` after calling `streamable_http_app()`. + +## Need Help? + +If you encounter issues during migration: + +1. Check the [API Reference](api/mcp/index.md) for updated method signatures +2. Review the [examples](https://github.com/modelcontextprotocol/python-sdk/tree/main/examples) for updated usage patterns +3. Open an issue on [GitHub](https://github.com/modelcontextprotocol/python-sdk/issues) if you find a bug or need further assistance diff --git a/docs/testing.md b/docs/testing.md index 9a222c906..bfbbad2a6 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,77 +1,77 @@ -# Testing MCP Servers - -The Python SDK provides a `Client` class for testing MCP servers with an in-memory transport. -This makes it easy to write tests without network overhead. - -## Basic Usage - -Let's assume you have a simple server with a single tool: - -```python title="server.py" -from mcp.server import MCPServer - -app = MCPServer("Calculator") - -@app.tool() -def add(a: int, b: int) -> int: - """Add two numbers.""" # (1)! - return a + b -``` - -1. The docstring is automatically added as the description of the tool. - -To run the below test, you'll need to install the following dependencies: - -=== "pip" - ```bash - pip install inline-snapshot pytest - ``` - -=== "uv" - ```bash - uv add inline-snapshot pytest - ``` - -!!! info - I think [`pytest`](https://docs.pytest.org/en/stable/) is a pretty standard testing framework, - so I won't go into details here. - - The [`inline-snapshot`](https://15r10nk.github.io/inline-snapshot/latest/) is a library that allows - you to take snapshots of the output of your tests. Which makes it easier to create tests for your - server - you don't need to use it, but we are spreading the word for best practices. - -```python title="test_server.py" -import pytest -from inline_snapshot import snapshot -from mcp import Client -from mcp.types import CallToolResult, TextContent - -from server import app - - -@pytest.fixture -def anyio_backend(): # (1)! - return "asyncio" - - -@pytest.fixture -async def client(): # (2)! - async with Client(app, raise_exceptions=True) as c: - yield c - - -@pytest.mark.anyio -async def test_call_add_tool(client: Client): - result = await client.call_tool("add", {"a": 1, "b": 2}) - assert result == snapshot( - CallToolResult( - content=[TextContent(type="text", text="3")], - structuredContent={"result": 3}, - ) - ) -``` - -1. If you are using `trio`, you should set `"trio"` as the `anyio_backend`. Check more information in the [anyio documentation](https://anyio.readthedocs.io/en/stable/testing.html#specifying-the-backends-to-run-on). -2. The `client` fixture creates a connected client that can be reused across multiple tests. - -There you go! You can now extend your tests to cover more scenarios. +# Testing MCP Servers + +The Python SDK provides a `Client` class for testing MCP servers with an in-memory transport. +This makes it easy to write tests without network overhead. + +## Basic Usage + +Let's assume you have a simple server with a single tool: + +```python title="server.py" +from mcp.server import MCPServer + +app = MCPServer("Calculator") + +@app.tool() +def add(a: int, b: int) -> int: + """Add two numbers.""" # (1)! + return a + b +``` + +1. The docstring is automatically added as the description of the tool. + +To run the below test, you'll need to install the following dependencies: + +=== "pip" + ```bash + pip install inline-snapshot pytest + ``` + +=== "uv" + ```bash + uv add inline-snapshot pytest + ``` + +!!! info + I think [`pytest`](https://docs.pytest.org/en/stable/) is a pretty standard testing framework, + so I won't go into details here. + + The [`inline-snapshot`](https://15r10nk.github.io/inline-snapshot/latest/) is a library that allows + you to take snapshots of the output of your tests. Which makes it easier to create tests for your + server - you don't need to use it, but we are spreading the word for best practices. + +```python title="test_server.py" +import pytest +from inline_snapshot import snapshot +from mcp import Client +from mcp.types import CallToolResult, TextContent + +from server import app + + +@pytest.fixture +def anyio_backend(): # (1)! + return "asyncio" + + +@pytest.fixture +async def client(): # (2)! + async with Client(app, raise_exceptions=True) as c: + yield c + + +@pytest.mark.anyio +async def test_call_add_tool(client: Client): + result = await client.call_tool("add", {"a": 1, "b": 2}) + assert result == snapshot( + CallToolResult( + content=[TextContent(type="text", text="3")], + structuredContent={"result": 3}, + ) + ) +``` + +1. If you are using `trio`, you should set `"trio"` as the `anyio_backend`. Check more information in the [anyio documentation](https://anyio.readthedocs.io/en/stable/testing.html#specifying-the-backends-to-run-on). +2. The `client` fixture creates a connected client that can be reused across multiple tests. + +There you go! You can now extend your tests to cover more scenarios. diff --git a/examples/README.md b/examples/README.md index 5ed4dd55f..6ee6968fa 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,5 +1,5 @@ -# Python SDK Examples - -This folders aims to provide simple examples of using the Python SDK. Please refer to the -[servers repository](https://github.com/modelcontextprotocol/servers) -for real-world servers. +# Python SDK Examples + +This folders aims to provide simple examples of using the Python SDK. Please refer to the +[servers repository](https://github.com/modelcontextprotocol/servers) +for real-world servers. diff --git a/examples/clients/simple-auth-client/README.md b/examples/clients/simple-auth-client/README.md index 708c0371b..b0b65729c 100644 --- a/examples/clients/simple-auth-client/README.md +++ b/examples/clients/simple-auth-client/README.md @@ -1,98 +1,98 @@ -# Simple Auth Client Example - -A demonstration of how to use the MCP Python SDK with OAuth authentication over streamable HTTP or SSE transport. - -## Features - -- OAuth 2.0 authentication with PKCE -- Support for both StreamableHTTP and SSE transports -- Interactive command-line interface - -## Installation - -```bash -cd examples/clients/simple-auth-client -uv sync --reinstall -``` - -## Usage - -### 1. Start an MCP server with OAuth support - -The simple-auth server example provides three server configurations. See [examples/servers/simple-auth/README.md](../../servers/simple-auth/README.md) for full details. - -#### Option A: New Architecture (Recommended) - -Separate Authorization Server and Resource Server: - -```bash -# Terminal 1: Start Authorization Server on port 9000 -cd examples/servers/simple-auth -uv run mcp-simple-auth-as --port=9000 - -# Terminal 2: Start Resource Server on port 8001 -cd examples/servers/simple-auth -uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --transport=streamable-http -``` - -#### Option B: Legacy Server (Backwards Compatibility) - -```bash -# Single server that acts as both AS and RS (port 8000) -cd examples/servers/simple-auth -uv run mcp-simple-auth-legacy --port=8000 --transport=streamable-http -``` - -### 2. Run the client - -```bash -# Connect to Resource Server (new architecture, default port 8001) -MCP_SERVER_PORT=8001 uv run mcp-simple-auth-client - -# Connect to Legacy Server (port 8000) -uv run mcp-simple-auth-client - -# Use SSE transport -MCP_SERVER_PORT=8001 MCP_TRANSPORT_TYPE=sse uv run mcp-simple-auth-client -``` - -### 3. Complete OAuth flow - -The client will open your browser for authentication. After completing OAuth, you can use commands: - -- `list` - List available tools -- `call <tool_name> [args]` - Call a tool with optional JSON arguments -- `quit` - Exit - -## Example - -```markdown -🚀 Simple MCP Auth Client -Connecting to: http://localhost:8001/mcp -Transport type: streamable-http - -🔗 Attempting to connect to http://localhost:8001/mcp... -📡 Opening StreamableHTTP transport connection with auth... -Opening browser for authorization: http://localhost:9000/authorize?... - -✅ Connected to MCP server at http://localhost:8001/mcp - -mcp> list -📋 Available tools: -1. get_time - Description: Get the current server time. - -mcp> call get_time -🔧 Tool 'get_time' result: -{"current_time": "2024-01-15T10:30:00", "timezone": "UTC", ...} - -mcp> quit -``` - -## Configuration - -| Environment Variable | Description | Default | -|---------------------|-------------|---------| -| `MCP_SERVER_PORT` | Port number of the MCP server | `8000` | -| `MCP_TRANSPORT_TYPE` | Transport type: `streamable-http` or `sse` | `streamable-http` | -| `MCP_CLIENT_METADATA_URL` | Optional URL for client metadata (CIMD) | None | +# Simple Auth Client Example + +A demonstration of how to use the MCP Python SDK with OAuth authentication over streamable HTTP or SSE transport. + +## Features + +- OAuth 2.0 authentication with PKCE +- Support for both StreamableHTTP and SSE transports +- Interactive command-line interface + +## Installation + +```bash +cd examples/clients/simple-auth-client +uv sync --reinstall +``` + +## Usage + +### 1. Start an MCP server with OAuth support + +The simple-auth server example provides three server configurations. See [examples/servers/simple-auth/README.md](../../servers/simple-auth/README.md) for full details. + +#### Option A: New Architecture (Recommended) + +Separate Authorization Server and Resource Server: + +```bash +# Terminal 1: Start Authorization Server on port 9000 +cd examples/servers/simple-auth +uv run mcp-simple-auth-as --port=9000 + +# Terminal 2: Start Resource Server on port 8001 +cd examples/servers/simple-auth +uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --transport=streamable-http +``` + +#### Option B: Legacy Server (Backwards Compatibility) + +```bash +# Single server that acts as both AS and RS (port 8000) +cd examples/servers/simple-auth +uv run mcp-simple-auth-legacy --port=8000 --transport=streamable-http +``` + +### 2. Run the client + +```bash +# Connect to Resource Server (new architecture, default port 8001) +MCP_SERVER_PORT=8001 uv run mcp-simple-auth-client + +# Connect to Legacy Server (port 8000) +uv run mcp-simple-auth-client + +# Use SSE transport +MCP_SERVER_PORT=8001 MCP_TRANSPORT_TYPE=sse uv run mcp-simple-auth-client +``` + +### 3. Complete OAuth flow + +The client will open your browser for authentication. After completing OAuth, you can use commands: + +- `list` - List available tools +- `call <tool_name> [args]` - Call a tool with optional JSON arguments +- `quit` - Exit + +## Example + +```markdown +🚀 Simple MCP Auth Client +Connecting to: http://localhost:8001/mcp +Transport type: streamable-http + +🔗 Attempting to connect to http://localhost:8001/mcp... +📡 Opening StreamableHTTP transport connection with auth... +Opening browser for authorization: http://localhost:9000/authorize?... + +✅ Connected to MCP server at http://localhost:8001/mcp + +mcp> list +📋 Available tools: +1. get_time + Description: Get the current server time. + +mcp> call get_time +🔧 Tool 'get_time' result: +{"current_time": "2024-01-15T10:30:00", "timezone": "UTC", ...} + +mcp> quit +``` + +## Configuration + +| Environment Variable | Description | Default | +|---------------------|-------------|---------| +| `MCP_SERVER_PORT` | Port number of the MCP server | `8000` | +| `MCP_TRANSPORT_TYPE` | Transport type: `streamable-http` or `sse` | `streamable-http` | +| `MCP_CLIENT_METADATA_URL` | Optional URL for client metadata (CIMD) | None | diff --git a/examples/clients/simple-auth-client/mcp_simple_auth_client/__init__.py b/examples/clients/simple-auth-client/mcp_simple_auth_client/__init__.py index 06eb1f29d..7073f4e7e 100644 --- a/examples/clients/simple-auth-client/mcp_simple_auth_client/__init__.py +++ b/examples/clients/simple-auth-client/mcp_simple_auth_client/__init__.py @@ -1 +1 @@ -"""Simple OAuth client for MCP simple-auth server.""" +"""Simple OAuth client for MCP simple-auth server.""" diff --git a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py index 6ef2f0b11..f66574f50 100644 --- a/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py +++ b/examples/clients/simple-auth-client/mcp_simple_auth_client/main.py @@ -1,383 +1,383 @@ -#!/usr/bin/env python3 -"""Simple MCP client example with OAuth authentication support. - -This client connects to an MCP server using streamable HTTP transport with OAuth. - -""" - -from __future__ import annotations as _annotations - -import asyncio -import os -import socketserver -import threading -import time -import webbrowser -from http.server import BaseHTTPRequestHandler, HTTPServer -from typing import Any -from urllib.parse import parse_qs, urlparse - -import httpx -from mcp.client._transport import ReadStream, WriteStream -from mcp.client.auth import OAuthClientProvider, TokenStorage -from mcp.client.session import ClientSession -from mcp.client.sse import sse_client -from mcp.client.streamable_http import streamable_http_client -from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken -from mcp.shared.message import SessionMessage - - -class InMemoryTokenStorage(TokenStorage): - """Simple in-memory token storage implementation.""" - - def __init__(self): - self._tokens: OAuthToken | None = None - self._client_info: OAuthClientInformationFull | None = None - - async def get_tokens(self) -> OAuthToken | None: - return self._tokens - - async def set_tokens(self, tokens: OAuthToken) -> None: - self._tokens = tokens - - async def get_client_info(self) -> OAuthClientInformationFull | None: - return self._client_info - - async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: - self._client_info = client_info - - -class CallbackHandler(BaseHTTPRequestHandler): - """Simple HTTP handler to capture OAuth callback.""" - - def __init__( - self, - request: Any, - client_address: tuple[str, int], - server: socketserver.BaseServer, - callback_data: dict[str, Any], - ): - """Initialize with callback data storage.""" - self.callback_data = callback_data - super().__init__(request, client_address, server) - - def do_GET(self): - """Handle GET request from OAuth redirect.""" - parsed = urlparse(self.path) - query_params = parse_qs(parsed.query) - - if "code" in query_params: - self.callback_data["authorization_code"] = query_params["code"][0] - self.callback_data["state"] = query_params.get("state", [None])[0] - self.send_response(200) - self.send_header("Content-type", "text/html") - self.end_headers() - self.wfile.write(b""" - <html> - <body> - <h1>Authorization Successful!</h1> - <p>You can close this window and return to the terminal.</p> - <script>setTimeout(() => window.close(), 2000);</script> - </body> - </html> - """) - elif "error" in query_params: - self.callback_data["error"] = query_params["error"][0] - self.send_response(400) - self.send_header("Content-type", "text/html") - self.end_headers() - self.wfile.write( - f""" - <html> - <body> - <h1>Authorization Failed</h1> - <p>Error: {query_params["error"][0]}</p> - <p>You can close this window and return to the terminal.</p> - </body> - </html> - """.encode() - ) - else: - self.send_response(404) - self.end_headers() - - def log_message(self, format: str, *args: Any): - """Suppress default logging.""" - - -class CallbackServer: - """Simple server to handle OAuth callbacks.""" - - def __init__(self, port: int = 3000): - self.port = port - self.server = None - self.thread = None - self.callback_data = {"authorization_code": None, "state": None, "error": None} - - def _create_handler_with_data(self): - """Create a handler class with access to callback data.""" - callback_data = self.callback_data - - class DataCallbackHandler(CallbackHandler): - def __init__( - self, - request: BaseHTTPRequestHandler, - client_address: tuple[str, int], - server: socketserver.BaseServer, - ): - super().__init__(request, client_address, server, callback_data) - - return DataCallbackHandler - - def start(self): - """Start the callback server in a background thread.""" - handler_class = self._create_handler_with_data() - self.server = HTTPServer(("localhost", self.port), handler_class) - self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) - self.thread.start() - print(f"🖥️ Started callback server on http://localhost:{self.port}") - - def stop(self): - """Stop the callback server.""" - if self.server: - self.server.shutdown() - self.server.server_close() - if self.thread: - self.thread.join(timeout=1) - - def wait_for_callback(self, timeout: int = 300): - """Wait for OAuth callback with timeout.""" - start_time = time.time() - while time.time() - start_time < timeout: - if self.callback_data["authorization_code"]: - return self.callback_data["authorization_code"] - elif self.callback_data["error"]: - raise Exception(f"OAuth error: {self.callback_data['error']}") - time.sleep(0.1) - raise Exception("Timeout waiting for OAuth callback") - - def get_state(self): - """Get the received state parameter.""" - return self.callback_data["state"] - - -class SimpleAuthClient: - """Simple MCP client with auth support.""" - - def __init__( - self, - server_url: str, - transport_type: str = "streamable-http", - client_metadata_url: str | None = None, - ): - self.server_url = server_url - self.transport_type = transport_type - self.client_metadata_url = client_metadata_url - self.session: ClientSession | None = None - - async def connect(self): - """Connect to the MCP server.""" - print(f"🔗 Attempting to connect to {self.server_url}...") - - try: - callback_server = CallbackServer(port=3030) - callback_server.start() - - async def callback_handler() -> tuple[str, str | None]: - """Wait for OAuth callback and return auth code and state.""" - print("⏳ Waiting for authorization callback...") - try: - auth_code = callback_server.wait_for_callback(timeout=300) - return auth_code, callback_server.get_state() - finally: - callback_server.stop() - - client_metadata_dict = { - "client_name": "Simple Auth Client", - "redirect_uris": ["http://localhost:3030/callback"], - "grant_types": ["authorization_code", "refresh_token"], - "response_types": ["code"], - } - - async def _default_redirect_handler(authorization_url: str) -> None: - """Default redirect handler that opens the URL in a browser.""" - print(f"Opening browser for authorization: {authorization_url}") - webbrowser.open(authorization_url) - - # Create OAuth authentication handler using the new interface - # Use client_metadata_url to enable CIMD when the server supports it - oauth_auth = OAuthClientProvider( - server_url=self.server_url.replace("/mcp", ""), - client_metadata=OAuthClientMetadata.model_validate(client_metadata_dict), - storage=InMemoryTokenStorage(), - redirect_handler=_default_redirect_handler, - callback_handler=callback_handler, - client_metadata_url=self.client_metadata_url, - ) - - # Create transport with auth handler based on transport type - if self.transport_type == "sse": - print("📡 Opening SSE transport connection with auth...") - async with sse_client( - url=self.server_url, - auth=oauth_auth, - timeout=60.0, - ) as (read_stream, write_stream): - await self._run_session(read_stream, write_stream) - else: - print("📡 Opening StreamableHTTP transport connection with auth...") - async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client: - async with streamable_http_client(url=self.server_url, http_client=custom_client) as ( - read_stream, - write_stream, - ): - await self._run_session(read_stream, write_stream) - - except Exception as e: - print(f"❌ Failed to connect: {e}") - import traceback - - traceback.print_exc() - - async def _run_session( - self, - read_stream: ReadStream[SessionMessage | Exception], - write_stream: WriteStream[SessionMessage], - ): - """Run the MCP session with the given streams.""" - print("🤝 Initializing MCP session...") - async with ClientSession(read_stream, write_stream) as session: - self.session = session - print("⚡ Starting session initialization...") - await session.initialize() - print("✨ Session initialization complete!") - - print(f"\n✅ Connected to MCP server at {self.server_url}") - - # Run interactive loop - await self.interactive_loop() - - async def list_tools(self): - """List available tools from the server.""" - if not self.session: - print("❌ Not connected to server") - return - - try: - result = await self.session.list_tools() - if hasattr(result, "tools") and result.tools: - print("\n📋 Available tools:") - for i, tool in enumerate(result.tools, 1): - print(f"{i}. {tool.name}") - if tool.description: - print(f" Description: {tool.description}") - print() - else: - print("No tools available") - except Exception as e: - print(f"❌ Failed to list tools: {e}") - - async def call_tool(self, tool_name: str, arguments: dict[str, Any] | None = None): - """Call a specific tool.""" - if not self.session: - print("❌ Not connected to server") - return - - try: - result = await self.session.call_tool(tool_name, arguments or {}) - print(f"\n🔧 Tool '{tool_name}' result:") - if hasattr(result, "content"): - for content in result.content: - if content.type == "text": - print(content.text) - else: - print(content) - else: - print(result) - except Exception as e: - print(f"❌ Failed to call tool '{tool_name}': {e}") - - async def interactive_loop(self): - """Run interactive command loop.""" - print("\n🎯 Interactive MCP Client") - print("Commands:") - print(" list - List available tools") - print(" call <tool_name> [args] - Call a tool") - print(" quit - Exit the client") - print() - - while True: - try: - command = input("mcp> ").strip() - - if not command: - continue - - if command == "quit": - break - - elif command == "list": - await self.list_tools() - - elif command.startswith("call "): - parts = command.split(maxsplit=2) - tool_name = parts[1] if len(parts) > 1 else "" - - if not tool_name: - print("❌ Please specify a tool name") - continue - - # Parse arguments (simple JSON-like format) - arguments: dict[str, Any] = {} - if len(parts) > 2: - import json - - try: - arguments = json.loads(parts[2]) - except json.JSONDecodeError: - print("❌ Invalid arguments format (expected JSON)") - continue - - await self.call_tool(tool_name, arguments) - - else: - print("❌ Unknown command. Try 'list', 'call <tool_name>', or 'quit'") - - except KeyboardInterrupt: - print("\n\n👋 Goodbye!") - break - except EOFError: - break - - -async def main(): - """Main entry point.""" - # Default server URL - can be overridden with environment variable - # Most MCP streamable HTTP servers use /mcp as the endpoint - server_url = os.getenv("MCP_SERVER_PORT", 8000) - transport_type = os.getenv("MCP_TRANSPORT_TYPE", "streamable-http") - client_metadata_url = os.getenv("MCP_CLIENT_METADATA_URL") - server_url = ( - f"http://localhost:{server_url}/mcp" - if transport_type == "streamable-http" - else f"http://localhost:{server_url}/sse" - ) - - print("🚀 Simple MCP Auth Client") - print(f"Connecting to: {server_url}") - print(f"Transport type: {transport_type}") - if client_metadata_url: - print(f"Client metadata URL: {client_metadata_url}") - - # Start connection flow - OAuth will be handled automatically - client = SimpleAuthClient(server_url, transport_type, client_metadata_url) - await client.connect() - - -def cli(): - """CLI entry point for uv script.""" - asyncio.run(main()) - - -if __name__ == "__main__": - cli() +#!/usr/bin/env python3 +"""Simple MCP client example with OAuth authentication support. + +This client connects to an MCP server using streamable HTTP transport with OAuth. + +""" + +from __future__ import annotations as _annotations + +import asyncio +import os +import socketserver +import threading +import time +import webbrowser +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import Any +from urllib.parse import parse_qs, urlparse + +import httpx +from mcp.client._transport import ReadStream, WriteStream +from mcp.client.auth import OAuthClientProvider, TokenStorage +from mcp.client.session import ClientSession +from mcp.client.sse import sse_client +from mcp.client.streamable_http import streamable_http_client +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken +from mcp.shared.message import SessionMessage + + +class InMemoryTokenStorage(TokenStorage): + """Simple in-memory token storage implementation.""" + + def __init__(self): + self._tokens: OAuthToken | None = None + self._client_info: OAuthClientInformationFull | None = None + + async def get_tokens(self) -> OAuthToken | None: + return self._tokens + + async def set_tokens(self, tokens: OAuthToken) -> None: + self._tokens = tokens + + async def get_client_info(self) -> OAuthClientInformationFull | None: + return self._client_info + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + self._client_info = client_info + + +class CallbackHandler(BaseHTTPRequestHandler): + """Simple HTTP handler to capture OAuth callback.""" + + def __init__( + self, + request: Any, + client_address: tuple[str, int], + server: socketserver.BaseServer, + callback_data: dict[str, Any], + ): + """Initialize with callback data storage.""" + self.callback_data = callback_data + super().__init__(request, client_address, server) + + def do_GET(self): + """Handle GET request from OAuth redirect.""" + parsed = urlparse(self.path) + query_params = parse_qs(parsed.query) + + if "code" in query_params: + self.callback_data["authorization_code"] = query_params["code"][0] + self.callback_data["state"] = query_params.get("state", [None])[0] + self.send_response(200) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write(b""" + <html> + <body> + <h1>Authorization Successful!</h1> + <p>You can close this window and return to the terminal.</p> + <script>setTimeout(() => window.close(), 2000);</script> + </body> + </html> + """) + elif "error" in query_params: + self.callback_data["error"] = query_params["error"][0] + self.send_response(400) + self.send_header("Content-type", "text/html") + self.end_headers() + self.wfile.write( + f""" + <html> + <body> + <h1>Authorization Failed</h1> + <p>Error: {query_params["error"][0]}</p> + <p>You can close this window and return to the terminal.</p> + </body> + </html> + """.encode() + ) + else: + self.send_response(404) + self.end_headers() + + def log_message(self, format: str, *args: Any): + """Suppress default logging.""" + + +class CallbackServer: + """Simple server to handle OAuth callbacks.""" + + def __init__(self, port: int = 3000): + self.port = port + self.server = None + self.thread = None + self.callback_data = {"authorization_code": None, "state": None, "error": None} + + def _create_handler_with_data(self): + """Create a handler class with access to callback data.""" + callback_data = self.callback_data + + class DataCallbackHandler(CallbackHandler): + def __init__( + self, + request: BaseHTTPRequestHandler, + client_address: tuple[str, int], + server: socketserver.BaseServer, + ): + super().__init__(request, client_address, server, callback_data) + + return DataCallbackHandler + + def start(self): + """Start the callback server in a background thread.""" + handler_class = self._create_handler_with_data() + self.server = HTTPServer(("localhost", self.port), handler_class) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + print(f"🖥️ Started callback server on http://localhost:{self.port}") + + def stop(self): + """Stop the callback server.""" + if self.server: + self.server.shutdown() + self.server.server_close() + if self.thread: + self.thread.join(timeout=1) + + def wait_for_callback(self, timeout: int = 300): + """Wait for OAuth callback with timeout.""" + start_time = time.time() + while time.time() - start_time < timeout: + if self.callback_data["authorization_code"]: + return self.callback_data["authorization_code"] + elif self.callback_data["error"]: + raise Exception(f"OAuth error: {self.callback_data['error']}") + time.sleep(0.1) + raise Exception("Timeout waiting for OAuth callback") + + def get_state(self): + """Get the received state parameter.""" + return self.callback_data["state"] + + +class SimpleAuthClient: + """Simple MCP client with auth support.""" + + def __init__( + self, + server_url: str, + transport_type: str = "streamable-http", + client_metadata_url: str | None = None, + ): + self.server_url = server_url + self.transport_type = transport_type + self.client_metadata_url = client_metadata_url + self.session: ClientSession | None = None + + async def connect(self): + """Connect to the MCP server.""" + print(f"🔗 Attempting to connect to {self.server_url}...") + + try: + callback_server = CallbackServer(port=3030) + callback_server.start() + + async def callback_handler() -> tuple[str, str | None]: + """Wait for OAuth callback and return auth code and state.""" + print("⏳ Waiting for authorization callback...") + try: + auth_code = callback_server.wait_for_callback(timeout=300) + return auth_code, callback_server.get_state() + finally: + callback_server.stop() + + client_metadata_dict = { + "client_name": "Simple Auth Client", + "redirect_uris": ["http://localhost:3030/callback"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + } + + async def _default_redirect_handler(authorization_url: str) -> None: + """Default redirect handler that opens the URL in a browser.""" + print(f"Opening browser for authorization: {authorization_url}") + webbrowser.open(authorization_url) + + # Create OAuth authentication handler using the new interface + # Use client_metadata_url to enable CIMD when the server supports it + oauth_auth = OAuthClientProvider( + server_url=self.server_url.replace("/mcp", ""), + client_metadata=OAuthClientMetadata.model_validate(client_metadata_dict), + storage=InMemoryTokenStorage(), + redirect_handler=_default_redirect_handler, + callback_handler=callback_handler, + client_metadata_url=self.client_metadata_url, + ) + + # Create transport with auth handler based on transport type + if self.transport_type == "sse": + print("📡 Opening SSE transport connection with auth...") + async with sse_client( + url=self.server_url, + auth=oauth_auth, + timeout=60.0, + ) as (read_stream, write_stream): + await self._run_session(read_stream, write_stream) + else: + print("📡 Opening StreamableHTTP transport connection with auth...") + async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client: + async with streamable_http_client(url=self.server_url, http_client=custom_client) as ( + read_stream, + write_stream, + ): + await self._run_session(read_stream, write_stream) + + except Exception as e: + print(f"❌ Failed to connect: {e}") + import traceback + + traceback.print_exc() + + async def _run_session( + self, + read_stream: ReadStream[SessionMessage | Exception], + write_stream: WriteStream[SessionMessage], + ): + """Run the MCP session with the given streams.""" + print("🤝 Initializing MCP session...") + async with ClientSession(read_stream, write_stream) as session: + self.session = session + print("⚡ Starting session initialization...") + await session.initialize() + print("✨ Session initialization complete!") + + print(f"\n✅ Connected to MCP server at {self.server_url}") + + # Run interactive loop + await self.interactive_loop() + + async def list_tools(self): + """List available tools from the server.""" + if not self.session: + print("❌ Not connected to server") + return + + try: + result = await self.session.list_tools() + if hasattr(result, "tools") and result.tools: + print("\n📋 Available tools:") + for i, tool in enumerate(result.tools, 1): + print(f"{i}. {tool.name}") + if tool.description: + print(f" Description: {tool.description}") + print() + else: + print("No tools available") + except Exception as e: + print(f"❌ Failed to list tools: {e}") + + async def call_tool(self, tool_name: str, arguments: dict[str, Any] | None = None): + """Call a specific tool.""" + if not self.session: + print("❌ Not connected to server") + return + + try: + result = await self.session.call_tool(tool_name, arguments or {}) + print(f"\n🔧 Tool '{tool_name}' result:") + if hasattr(result, "content"): + for content in result.content: + if content.type == "text": + print(content.text) + else: + print(content) + else: + print(result) + except Exception as e: + print(f"❌ Failed to call tool '{tool_name}': {e}") + + async def interactive_loop(self): + """Run interactive command loop.""" + print("\n🎯 Interactive MCP Client") + print("Commands:") + print(" list - List available tools") + print(" call <tool_name> [args] - Call a tool") + print(" quit - Exit the client") + print() + + while True: + try: + command = input("mcp> ").strip() + + if not command: + continue + + if command == "quit": + break + + elif command == "list": + await self.list_tools() + + elif command.startswith("call "): + parts = command.split(maxsplit=2) + tool_name = parts[1] if len(parts) > 1 else "" + + if not tool_name: + print("❌ Please specify a tool name") + continue + + # Parse arguments (simple JSON-like format) + arguments: dict[str, Any] = {} + if len(parts) > 2: + import json + + try: + arguments = json.loads(parts[2]) + except json.JSONDecodeError: + print("❌ Invalid arguments format (expected JSON)") + continue + + await self.call_tool(tool_name, arguments) + + else: + print("❌ Unknown command. Try 'list', 'call <tool_name>', or 'quit'") + + except KeyboardInterrupt: + print("\n\n👋 Goodbye!") + break + except EOFError: + break + + +async def main(): + """Main entry point.""" + # Default server URL - can be overridden with environment variable + # Most MCP streamable HTTP servers use /mcp as the endpoint + server_url = os.getenv("MCP_SERVER_PORT", 8000) + transport_type = os.getenv("MCP_TRANSPORT_TYPE", "streamable-http") + client_metadata_url = os.getenv("MCP_CLIENT_METADATA_URL") + server_url = ( + f"http://localhost:{server_url}/mcp" + if transport_type == "streamable-http" + else f"http://localhost:{server_url}/sse" + ) + + print("🚀 Simple MCP Auth Client") + print(f"Connecting to: {server_url}") + print(f"Transport type: {transport_type}") + if client_metadata_url: + print(f"Client metadata URL: {client_metadata_url}") + + # Start connection flow - OAuth will be handled automatically + client = SimpleAuthClient(server_url, transport_type, client_metadata_url) + await client.connect() + + +def cli(): + """CLI entry point for uv script.""" + asyncio.run(main()) + + +if __name__ == "__main__": + cli() diff --git a/examples/clients/simple-auth-client/pyproject.toml b/examples/clients/simple-auth-client/pyproject.toml index f84d1430f..eccf01b26 100644 --- a/examples/clients/simple-auth-client/pyproject.toml +++ b/examples/clients/simple-auth-client/pyproject.toml @@ -1,43 +1,43 @@ -[project] -name = "mcp-simple-auth-client" -version = "0.1.0" -description = "A simple OAuth client for the MCP simple-auth server" -readme = "README.md" -requires-python = ">=3.10" -authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] -keywords = ["mcp", "oauth", "client", "auth"] -license = { text = "MIT" } -classifiers = [ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", -] -dependencies = ["click>=8.2.0", "mcp"] - -[project.scripts] -mcp-simple-auth-client = "mcp_simple_auth_client.main:cli" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["mcp_simple_auth_client"] - -[tool.pyright] -include = ["mcp_simple_auth_client"] -venvPath = "." -venv = ".venv" - -[tool.ruff.lint] -select = ["E", "F", "I"] -ignore = [] - -[tool.ruff] -line-length = 120 -target-version = "py310" - -[dependency-groups] -dev = ["pyright>=1.1.379", "pytest>=8.3.3", "ruff>=0.6.9"] +[project] +name = "mcp-simple-auth-client" +version = "0.1.0" +description = "A simple OAuth client for the MCP simple-auth server" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +keywords = ["mcp", "oauth", "client", "auth"] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", +] +dependencies = ["click>=8.2.0", "mcp"] + +[project.scripts] +mcp-simple-auth-client = "mcp_simple_auth_client.main:cli" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_auth_client"] + +[tool.pyright] +include = ["mcp_simple_auth_client"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.379", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/clients/simple-chatbot/.python-version b/examples/clients/simple-chatbot/.python-version index c8cfe3959..2951d9b02 100644 --- a/examples/clients/simple-chatbot/.python-version +++ b/examples/clients/simple-chatbot/.python-version @@ -1 +1 @@ -3.10 +3.10 diff --git a/examples/clients/simple-chatbot/README.MD b/examples/clients/simple-chatbot/README.MD index 482109f97..46c913c36 100644 --- a/examples/clients/simple-chatbot/README.MD +++ b/examples/clients/simple-chatbot/README.MD @@ -1,113 +1,113 @@ -# MCP Simple Chatbot - -This example demonstrates how to integrate the Model Context Protocol (MCP) into a simple CLI chatbot. The implementation showcases MCP's flexibility by supporting multiple tools through MCP servers and is compatible with any LLM provider that follows OpenAI API standards. - -## Requirements - -- Python 3.10 -- `python-dotenv` -- `requests` -- `mcp` -- `uvicorn` - -## Installation - -1. **Install the dependencies:** - - ```bash - pip install -r requirements.txt - ``` - -2. **Set up environment variables:** - - Create a `.env` file in the root directory and add your API key: - - ```plaintext - LLM_API_KEY=your_api_key_here - ``` - - **Note:** The current implementation is configured to use the Groq API endpoint (`https://api.groq.com/openai/v1/chat/completions`) with the `llama-3.2-90b-vision-preview` model. If you plan to use a different LLM provider, you'll need to modify the `LLMClient` class in `main.py` to use the appropriate endpoint URL and model parameters. - -3. **Configure servers:** - - The `servers_config.json` follows the same structure as Claude Desktop, allowing for easy integration of multiple servers. - Here's an example: - - ```json - { - "mcpServers": { - "sqlite": { - "command": "uvx", - "args": ["mcp-server-sqlite", "--db-path", "./test.db"] - }, - "puppeteer": { - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-puppeteer"] - } - } - } - ``` - - Environment variables are supported as well. Pass them as you would with the Claude Desktop App. - - Example: - - ```json - { - "mcpServers": { - "server_name": { - "command": "uvx", - "args": ["mcp-server-name", "--additional-args"], - "env": { - "API_KEY": "your_api_key_here" - } - } - } - } - ``` - -## Usage - -1. **Run the client:** - - ```bash - python main.py - ``` - -2. **Interact with the assistant:** - - The assistant will automatically detect available tools and can respond to queries based on the tools provided by the configured servers. - -3. **Exit the session:** - - Type `quit` or `exit` to end the session. - -## Architecture - -- **Tool Discovery**: Tools are automatically discovered from configured servers. -- **System Prompt**: Tools are dynamically included in the system prompt, allowing the LLM to understand available capabilities. -- **Server Integration**: Supports any MCP-compatible server, tested with various server implementations including Uvicorn and Node.js. - -### Class Structure - -- **Configuration**: Manages environment variables and server configurations -- **Server**: Handles MCP server initialization, tool discovery, and execution -- **Tool**: Represents individual tools with their properties and formatting -- **LLMClient**: Manages communication with the LLM provider -- **ChatSession**: Orchestrates the interaction between user, LLM, and tools - -### Logic Flow - -1. **Tool Integration**: - - Tools are dynamically discovered from MCP servers - - Tool descriptions are automatically included in system prompt - - Tool execution is handled through standardized MCP protocol - -2. **Runtime Flow**: - - User input is received - - Input is sent to LLM with context of available tools - - LLM response is parsed: - - If it's a tool call → execute tool and return result - - If it's a direct response → return to user - - Tool results are sent back to LLM for interpretation - - Final response is presented to user +# MCP Simple Chatbot + +This example demonstrates how to integrate the Model Context Protocol (MCP) into a simple CLI chatbot. The implementation showcases MCP's flexibility by supporting multiple tools through MCP servers and is compatible with any LLM provider that follows OpenAI API standards. + +## Requirements + +- Python 3.10 +- `python-dotenv` +- `requests` +- `mcp` +- `uvicorn` + +## Installation + +1. **Install the dependencies:** + + ```bash + pip install -r requirements.txt + ``` + +2. **Set up environment variables:** + + Create a `.env` file in the root directory and add your API key: + + ```plaintext + LLM_API_KEY=your_api_key_here + ``` + + **Note:** The current implementation is configured to use the Groq API endpoint (`https://api.groq.com/openai/v1/chat/completions`) with the `llama-3.2-90b-vision-preview` model. If you plan to use a different LLM provider, you'll need to modify the `LLMClient` class in `main.py` to use the appropriate endpoint URL and model parameters. + +3. **Configure servers:** + + The `servers_config.json` follows the same structure as Claude Desktop, allowing for easy integration of multiple servers. + Here's an example: + + ```json + { + "mcpServers": { + "sqlite": { + "command": "uvx", + "args": ["mcp-server-sqlite", "--db-path", "./test.db"] + }, + "puppeteer": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-puppeteer"] + } + } + } + ``` + + Environment variables are supported as well. Pass them as you would with the Claude Desktop App. + + Example: + + ```json + { + "mcpServers": { + "server_name": { + "command": "uvx", + "args": ["mcp-server-name", "--additional-args"], + "env": { + "API_KEY": "your_api_key_here" + } + } + } + } + ``` + +## Usage + +1. **Run the client:** + + ```bash + python main.py + ``` + +2. **Interact with the assistant:** + + The assistant will automatically detect available tools and can respond to queries based on the tools provided by the configured servers. + +3. **Exit the session:** + + Type `quit` or `exit` to end the session. + +## Architecture + +- **Tool Discovery**: Tools are automatically discovered from configured servers. +- **System Prompt**: Tools are dynamically included in the system prompt, allowing the LLM to understand available capabilities. +- **Server Integration**: Supports any MCP-compatible server, tested with various server implementations including Uvicorn and Node.js. + +### Class Structure + +- **Configuration**: Manages environment variables and server configurations +- **Server**: Handles MCP server initialization, tool discovery, and execution +- **Tool**: Represents individual tools with their properties and formatting +- **LLMClient**: Manages communication with the LLM provider +- **ChatSession**: Orchestrates the interaction between user, LLM, and tools + +### Logic Flow + +1. **Tool Integration**: + - Tools are dynamically discovered from MCP servers + - Tool descriptions are automatically included in system prompt + - Tool execution is handled through standardized MCP protocol + +2. **Runtime Flow**: + - User input is received + - Input is sent to LLM with context of available tools + - LLM response is parsed: + - If it's a tool call → execute tool and return result + - If it's a direct response → return to user + - Tool results are sent back to LLM for interpretation + - Final response is presented to user diff --git a/examples/clients/simple-chatbot/mcp_simple_chatbot/.env.example b/examples/clients/simple-chatbot/mcp_simple_chatbot/.env.example index 39be363c2..dd198dfbb 100644 --- a/examples/clients/simple-chatbot/mcp_simple_chatbot/.env.example +++ b/examples/clients/simple-chatbot/mcp_simple_chatbot/.env.example @@ -1 +1 @@ -LLM_API_KEY=gsk_1234567890 +LLM_API_KEY=gsk_1234567890 diff --git a/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py b/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py index 72b1a6f20..637a25205 100644 --- a/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py +++ b/examples/clients/simple-chatbot/mcp_simple_chatbot/main.py @@ -1,421 +1,421 @@ -from __future__ import annotations - -import asyncio -import json -import logging -import os -import shutil -from contextlib import AsyncExitStack -from typing import Any - -import httpx -from dotenv import load_dotenv -from mcp import ClientSession, StdioServerParameters -from mcp.client.stdio import stdio_client - -# Configure logging -logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") - - -class Configuration: - """Manages configuration and environment variables for the MCP client.""" - - def __init__(self) -> None: - """Initialize configuration with environment variables.""" - self.load_env() - self.api_key = os.getenv("LLM_API_KEY") - - @staticmethod - def load_env() -> None: - """Load environment variables from .env file.""" - load_dotenv() - - @staticmethod - def load_config(file_path: str) -> dict[str, Any]: - """Load server configuration from JSON file. - - Args: - file_path: Path to the JSON configuration file. - - Returns: - Dict containing server configuration. - - Raises: - FileNotFoundError: If configuration file doesn't exist. - JSONDecodeError: If configuration file is invalid JSON. - """ - with open(file_path, "r") as f: - return json.load(f) - - @property - def llm_api_key(self) -> str: - """Get the LLM API key. - - Returns: - The API key as a string. - - Raises: - ValueError: If the API key is not found in environment variables. - """ - if not self.api_key: - raise ValueError("LLM_API_KEY not found in environment variables") - return self.api_key - - -class Server: - """Manages MCP server connections and tool execution.""" - - def __init__(self, name: str, config: dict[str, Any]) -> None: - self.name: str = name - self.config: dict[str, Any] = config - self.stdio_context: Any | None = None - self.session: ClientSession | None = None - self._cleanup_lock: asyncio.Lock = asyncio.Lock() - self.exit_stack: AsyncExitStack = AsyncExitStack() - - async def initialize(self) -> None: - """Initialize the server connection.""" - command = shutil.which("npx") if self.config["command"] == "npx" else self.config["command"] - if command is None: - raise ValueError("The command must be a valid string and cannot be None.") - - server_params = StdioServerParameters( - command=command, - args=self.config["args"], - env={**os.environ, **self.config["env"]} if self.config.get("env") else None, - ) - try: - stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params)) - read, write = stdio_transport - session = await self.exit_stack.enter_async_context(ClientSession(read, write)) - await session.initialize() - self.session = session - except Exception as e: - logging.error(f"Error initializing server {self.name}: {e}") - await self.cleanup() - raise - - async def list_tools(self) -> list[Tool]: - """List available tools from the server. - - Returns: - A list of available tools. - - Raises: - RuntimeError: If the server is not initialized. - """ - if not self.session: - raise RuntimeError(f"Server {self.name} not initialized") - - tools_response = await self.session.list_tools() - tools: list[Tool] = [] - - for item in tools_response: - if item[0] == "tools": - tools.extend(Tool(tool.name, tool.description, tool.input_schema, tool.title) for tool in item[1]) - - return tools - - async def execute_tool( - self, - tool_name: str, - arguments: dict[str, Any], - retries: int = 2, - delay: float = 1.0, - ) -> Any: - """Execute a tool with retry mechanism. - - Args: - tool_name: Name of the tool to execute. - arguments: Tool arguments. - retries: Number of retry attempts. - delay: Delay between retries in seconds. - - Returns: - Tool execution result. - - Raises: - RuntimeError: If server is not initialized. - Exception: If tool execution fails after all retries. - """ - if not self.session: - raise RuntimeError(f"Server {self.name} not initialized") - - attempt = 0 - while attempt < retries: - try: - logging.info(f"Executing {tool_name}...") - result = await self.session.call_tool(tool_name, arguments) - - return result - - except Exception as e: - attempt += 1 - logging.warning(f"Error executing tool: {e}. Attempt {attempt} of {retries}.") - if attempt < retries: - logging.info(f"Retrying in {delay} seconds...") - await asyncio.sleep(delay) - else: - logging.error("Max retries reached. Failing.") - raise - - async def cleanup(self) -> None: - """Clean up server resources.""" - async with self._cleanup_lock: - try: - await self.exit_stack.aclose() - self.session = None - self.stdio_context = None - except Exception as e: - logging.error(f"Error during cleanup of server {self.name}: {e}") - - -class Tool: - """Represents a tool with its properties and formatting.""" - - def __init__( - self, - name: str, - description: str, - input_schema: dict[str, Any], - title: str | None = None, - ) -> None: - self.name: str = name - self.title: str | None = title - self.description: str = description - self.input_schema: dict[str, Any] = input_schema - - def format_for_llm(self) -> str: - """Format tool information for LLM. - - Returns: - A formatted string describing the tool. - """ - args_desc: list[str] = [] - if "properties" in self.input_schema: - for param_name, param_info in self.input_schema["properties"].items(): - arg_desc = f"- {param_name}: {param_info.get('description', 'No description')}" - if param_name in self.input_schema.get("required", []): - arg_desc += " (required)" - args_desc.append(arg_desc) - - # Build the formatted output with title as a separate field - output = f"Tool: {self.name}\n" - - # Add human-readable title if available - if self.title: - output += f"User-readable title: {self.title}\n" - - output += f"""Description: {self.description} -Arguments: -{chr(10).join(args_desc)} -""" - - return output - - -class LLMClient: - """Manages communication with the LLM provider.""" - - def __init__(self, api_key: str) -> None: - self.api_key: str = api_key - - def get_response(self, messages: list[dict[str, str]]) -> str: - """Get a response from the LLM. - - Args: - messages: A list of message dictionaries. - - Returns: - The LLM's response as a string. - - Raises: - httpx.RequestError: If the request to the LLM fails. - """ - url = "https://api.groq.com/openai/v1/chat/completions" - - headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {self.api_key}", - } - payload = { - "messages": messages, - "model": "meta-llama/llama-4-scout-17b-16e-instruct", - "temperature": 0.7, - "max_tokens": 4096, - "top_p": 1, - "stream": False, - "stop": None, - } - - try: - with httpx.Client() as client: - response = client.post(url, headers=headers, json=payload) - response.raise_for_status() - data = response.json() - return data["choices"][0]["message"]["content"] - - except httpx.RequestError as e: - error_message = f"Error getting LLM response: {str(e)}" - logging.error(error_message) - - if isinstance(e, httpx.HTTPStatusError): - status_code = e.response.status_code - logging.error(f"Status code: {status_code}") - logging.error(f"Response details: {e.response.text}") - - return f"I encountered an error: {error_message}. Please try again or rephrase your request." - - -class ChatSession: - """Orchestrates the interaction between user, LLM, and tools.""" - - def __init__(self, servers: list[Server], llm_client: LLMClient) -> None: - self.servers: list[Server] = servers - self.llm_client: LLMClient = llm_client - - async def cleanup_servers(self) -> None: - """Clean up all servers properly.""" - for server in reversed(self.servers): - try: - await server.cleanup() - except Exception as e: - logging.warning(f"Warning during final cleanup: {e}") - - async def process_llm_response(self, llm_response: str) -> str: - """Process the LLM response and execute tools if needed. - - Args: - llm_response: The response from the LLM. - - Returns: - The result of tool execution or the original response. - """ - import json - - def _clean_json_string(json_string: str) -> str: - """Remove ```json ... ``` or ``` ... ``` wrappers if the LLM response is fenced.""" - import re - - pattern = r"^```(?:\s*json)?\s*(.*?)\s*```$" - return re.sub(pattern, r"\1", json_string, flags=re.DOTALL | re.IGNORECASE).strip() - - try: - tool_call = json.loads(_clean_json_string(llm_response)) - if "tool" in tool_call and "arguments" in tool_call: - logging.info(f"Executing tool: {tool_call['tool']}") - logging.info(f"With arguments: {tool_call['arguments']}") - - for server in self.servers: - tools = await server.list_tools() - if any(tool.name == tool_call["tool"] for tool in tools): - try: - result = await server.execute_tool(tool_call["tool"], tool_call["arguments"]) - - if isinstance(result, dict) and "progress" in result: - progress = result["progress"] # type: ignore - total = result["total"] # type: ignore - percentage = (progress / total) * 100 # type: ignore - logging.info(f"Progress: {progress}/{total} ({percentage:.1f}%)") - - return f"Tool execution result: {result}" - except Exception as e: - error_msg = f"Error executing tool: {str(e)}" - logging.error(error_msg) - return error_msg - - return f"No server found with tool: {tool_call['tool']}" - return llm_response - except json.JSONDecodeError: - return llm_response - - async def start(self) -> None: - """Main chat session handler.""" - try: - for server in self.servers: - try: - await server.initialize() - except Exception as e: - logging.error(f"Failed to initialize server: {e}") - await self.cleanup_servers() - return - - all_tools: list[Tool] = [] - for server in self.servers: - tools = await server.list_tools() - all_tools.extend(tools) - - tools_description = "\n".join([tool.format_for_llm() for tool in all_tools]) - - system_message = ( - "You are a helpful assistant with access to these tools:\n\n" - f"{tools_description}\n" - "Choose the appropriate tool based on the user's question. " - "If no tool is needed, reply directly.\n\n" - "IMPORTANT: When you need to use a tool, you must ONLY respond with " - "the exact JSON object format below, nothing else:\n" - "{\n" - ' "tool": "tool-name",\n' - ' "arguments": {\n' - ' "argument-name": "value"\n' - " }\n" - "}\n\n" - "After receiving a tool's response:\n" - "1. Transform the raw data into a natural, conversational response\n" - "2. Keep responses concise but informative\n" - "3. Focus on the most relevant information\n" - "4. Use appropriate context from the user's question\n" - "5. Avoid simply repeating the raw data\n\n" - "Please use only the tools that are explicitly defined above." - ) - - messages = [{"role": "system", "content": system_message}] - - while True: - try: - user_input = input("You: ").strip().lower() - if user_input in ["quit", "exit"]: - logging.info("\nExiting...") - break - - messages.append({"role": "user", "content": user_input}) - - llm_response = self.llm_client.get_response(messages) - logging.info("\nAssistant: %s", llm_response) - - result = await self.process_llm_response(llm_response) - - if result != llm_response: - messages.append({"role": "assistant", "content": llm_response}) - messages.append({"role": "system", "content": result}) - - final_response = self.llm_client.get_response(messages) - logging.info("\nFinal response: %s", final_response) - messages.append({"role": "assistant", "content": final_response}) - else: - messages.append({"role": "assistant", "content": llm_response}) - - except KeyboardInterrupt: - logging.info("\nExiting...") - break - - finally: - await self.cleanup_servers() - - -async def run() -> None: - """Initialize and run the chat session.""" - config = Configuration() - server_config = config.load_config("servers_config.json") - servers = [Server(name, srv_config) for name, srv_config in server_config["mcpServers"].items()] - llm_client = LLMClient(config.llm_api_key) - chat_session = ChatSession(servers, llm_client) - await chat_session.start() - - -def main() -> None: - asyncio.run(run()) - - -if __name__ == "__main__": - main() +from __future__ import annotations + +import asyncio +import json +import logging +import os +import shutil +from contextlib import AsyncExitStack +from typing import Any + +import httpx +from dotenv import load_dotenv +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + +# Configure logging +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") + + +class Configuration: + """Manages configuration and environment variables for the MCP client.""" + + def __init__(self) -> None: + """Initialize configuration with environment variables.""" + self.load_env() + self.api_key = os.getenv("LLM_API_KEY") + + @staticmethod + def load_env() -> None: + """Load environment variables from .env file.""" + load_dotenv() + + @staticmethod + def load_config(file_path: str) -> dict[str, Any]: + """Load server configuration from JSON file. + + Args: + file_path: Path to the JSON configuration file. + + Returns: + Dict containing server configuration. + + Raises: + FileNotFoundError: If configuration file doesn't exist. + JSONDecodeError: If configuration file is invalid JSON. + """ + with open(file_path, "r") as f: + return json.load(f) + + @property + def llm_api_key(self) -> str: + """Get the LLM API key. + + Returns: + The API key as a string. + + Raises: + ValueError: If the API key is not found in environment variables. + """ + if not self.api_key: + raise ValueError("LLM_API_KEY not found in environment variables") + return self.api_key + + +class Server: + """Manages MCP server connections and tool execution.""" + + def __init__(self, name: str, config: dict[str, Any]) -> None: + self.name: str = name + self.config: dict[str, Any] = config + self.stdio_context: Any | None = None + self.session: ClientSession | None = None + self._cleanup_lock: asyncio.Lock = asyncio.Lock() + self.exit_stack: AsyncExitStack = AsyncExitStack() + + async def initialize(self) -> None: + """Initialize the server connection.""" + command = shutil.which("npx") if self.config["command"] == "npx" else self.config["command"] + if command is None: + raise ValueError("The command must be a valid string and cannot be None.") + + server_params = StdioServerParameters( + command=command, + args=self.config["args"], + env={**os.environ, **self.config["env"]} if self.config.get("env") else None, + ) + try: + stdio_transport = await self.exit_stack.enter_async_context(stdio_client(server_params)) + read, write = stdio_transport + session = await self.exit_stack.enter_async_context(ClientSession(read, write)) + await session.initialize() + self.session = session + except Exception as e: + logging.error(f"Error initializing server {self.name}: {e}") + await self.cleanup() + raise + + async def list_tools(self) -> list[Tool]: + """List available tools from the server. + + Returns: + A list of available tools. + + Raises: + RuntimeError: If the server is not initialized. + """ + if not self.session: + raise RuntimeError(f"Server {self.name} not initialized") + + tools_response = await self.session.list_tools() + tools: list[Tool] = [] + + for item in tools_response: + if item[0] == "tools": + tools.extend(Tool(tool.name, tool.description, tool.input_schema, tool.title) for tool in item[1]) + + return tools + + async def execute_tool( + self, + tool_name: str, + arguments: dict[str, Any], + retries: int = 2, + delay: float = 1.0, + ) -> Any: + """Execute a tool with retry mechanism. + + Args: + tool_name: Name of the tool to execute. + arguments: Tool arguments. + retries: Number of retry attempts. + delay: Delay between retries in seconds. + + Returns: + Tool execution result. + + Raises: + RuntimeError: If server is not initialized. + Exception: If tool execution fails after all retries. + """ + if not self.session: + raise RuntimeError(f"Server {self.name} not initialized") + + attempt = 0 + while attempt < retries: + try: + logging.info(f"Executing {tool_name}...") + result = await self.session.call_tool(tool_name, arguments) + + return result + + except Exception as e: + attempt += 1 + logging.warning(f"Error executing tool: {e}. Attempt {attempt} of {retries}.") + if attempt < retries: + logging.info(f"Retrying in {delay} seconds...") + await asyncio.sleep(delay) + else: + logging.error("Max retries reached. Failing.") + raise + + async def cleanup(self) -> None: + """Clean up server resources.""" + async with self._cleanup_lock: + try: + await self.exit_stack.aclose() + self.session = None + self.stdio_context = None + except Exception as e: + logging.error(f"Error during cleanup of server {self.name}: {e}") + + +class Tool: + """Represents a tool with its properties and formatting.""" + + def __init__( + self, + name: str, + description: str, + input_schema: dict[str, Any], + title: str | None = None, + ) -> None: + self.name: str = name + self.title: str | None = title + self.description: str = description + self.input_schema: dict[str, Any] = input_schema + + def format_for_llm(self) -> str: + """Format tool information for LLM. + + Returns: + A formatted string describing the tool. + """ + args_desc: list[str] = [] + if "properties" in self.input_schema: + for param_name, param_info in self.input_schema["properties"].items(): + arg_desc = f"- {param_name}: {param_info.get('description', 'No description')}" + if param_name in self.input_schema.get("required", []): + arg_desc += " (required)" + args_desc.append(arg_desc) + + # Build the formatted output with title as a separate field + output = f"Tool: {self.name}\n" + + # Add human-readable title if available + if self.title: + output += f"User-readable title: {self.title}\n" + + output += f"""Description: {self.description} +Arguments: +{chr(10).join(args_desc)} +""" + + return output + + +class LLMClient: + """Manages communication with the LLM provider.""" + + def __init__(self, api_key: str) -> None: + self.api_key: str = api_key + + def get_response(self, messages: list[dict[str, str]]) -> str: + """Get a response from the LLM. + + Args: + messages: A list of message dictionaries. + + Returns: + The LLM's response as a string. + + Raises: + httpx.RequestError: If the request to the LLM fails. + """ + url = "https://api.groq.com/openai/v1/chat/completions" + + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}", + } + payload = { + "messages": messages, + "model": "meta-llama/llama-4-scout-17b-16e-instruct", + "temperature": 0.7, + "max_tokens": 4096, + "top_p": 1, + "stream": False, + "stop": None, + } + + try: + with httpx.Client() as client: + response = client.post(url, headers=headers, json=payload) + response.raise_for_status() + data = response.json() + return data["choices"][0]["message"]["content"] + + except httpx.RequestError as e: + error_message = f"Error getting LLM response: {str(e)}" + logging.error(error_message) + + if isinstance(e, httpx.HTTPStatusError): + status_code = e.response.status_code + logging.error(f"Status code: {status_code}") + logging.error(f"Response details: {e.response.text}") + + return f"I encountered an error: {error_message}. Please try again or rephrase your request." + + +class ChatSession: + """Orchestrates the interaction between user, LLM, and tools.""" + + def __init__(self, servers: list[Server], llm_client: LLMClient) -> None: + self.servers: list[Server] = servers + self.llm_client: LLMClient = llm_client + + async def cleanup_servers(self) -> None: + """Clean up all servers properly.""" + for server in reversed(self.servers): + try: + await server.cleanup() + except Exception as e: + logging.warning(f"Warning during final cleanup: {e}") + + async def process_llm_response(self, llm_response: str) -> str: + """Process the LLM response and execute tools if needed. + + Args: + llm_response: The response from the LLM. + + Returns: + The result of tool execution or the original response. + """ + import json + + def _clean_json_string(json_string: str) -> str: + """Remove ```json ... ``` or ``` ... ``` wrappers if the LLM response is fenced.""" + import re + + pattern = r"^```(?:\s*json)?\s*(.*?)\s*```$" + return re.sub(pattern, r"\1", json_string, flags=re.DOTALL | re.IGNORECASE).strip() + + try: + tool_call = json.loads(_clean_json_string(llm_response)) + if "tool" in tool_call and "arguments" in tool_call: + logging.info(f"Executing tool: {tool_call['tool']}") + logging.info(f"With arguments: {tool_call['arguments']}") + + for server in self.servers: + tools = await server.list_tools() + if any(tool.name == tool_call["tool"] for tool in tools): + try: + result = await server.execute_tool(tool_call["tool"], tool_call["arguments"]) + + if isinstance(result, dict) and "progress" in result: + progress = result["progress"] # type: ignore + total = result["total"] # type: ignore + percentage = (progress / total) * 100 # type: ignore + logging.info(f"Progress: {progress}/{total} ({percentage:.1f}%)") + + return f"Tool execution result: {result}" + except Exception as e: + error_msg = f"Error executing tool: {str(e)}" + logging.error(error_msg) + return error_msg + + return f"No server found with tool: {tool_call['tool']}" + return llm_response + except json.JSONDecodeError: + return llm_response + + async def start(self) -> None: + """Main chat session handler.""" + try: + for server in self.servers: + try: + await server.initialize() + except Exception as e: + logging.error(f"Failed to initialize server: {e}") + await self.cleanup_servers() + return + + all_tools: list[Tool] = [] + for server in self.servers: + tools = await server.list_tools() + all_tools.extend(tools) + + tools_description = "\n".join([tool.format_for_llm() for tool in all_tools]) + + system_message = ( + "You are a helpful assistant with access to these tools:\n\n" + f"{tools_description}\n" + "Choose the appropriate tool based on the user's question. " + "If no tool is needed, reply directly.\n\n" + "IMPORTANT: When you need to use a tool, you must ONLY respond with " + "the exact JSON object format below, nothing else:\n" + "{\n" + ' "tool": "tool-name",\n' + ' "arguments": {\n' + ' "argument-name": "value"\n' + " }\n" + "}\n\n" + "After receiving a tool's response:\n" + "1. Transform the raw data into a natural, conversational response\n" + "2. Keep responses concise but informative\n" + "3. Focus on the most relevant information\n" + "4. Use appropriate context from the user's question\n" + "5. Avoid simply repeating the raw data\n\n" + "Please use only the tools that are explicitly defined above." + ) + + messages = [{"role": "system", "content": system_message}] + + while True: + try: + user_input = input("You: ").strip().lower() + if user_input in ["quit", "exit"]: + logging.info("\nExiting...") + break + + messages.append({"role": "user", "content": user_input}) + + llm_response = self.llm_client.get_response(messages) + logging.info("\nAssistant: %s", llm_response) + + result = await self.process_llm_response(llm_response) + + if result != llm_response: + messages.append({"role": "assistant", "content": llm_response}) + messages.append({"role": "system", "content": result}) + + final_response = self.llm_client.get_response(messages) + logging.info("\nFinal response: %s", final_response) + messages.append({"role": "assistant", "content": final_response}) + else: + messages.append({"role": "assistant", "content": llm_response}) + + except KeyboardInterrupt: + logging.info("\nExiting...") + break + + finally: + await self.cleanup_servers() + + +async def run() -> None: + """Initialize and run the chat session.""" + config = Configuration() + server_config = config.load_config("servers_config.json") + servers = [Server(name, srv_config) for name, srv_config in server_config["mcpServers"].items()] + llm_client = LLMClient(config.llm_api_key) + chat_session = ChatSession(servers, llm_client) + await chat_session.start() + + +def main() -> None: + asyncio.run(run()) + + +if __name__ == "__main__": + main() diff --git a/examples/clients/simple-chatbot/mcp_simple_chatbot/requirements.txt b/examples/clients/simple-chatbot/mcp_simple_chatbot/requirements.txt index 2292072ff..54d875f66 100644 --- a/examples/clients/simple-chatbot/mcp_simple_chatbot/requirements.txt +++ b/examples/clients/simple-chatbot/mcp_simple_chatbot/requirements.txt @@ -1,4 +1,4 @@ -python-dotenv>=1.0.0 -requests>=2.31.0 -mcp>=1.0.0 -uvicorn>=0.32.1 +python-dotenv>=1.0.0 +requests>=2.31.0 +mcp>=1.0.0 +uvicorn>=0.32.1 diff --git a/examples/clients/simple-chatbot/mcp_simple_chatbot/servers_config.json b/examples/clients/simple-chatbot/mcp_simple_chatbot/servers_config.json index 3a92d05d1..58cc85204 100644 --- a/examples/clients/simple-chatbot/mcp_simple_chatbot/servers_config.json +++ b/examples/clients/simple-chatbot/mcp_simple_chatbot/servers_config.json @@ -1,12 +1,12 @@ -{ - "mcpServers": { - "sqlite": { - "command": "uvx", - "args": ["mcp-server-sqlite", "--db-path", "./test.db"] - }, - "puppeteer": { - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-puppeteer"] - } - } -} +{ + "mcpServers": { + "sqlite": { + "command": "uvx", + "args": ["mcp-server-sqlite", "--db-path", "./test.db"] + }, + "puppeteer": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-puppeteer"] + } + } +} diff --git a/examples/clients/simple-chatbot/pyproject.toml b/examples/clients/simple-chatbot/pyproject.toml index 2d7205735..3f4c9221b 100644 --- a/examples/clients/simple-chatbot/pyproject.toml +++ b/examples/clients/simple-chatbot/pyproject.toml @@ -1,47 +1,47 @@ -[project] -name = "mcp-simple-chatbot" -version = "0.1.0" -description = "A simple CLI chatbot using the Model Context Protocol (MCP)" -readme = "README.md" -requires-python = ">=3.10" -authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] -keywords = ["mcp", "llm", "chatbot", "cli"] -license = { text = "MIT" } -classifiers = [ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", -] -dependencies = [ - "python-dotenv>=1.0.0", - "mcp", - "uvicorn>=0.32.1", -] - -[project.scripts] -mcp-simple-chatbot = "mcp_simple_chatbot.main:main" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["mcp_simple_chatbot"] - -[tool.pyright] -include = ["mcp_simple_chatbot"] -venvPath = "." -venv = ".venv" - -[tool.ruff.lint] -select = ["E", "F", "I"] -ignore = [] - -[tool.ruff] -line-length = 120 -target-version = "py310" - -[dependency-groups] -dev = ["pyright>=1.1.379", "pytest>=8.3.3", "ruff>=0.6.9"] +[project] +name = "mcp-simple-chatbot" +version = "0.1.0" +description = "A simple CLI chatbot using the Model Context Protocol (MCP)" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +keywords = ["mcp", "llm", "chatbot", "cli"] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", +] +dependencies = [ + "python-dotenv>=1.0.0", + "mcp", + "uvicorn>=0.32.1", +] + +[project.scripts] +mcp-simple-chatbot = "mcp_simple_chatbot.main:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_chatbot"] + +[tool.pyright] +include = ["mcp_simple_chatbot"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.379", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/clients/simple-task-client/README.md b/examples/clients/simple-task-client/README.md index 103be0f1f..d4fb71354 100644 --- a/examples/clients/simple-task-client/README.md +++ b/examples/clients/simple-task-client/README.md @@ -1,43 +1,43 @@ -# Simple Task Client - -A minimal MCP client demonstrating polling for task results over streamable HTTP. - -## Running - -First, start the simple-task server in another terminal: - -```bash -cd examples/servers/simple-task -uv run mcp-simple-task -``` - -Then run the client: - -```bash -cd examples/clients/simple-task-client -uv run mcp-simple-task-client -``` - -Use `--url` to connect to a different server. - -## What it does - -1. Connects to the server via streamable HTTP -2. Calls the `long_running_task` tool as a task -3. Polls the task status until completion -4. Retrieves and prints the result - -## Expected output - -```text -Available tools: ['long_running_task'] - -Calling tool as a task... -Task created: <task-id> - Status: working - Starting work... - Status: working - Processing step 1... - Status: working - Processing step 2... - Status: completed - - -Result: Task completed! -``` +# Simple Task Client + +A minimal MCP client demonstrating polling for task results over streamable HTTP. + +## Running + +First, start the simple-task server in another terminal: + +```bash +cd examples/servers/simple-task +uv run mcp-simple-task +``` + +Then run the client: + +```bash +cd examples/clients/simple-task-client +uv run mcp-simple-task-client +``` + +Use `--url` to connect to a different server. + +## What it does + +1. Connects to the server via streamable HTTP +2. Calls the `long_running_task` tool as a task +3. Polls the task status until completion +4. Retrieves and prints the result + +## Expected output + +```text +Available tools: ['long_running_task'] + +Calling tool as a task... +Task created: <task-id> + Status: working - Starting work... + Status: working - Processing step 1... + Status: working - Processing step 2... + Status: completed - + +Result: Task completed! +``` diff --git a/examples/clients/simple-task-client/mcp_simple_task_client/__main__.py b/examples/clients/simple-task-client/mcp_simple_task_client/__main__.py index 2fc2cda8d..1b328f0fb 100644 --- a/examples/clients/simple-task-client/mcp_simple_task_client/__main__.py +++ b/examples/clients/simple-task-client/mcp_simple_task_client/__main__.py @@ -1,5 +1,5 @@ -import sys - -from .main import main - -sys.exit(main()) # type: ignore[call-arg] +import sys + +from .main import main + +sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/clients/simple-task-client/mcp_simple_task_client/main.py b/examples/clients/simple-task-client/mcp_simple_task_client/main.py index f9e555c8e..1a53922a9 100644 --- a/examples/clients/simple-task-client/mcp_simple_task_client/main.py +++ b/examples/clients/simple-task-client/mcp_simple_task_client/main.py @@ -1,56 +1,56 @@ -"""Simple task client demonstrating MCP tasks polling over streamable HTTP.""" - -import asyncio - -import click -from mcp import ClientSession -from mcp.client.streamable_http import streamable_http_client -from mcp.types import CallToolResult, TextContent - - -async def run(url: str) -> None: - async with streamable_http_client(url) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - - # List tools - tools = await session.list_tools() - print(f"Available tools: {[t.name for t in tools.tools]}") - - # Call the tool as a task - print("\nCalling tool as a task...") - - result = await session.experimental.call_tool_as_task( - "long_running_task", - arguments={}, - ttl=60000, - ) - task_id = result.task.task_id - print(f"Task created: {task_id}") - - status = None - # Poll until done (respects server's pollInterval hint) - async for status in session.experimental.poll_task(task_id): - print(f" Status: {status.status} - {status.status_message or ''}") - - # Check final status - if status and status.status != "completed": - print(f"Task ended with status: {status.status}") - return - - # Get the result - task_result = await session.experimental.get_task_result(task_id, CallToolResult) - content = task_result.content[0] - if isinstance(content, TextContent): - print(f"\nResult: {content.text}") - - -@click.command() -@click.option("--url", default="http://localhost:8000/mcp", help="Server URL") -def main(url: str) -> int: - asyncio.run(run(url)) - return 0 - - -if __name__ == "__main__": - main() +"""Simple task client demonstrating MCP tasks polling over streamable HTTP.""" + +import asyncio + +import click +from mcp import ClientSession +from mcp.client.streamable_http import streamable_http_client +from mcp.types import CallToolResult, TextContent + + +async def run(url: str) -> None: + async with streamable_http_client(url) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + + # List tools + tools = await session.list_tools() + print(f"Available tools: {[t.name for t in tools.tools]}") + + # Call the tool as a task + print("\nCalling tool as a task...") + + result = await session.experimental.call_tool_as_task( + "long_running_task", + arguments={}, + ttl=60000, + ) + task_id = result.task.task_id + print(f"Task created: {task_id}") + + status = None + # Poll until done (respects server's pollInterval hint) + async for status in session.experimental.poll_task(task_id): + print(f" Status: {status.status} - {status.status_message or ''}") + + # Check final status + if status and status.status != "completed": + print(f"Task ended with status: {status.status}") + return + + # Get the result + task_result = await session.experimental.get_task_result(task_id, CallToolResult) + content = task_result.content[0] + if isinstance(content, TextContent): + print(f"\nResult: {content.text}") + + +@click.command() +@click.option("--url", default="http://localhost:8000/mcp", help="Server URL") +def main(url: str) -> int: + asyncio.run(run(url)) + return 0 + + +if __name__ == "__main__": + main() diff --git a/examples/clients/simple-task-client/pyproject.toml b/examples/clients/simple-task-client/pyproject.toml index c7abf5115..a45ed6924 100644 --- a/examples/clients/simple-task-client/pyproject.toml +++ b/examples/clients/simple-task-client/pyproject.toml @@ -1,43 +1,43 @@ -[project] -name = "mcp-simple-task-client" -version = "0.1.0" -description = "A simple MCP client demonstrating task polling" -readme = "README.md" -requires-python = ">=3.10" -authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] -keywords = ["mcp", "llm", "tasks", "client"] -license = { text = "MIT" } -classifiers = [ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", -] -dependencies = ["click>=8.0", "mcp"] - -[project.scripts] -mcp-simple-task-client = "mcp_simple_task_client.main:main" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["mcp_simple_task_client"] - -[tool.pyright] -include = ["mcp_simple_task_client"] -venvPath = "." -venv = ".venv" - -[tool.ruff.lint] -select = ["E", "F", "I"] -ignore = [] - -[tool.ruff] -line-length = 120 -target-version = "py310" - -[dependency-groups] -dev = ["pyright>=1.1.378", "ruff>=0.6.9"] +[project] +name = "mcp-simple-task-client" +version = "0.1.0" +description = "A simple MCP client demonstrating task polling" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +keywords = ["mcp", "llm", "tasks", "client"] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", +] +dependencies = ["click>=8.0", "mcp"] + +[project.scripts] +mcp-simple-task-client = "mcp_simple_task_client.main:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_task_client"] + +[tool.pyright] +include = ["mcp_simple_task_client"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "ruff>=0.6.9"] diff --git a/examples/clients/simple-task-interactive-client/README.md b/examples/clients/simple-task-interactive-client/README.md index 3397d3b5d..19aaf0085 100644 --- a/examples/clients/simple-task-interactive-client/README.md +++ b/examples/clients/simple-task-interactive-client/README.md @@ -1,87 +1,87 @@ -# Simple Interactive Task Client - -A minimal MCP client demonstrating responses to interactive tasks (elicitation and sampling). - -## Running - -First, start the interactive task server in another terminal: - -```bash -cd examples/servers/simple-task-interactive -uv run mcp-simple-task-interactive -``` - -Then run the client: - -```bash -cd examples/clients/simple-task-interactive-client -uv run mcp-simple-task-interactive-client -``` - -Use `--url` to connect to a different server. - -## What it does - -1. Connects to the server via streamable HTTP -2. Calls `confirm_delete` - server asks for confirmation, client responds via terminal -3. Calls `write_haiku` - server requests LLM completion, client returns a hardcoded haiku - -## Key concepts - -### Elicitation callback - -```python -async def elicitation_callback(context, params) -> ElicitResult: - # Handle user input request from server - return ElicitResult(action="accept", content={"confirm": True}) -``` - -### Sampling callback - -```python -async def sampling_callback(context, params) -> CreateMessageResult: - # Handle LLM completion request from server - return CreateMessageResult(model="...", role="assistant", content=...) -``` - -### Using call_tool_as_task - -```python -# Call a tool as a task (returns immediately with task reference) -result = await session.experimental.call_tool_as_task("tool_name", {"arg": "value"}) -task_id = result.task.task_id - -# Get result - this delivers elicitation/sampling requests and blocks until complete -final = await session.experimental.get_task_result(task_id, CallToolResult) -``` - -**Important**: The `get_task_result()` call is what triggers the delivery of elicitation -and sampling requests to your callbacks. It blocks until the task completes and returns -the final result. - -## Expected output - -```text -Available tools: ['confirm_delete', 'write_haiku'] - ---- Demo 1: Elicitation --- -Calling confirm_delete tool... -Task created: <task-id> - -[Elicitation] Server asks: Are you sure you want to delete 'important.txt'? -Your response (y/n): y -[Elicitation] Responding with: confirm=True -Result: Deleted 'important.txt' - ---- Demo 2: Sampling --- -Calling write_haiku tool... -Task created: <task-id> - -[Sampling] Server requests LLM completion for: Write a haiku about autumn leaves -[Sampling] Responding with haiku -Result: -Haiku: -Cherry blossoms fall -Softly on the quiet pond -Spring whispers goodbye -``` +# Simple Interactive Task Client + +A minimal MCP client demonstrating responses to interactive tasks (elicitation and sampling). + +## Running + +First, start the interactive task server in another terminal: + +```bash +cd examples/servers/simple-task-interactive +uv run mcp-simple-task-interactive +``` + +Then run the client: + +```bash +cd examples/clients/simple-task-interactive-client +uv run mcp-simple-task-interactive-client +``` + +Use `--url` to connect to a different server. + +## What it does + +1. Connects to the server via streamable HTTP +2. Calls `confirm_delete` - server asks for confirmation, client responds via terminal +3. Calls `write_haiku` - server requests LLM completion, client returns a hardcoded haiku + +## Key concepts + +### Elicitation callback + +```python +async def elicitation_callback(context, params) -> ElicitResult: + # Handle user input request from server + return ElicitResult(action="accept", content={"confirm": True}) +``` + +### Sampling callback + +```python +async def sampling_callback(context, params) -> CreateMessageResult: + # Handle LLM completion request from server + return CreateMessageResult(model="...", role="assistant", content=...) +``` + +### Using call_tool_as_task + +```python +# Call a tool as a task (returns immediately with task reference) +result = await session.experimental.call_tool_as_task("tool_name", {"arg": "value"}) +task_id = result.task.task_id + +# Get result - this delivers elicitation/sampling requests and blocks until complete +final = await session.experimental.get_task_result(task_id, CallToolResult) +``` + +**Important**: The `get_task_result()` call is what triggers the delivery of elicitation +and sampling requests to your callbacks. It blocks until the task completes and returns +the final result. + +## Expected output + +```text +Available tools: ['confirm_delete', 'write_haiku'] + +--- Demo 1: Elicitation --- +Calling confirm_delete tool... +Task created: <task-id> + +[Elicitation] Server asks: Are you sure you want to delete 'important.txt'? +Your response (y/n): y +[Elicitation] Responding with: confirm=True +Result: Deleted 'important.txt' + +--- Demo 2: Sampling --- +Calling write_haiku tool... +Task created: <task-id> + +[Sampling] Server requests LLM completion for: Write a haiku about autumn leaves +[Sampling] Responding with haiku +Result: +Haiku: +Cherry blossoms fall +Softly on the quiet pond +Spring whispers goodbye +``` diff --git a/examples/clients/simple-task-interactive-client/mcp_simple_task_interactive_client/__main__.py b/examples/clients/simple-task-interactive-client/mcp_simple_task_interactive_client/__main__.py index 2fc2cda8d..1b328f0fb 100644 --- a/examples/clients/simple-task-interactive-client/mcp_simple_task_interactive_client/__main__.py +++ b/examples/clients/simple-task-interactive-client/mcp_simple_task_interactive_client/__main__.py @@ -1,5 +1,5 @@ -import sys - -from .main import main - -sys.exit(main()) # type: ignore[call-arg] +import sys + +from .main import main + +sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/clients/simple-task-interactive-client/mcp_simple_task_interactive_client/main.py b/examples/clients/simple-task-interactive-client/mcp_simple_task_interactive_client/main.py index ff5f49928..9c093cfd0 100644 --- a/examples/clients/simple-task-interactive-client/mcp_simple_task_interactive_client/main.py +++ b/examples/clients/simple-task-interactive-client/mcp_simple_task_interactive_client/main.py @@ -1,137 +1,137 @@ -"""Simple interactive task client demonstrating elicitation and sampling responses. - -This example demonstrates the spec-compliant polling pattern: -1. Poll tasks/get watching for status changes -2. On input_required, call tasks/result to receive elicitation/sampling requests -3. Continue until terminal status, then retrieve final result -""" - -import asyncio - -import click -from mcp import ClientSession -from mcp.client.context import ClientRequestContext -from mcp.client.streamable_http import streamable_http_client -from mcp.types import ( - CallToolResult, - CreateMessageRequestParams, - CreateMessageResult, - ElicitRequestParams, - ElicitResult, - TextContent, -) - - -async def elicitation_callback( - context: ClientRequestContext, - params: ElicitRequestParams, -) -> ElicitResult: - """Handle elicitation requests from the server.""" - print(f"\n[Elicitation] Server asks: {params.message}") - - # Simple terminal prompt - response = input("Your response (y/n): ").strip().lower() - confirmed = response in ("y", "yes", "true", "1") - - print(f"[Elicitation] Responding with: confirm={confirmed}") - return ElicitResult(action="accept", content={"confirm": confirmed}) - - -async def sampling_callback( - context: ClientRequestContext, - params: CreateMessageRequestParams, -) -> CreateMessageResult: - """Handle sampling requests from the server.""" - # Get the prompt from the first message - prompt = "unknown" - if params.messages: - content = params.messages[0].content - if isinstance(content, TextContent): - prompt = content.text - - print(f"\n[Sampling] Server requests LLM completion for: {prompt}") - - # Return a hardcoded haiku (in real use, call your LLM here) - haiku = """Cherry blossoms fall -Softly on the quiet pond -Spring whispers goodbye""" - - print("[Sampling] Responding with haiku") - return CreateMessageResult( - model="mock-haiku-model", - role="assistant", - content=TextContent(type="text", text=haiku), - ) - - -def get_text(result: CallToolResult) -> str: - """Extract text from a CallToolResult.""" - if result.content and isinstance(result.content[0], TextContent): - return result.content[0].text - return "(no text)" - - -async def run(url: str) -> None: - async with streamable_http_client(url) as (read, write): - async with ClientSession( - read, - write, - elicitation_callback=elicitation_callback, - sampling_callback=sampling_callback, - ) as session: - await session.initialize() - - # List tools - tools = await session.list_tools() - print(f"Available tools: {[t.name for t in tools.tools]}") - - # Demo 1: Elicitation (confirm_delete) - print("\n--- Demo 1: Elicitation ---") - print("Calling confirm_delete tool...") - - elicit_task = await session.experimental.call_tool_as_task("confirm_delete", {"filename": "important.txt"}) - elicit_task_id = elicit_task.task.task_id - print(f"Task created: {elicit_task_id}") - - # Poll until terminal, calling tasks/result on input_required - async for status in session.experimental.poll_task(elicit_task_id): - print(f"[Poll] Status: {status.status}") - if status.status == "input_required": - # Server needs input - tasks/result delivers the elicitation request - elicit_result = await session.experimental.get_task_result(elicit_task_id, CallToolResult) - break - else: - # poll_task exited due to terminal status - elicit_result = await session.experimental.get_task_result(elicit_task_id, CallToolResult) - - print(f"Result: {get_text(elicit_result)}") - - # Demo 2: Sampling (write_haiku) - print("\n--- Demo 2: Sampling ---") - print("Calling write_haiku tool...") - - sampling_task = await session.experimental.call_tool_as_task("write_haiku", {"topic": "autumn leaves"}) - sampling_task_id = sampling_task.task.task_id - print(f"Task created: {sampling_task_id}") - - # Poll until terminal, calling tasks/result on input_required - async for status in session.experimental.poll_task(sampling_task_id): - print(f"[Poll] Status: {status.status}") - if status.status == "input_required": - sampling_result = await session.experimental.get_task_result(sampling_task_id, CallToolResult) - break - else: - sampling_result = await session.experimental.get_task_result(sampling_task_id, CallToolResult) - - print(f"Result:\n{get_text(sampling_result)}") - - -@click.command() -@click.option("--url", default="http://localhost:8000/mcp", help="Server URL") -def main(url: str) -> int: - asyncio.run(run(url)) - return 0 - - -if __name__ == "__main__": - main() +"""Simple interactive task client demonstrating elicitation and sampling responses. + +This example demonstrates the spec-compliant polling pattern: +1. Poll tasks/get watching for status changes +2. On input_required, call tasks/result to receive elicitation/sampling requests +3. Continue until terminal status, then retrieve final result +""" + +import asyncio + +import click +from mcp import ClientSession +from mcp.client.context import ClientRequestContext +from mcp.client.streamable_http import streamable_http_client +from mcp.types import ( + CallToolResult, + CreateMessageRequestParams, + CreateMessageResult, + ElicitRequestParams, + ElicitResult, + TextContent, +) + + +async def elicitation_callback( + context: ClientRequestContext, + params: ElicitRequestParams, +) -> ElicitResult: + """Handle elicitation requests from the server.""" + print(f"\n[Elicitation] Server asks: {params.message}") + + # Simple terminal prompt + response = input("Your response (y/n): ").strip().lower() + confirmed = response in ("y", "yes", "true", "1") + + print(f"[Elicitation] Responding with: confirm={confirmed}") + return ElicitResult(action="accept", content={"confirm": confirmed}) + + +async def sampling_callback( + context: ClientRequestContext, + params: CreateMessageRequestParams, +) -> CreateMessageResult: + """Handle sampling requests from the server.""" + # Get the prompt from the first message + prompt = "unknown" + if params.messages: + content = params.messages[0].content + if isinstance(content, TextContent): + prompt = content.text + + print(f"\n[Sampling] Server requests LLM completion for: {prompt}") + + # Return a hardcoded haiku (in real use, call your LLM here) + haiku = """Cherry blossoms fall +Softly on the quiet pond +Spring whispers goodbye""" + + print("[Sampling] Responding with haiku") + return CreateMessageResult( + model="mock-haiku-model", + role="assistant", + content=TextContent(type="text", text=haiku), + ) + + +def get_text(result: CallToolResult) -> str: + """Extract text from a CallToolResult.""" + if result.content and isinstance(result.content[0], TextContent): + return result.content[0].text + return "(no text)" + + +async def run(url: str) -> None: + async with streamable_http_client(url) as (read, write): + async with ClientSession( + read, + write, + elicitation_callback=elicitation_callback, + sampling_callback=sampling_callback, + ) as session: + await session.initialize() + + # List tools + tools = await session.list_tools() + print(f"Available tools: {[t.name for t in tools.tools]}") + + # Demo 1: Elicitation (confirm_delete) + print("\n--- Demo 1: Elicitation ---") + print("Calling confirm_delete tool...") + + elicit_task = await session.experimental.call_tool_as_task("confirm_delete", {"filename": "important.txt"}) + elicit_task_id = elicit_task.task.task_id + print(f"Task created: {elicit_task_id}") + + # Poll until terminal, calling tasks/result on input_required + async for status in session.experimental.poll_task(elicit_task_id): + print(f"[Poll] Status: {status.status}") + if status.status == "input_required": + # Server needs input - tasks/result delivers the elicitation request + elicit_result = await session.experimental.get_task_result(elicit_task_id, CallToolResult) + break + else: + # poll_task exited due to terminal status + elicit_result = await session.experimental.get_task_result(elicit_task_id, CallToolResult) + + print(f"Result: {get_text(elicit_result)}") + + # Demo 2: Sampling (write_haiku) + print("\n--- Demo 2: Sampling ---") + print("Calling write_haiku tool...") + + sampling_task = await session.experimental.call_tool_as_task("write_haiku", {"topic": "autumn leaves"}) + sampling_task_id = sampling_task.task.task_id + print(f"Task created: {sampling_task_id}") + + # Poll until terminal, calling tasks/result on input_required + async for status in session.experimental.poll_task(sampling_task_id): + print(f"[Poll] Status: {status.status}") + if status.status == "input_required": + sampling_result = await session.experimental.get_task_result(sampling_task_id, CallToolResult) + break + else: + sampling_result = await session.experimental.get_task_result(sampling_task_id, CallToolResult) + + print(f"Result:\n{get_text(sampling_result)}") + + +@click.command() +@click.option("--url", default="http://localhost:8000/mcp", help="Server URL") +def main(url: str) -> int: + asyncio.run(run(url)) + return 0 + + +if __name__ == "__main__": + main() diff --git a/examples/clients/simple-task-interactive-client/pyproject.toml b/examples/clients/simple-task-interactive-client/pyproject.toml index 47191573f..30d50ea53 100644 --- a/examples/clients/simple-task-interactive-client/pyproject.toml +++ b/examples/clients/simple-task-interactive-client/pyproject.toml @@ -1,43 +1,43 @@ -[project] -name = "mcp-simple-task-interactive-client" -version = "0.1.0" -description = "A simple MCP client demonstrating interactive task responses" -readme = "README.md" -requires-python = ">=3.10" -authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] -keywords = ["mcp", "llm", "tasks", "client", "elicitation", "sampling"] -license = { text = "MIT" } -classifiers = [ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", -] -dependencies = ["click>=8.0", "mcp"] - -[project.scripts] -mcp-simple-task-interactive-client = "mcp_simple_task_interactive_client.main:main" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["mcp_simple_task_interactive_client"] - -[tool.pyright] -include = ["mcp_simple_task_interactive_client"] -venvPath = "." -venv = ".venv" - -[tool.ruff.lint] -select = ["E", "F", "I"] -ignore = [] - -[tool.ruff] -line-length = 120 -target-version = "py310" - -[dependency-groups] -dev = ["pyright>=1.1.378", "ruff>=0.6.9"] +[project] +name = "mcp-simple-task-interactive-client" +version = "0.1.0" +description = "A simple MCP client demonstrating interactive task responses" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +keywords = ["mcp", "llm", "tasks", "client", "elicitation", "sampling"] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", +] +dependencies = ["click>=8.0", "mcp"] + +[project.scripts] +mcp-simple-task-interactive-client = "mcp_simple_task_interactive_client.main:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_task_interactive_client"] + +[tool.pyright] +include = ["mcp_simple_task_interactive_client"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "ruff>=0.6.9"] diff --git a/examples/clients/sse-polling-client/README.md b/examples/clients/sse-polling-client/README.md index 78449aa83..631d61909 100644 --- a/examples/clients/sse-polling-client/README.md +++ b/examples/clients/sse-polling-client/README.md @@ -1,30 +1,30 @@ -# MCP SSE Polling Demo Client - -Demonstrates client-side auto-reconnect for the SSE polling pattern (SEP-1699). - -## Features - -- Connects to SSE polling demo server -- Automatically reconnects when server closes SSE stream -- Resumes from Last-Event-ID to avoid missing messages -- Respects server-provided retry interval - -## Usage - -```bash -# First start the server: -uv run mcp-sse-polling-demo --port 3000 - -# Then run this client: -uv run mcp-sse-polling-client --url http://localhost:3000/mcp - -# Custom options: -uv run mcp-sse-polling-client --url http://localhost:3000/mcp --items 20 --checkpoint-every 5 -``` - -## Options - -- `--url`: Server URL (default: <http://localhost:3000/mcp>) -- `--items`: Number of items to process (default: 10) -- `--checkpoint-every`: Checkpoint interval (default: 3) -- `--log-level`: Logging level (default: DEBUG) +# MCP SSE Polling Demo Client + +Demonstrates client-side auto-reconnect for the SSE polling pattern (SEP-1699). + +## Features + +- Connects to SSE polling demo server +- Automatically reconnects when server closes SSE stream +- Resumes from Last-Event-ID to avoid missing messages +- Respects server-provided retry interval + +## Usage + +```bash +# First start the server: +uv run mcp-sse-polling-demo --port 3000 + +# Then run this client: +uv run mcp-sse-polling-client --url http://localhost:3000/mcp + +# Custom options: +uv run mcp-sse-polling-client --url http://localhost:3000/mcp --items 20 --checkpoint-every 5 +``` + +## Options + +- `--url`: Server URL (default: <http://localhost:3000/mcp>) +- `--items`: Number of items to process (default: 10) +- `--checkpoint-every`: Checkpoint interval (default: 3) +- `--log-level`: Logging level (default: DEBUG) diff --git a/examples/clients/sse-polling-client/mcp_sse_polling_client/__init__.py b/examples/clients/sse-polling-client/mcp_sse_polling_client/__init__.py index ee69b32c9..ba56547c6 100644 --- a/examples/clients/sse-polling-client/mcp_sse_polling_client/__init__.py +++ b/examples/clients/sse-polling-client/mcp_sse_polling_client/__init__.py @@ -1 +1 @@ -"""SSE Polling Demo Client - demonstrates auto-reconnect for long-running tasks.""" +"""SSE Polling Demo Client - demonstrates auto-reconnect for long-running tasks.""" diff --git a/examples/clients/sse-polling-client/mcp_sse_polling_client/main.py b/examples/clients/sse-polling-client/mcp_sse_polling_client/main.py index e91ed9d52..d35110e37 100644 --- a/examples/clients/sse-polling-client/mcp_sse_polling_client/main.py +++ b/examples/clients/sse-polling-client/mcp_sse_polling_client/main.py @@ -1,102 +1,102 @@ -"""SSE Polling Demo Client - -Demonstrates the client-side auto-reconnect for SSE polling pattern. - -This client connects to the SSE Polling Demo server and calls process_batch, -which triggers periodic server-side stream closes. The client automatically -reconnects using Last-Event-ID and resumes receiving messages. - -Run with: - # First start the server: - uv run mcp-sse-polling-demo --port 3000 - - # Then run this client: - uv run mcp-sse-polling-client --url http://localhost:3000/mcp -""" - -import asyncio -import logging - -import click -from mcp import ClientSession -from mcp.client.streamable_http import streamable_http_client - - -async def run_demo(url: str, items: int, checkpoint_every: int) -> None: - """Run the SSE polling demo.""" - print(f"\n{'=' * 60}") - print("SSE Polling Demo Client") - print(f"{'=' * 60}") - print(f"Server URL: {url}") - print(f"Processing {items} items with checkpoints every {checkpoint_every}") - print(f"{'=' * 60}\n") - - async with streamable_http_client(url) as (read_stream, write_stream): - async with ClientSession(read_stream, write_stream) as session: - # Initialize the connection - print("Initializing connection...") - await session.initialize() - print("Connected!\n") - - # List available tools - tools = await session.list_tools() - print(f"Available tools: {[t.name for t in tools.tools]}\n") - - # Call the process_batch tool - print(f"Calling process_batch(items={items}, checkpoint_every={checkpoint_every})...\n") - print("-" * 40) - - result = await session.call_tool( - "process_batch", - { - "items": items, - "checkpoint_every": checkpoint_every, - }, - ) - - print("-" * 40) - if result.content: - content = result.content[0] - text = getattr(content, "text", str(content)) - print(f"\nResult: {text}") - else: - print("\nResult: No content") - print(f"{'=' * 60}\n") - - -@click.command() -@click.option( - "--url", - default="http://localhost:3000/mcp", - help="Server URL", -) -@click.option( - "--items", - default=10, - help="Number of items to process", -) -@click.option( - "--checkpoint-every", - default=3, - help="Checkpoint interval", -) -@click.option( - "--log-level", - default="INFO", - help="Logging level", -) -def main(url: str, items: int, checkpoint_every: int, log_level: str) -> None: - """Run the SSE Polling Demo client.""" - logging.basicConfig( - level=getattr(logging, log_level.upper()), - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - ) - # Suppress noisy HTTP client logging - logging.getLogger("httpx").setLevel(logging.WARNING) - logging.getLogger("httpcore").setLevel(logging.WARNING) - - asyncio.run(run_demo(url, items, checkpoint_every)) - - -if __name__ == "__main__": - main() +"""SSE Polling Demo Client + +Demonstrates the client-side auto-reconnect for SSE polling pattern. + +This client connects to the SSE Polling Demo server and calls process_batch, +which triggers periodic server-side stream closes. The client automatically +reconnects using Last-Event-ID and resumes receiving messages. + +Run with: + # First start the server: + uv run mcp-sse-polling-demo --port 3000 + + # Then run this client: + uv run mcp-sse-polling-client --url http://localhost:3000/mcp +""" + +import asyncio +import logging + +import click +from mcp import ClientSession +from mcp.client.streamable_http import streamable_http_client + + +async def run_demo(url: str, items: int, checkpoint_every: int) -> None: + """Run the SSE polling demo.""" + print(f"\n{'=' * 60}") + print("SSE Polling Demo Client") + print(f"{'=' * 60}") + print(f"Server URL: {url}") + print(f"Processing {items} items with checkpoints every {checkpoint_every}") + print(f"{'=' * 60}\n") + + async with streamable_http_client(url) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: + # Initialize the connection + print("Initializing connection...") + await session.initialize() + print("Connected!\n") + + # List available tools + tools = await session.list_tools() + print(f"Available tools: {[t.name for t in tools.tools]}\n") + + # Call the process_batch tool + print(f"Calling process_batch(items={items}, checkpoint_every={checkpoint_every})...\n") + print("-" * 40) + + result = await session.call_tool( + "process_batch", + { + "items": items, + "checkpoint_every": checkpoint_every, + }, + ) + + print("-" * 40) + if result.content: + content = result.content[0] + text = getattr(content, "text", str(content)) + print(f"\nResult: {text}") + else: + print("\nResult: No content") + print(f"{'=' * 60}\n") + + +@click.command() +@click.option( + "--url", + default="http://localhost:3000/mcp", + help="Server URL", +) +@click.option( + "--items", + default=10, + help="Number of items to process", +) +@click.option( + "--checkpoint-every", + default=3, + help="Checkpoint interval", +) +@click.option( + "--log-level", + default="INFO", + help="Logging level", +) +def main(url: str, items: int, checkpoint_every: int, log_level: str) -> None: + """Run the SSE Polling Demo client.""" + logging.basicConfig( + level=getattr(logging, log_level.upper()), + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + # Suppress noisy HTTP client logging + logging.getLogger("httpx").setLevel(logging.WARNING) + logging.getLogger("httpcore").setLevel(logging.WARNING) + + asyncio.run(run_demo(url, items, checkpoint_every)) + + +if __name__ == "__main__": + main() diff --git a/examples/clients/sse-polling-client/pyproject.toml b/examples/clients/sse-polling-client/pyproject.toml index 4db29857f..5200a8206 100644 --- a/examples/clients/sse-polling-client/pyproject.toml +++ b/examples/clients/sse-polling-client/pyproject.toml @@ -1,36 +1,36 @@ -[project] -name = "mcp-sse-polling-client" -version = "0.1.0" -description = "Demo client for SSE polling with auto-reconnect" -readme = "README.md" -requires-python = ">=3.10" -authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] -keywords = ["mcp", "sse", "polling", "client"] -license = { text = "MIT" } -dependencies = ["click>=8.2.0", "mcp"] - -[project.scripts] -mcp-sse-polling-client = "mcp_sse_polling_client.main:main" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["mcp_sse_polling_client"] - -[tool.pyright] -include = ["mcp_sse_polling_client"] -venvPath = "." -venv = ".venv" - -[tool.ruff.lint] -select = ["E", "F", "I"] -ignore = [] - -[tool.ruff] -line-length = 120 -target-version = "py310" - -[dependency-groups] -dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] +[project] +name = "mcp-sse-polling-client" +version = "0.1.0" +description = "Demo client for SSE polling with auto-reconnect" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +keywords = ["mcp", "sse", "polling", "client"] +license = { text = "MIT" } +dependencies = ["click>=8.2.0", "mcp"] + +[project.scripts] +mcp-sse-polling-client = "mcp_sse_polling_client.main:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_sse_polling_client"] + +[tool.pyright] +include = ["mcp_sse_polling_client"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/mcpserver/complex_inputs.py b/examples/mcpserver/complex_inputs.py index 93a42d1c8..fbeaa54ec 100644 --- a/examples/mcpserver/complex_inputs.py +++ b/examples/mcpserver/complex_inputs.py @@ -1,29 +1,29 @@ -"""MCPServer Complex inputs Example - -Demonstrates validation via pydantic with complex models. -""" - -from typing import Annotated - -from pydantic import BaseModel, Field - -from mcp.server.mcpserver import MCPServer - -mcp = MCPServer("Shrimp Tank") - - -class ShrimpTank(BaseModel): - class Shrimp(BaseModel): - name: Annotated[str, Field(max_length=10)] - - shrimp: list[Shrimp] - - -@mcp.tool() -def name_shrimp( - tank: ShrimpTank, - # You can use pydantic Field in function signatures for validation. - extra_names: Annotated[list[str], Field(max_length=10)], -) -> list[str]: - """List all shrimp names in the tank""" - return [shrimp.name for shrimp in tank.shrimp] + extra_names +"""MCPServer Complex inputs Example + +Demonstrates validation via pydantic with complex models. +""" + +from typing import Annotated + +from pydantic import BaseModel, Field + +from mcp.server.mcpserver import MCPServer + +mcp = MCPServer("Shrimp Tank") + + +class ShrimpTank(BaseModel): + class Shrimp(BaseModel): + name: Annotated[str, Field(max_length=10)] + + shrimp: list[Shrimp] + + +@mcp.tool() +def name_shrimp( + tank: ShrimpTank, + # You can use pydantic Field in function signatures for validation. + extra_names: Annotated[list[str], Field(max_length=10)], +) -> list[str]: + """List all shrimp names in the tank""" + return [shrimp.name for shrimp in tank.shrimp] + extra_names diff --git a/examples/mcpserver/desktop.py b/examples/mcpserver/desktop.py index 804184516..415b1629e 100644 --- a/examples/mcpserver/desktop.py +++ b/examples/mcpserver/desktop.py @@ -1,24 +1,24 @@ -"""MCPServer Desktop Example - -A simple example that exposes the desktop directory as a resource. -""" - -from pathlib import Path - -from mcp.server.mcpserver import MCPServer - -# Create server -mcp = MCPServer("Demo") - - -@mcp.resource("dir://desktop") -def desktop() -> list[str]: - """List the files in the user's desktop""" - desktop = Path.home() / "Desktop" - return [str(f) for f in desktop.iterdir()] - - -@mcp.tool() -def sum(a: int, b: int) -> int: - """Add two numbers""" - return a + b +"""MCPServer Desktop Example + +A simple example that exposes the desktop directory as a resource. +""" + +from pathlib import Path + +from mcp.server.mcpserver import MCPServer + +# Create server +mcp = MCPServer("Demo") + + +@mcp.resource("dir://desktop") +def desktop() -> list[str]: + """List the files in the user's desktop""" + desktop = Path.home() / "Desktop" + return [str(f) for f in desktop.iterdir()] + + +@mcp.tool() +def sum(a: int, b: int) -> int: + """Add two numbers""" + return a + b diff --git a/examples/mcpserver/direct_call_tool_result_return.py b/examples/mcpserver/direct_call_tool_result_return.py index 44a316bc6..940b93bec 100644 --- a/examples/mcpserver/direct_call_tool_result_return.py +++ b/examples/mcpserver/direct_call_tool_result_return.py @@ -1,22 +1,22 @@ -"""MCPServer Echo Server with direct CallToolResult return""" - -from typing import Annotated - -from pydantic import BaseModel - -from mcp.server.mcpserver import MCPServer -from mcp.types import CallToolResult, TextContent - -mcp = MCPServer("Echo Server") - - -class EchoResponse(BaseModel): - text: str - - -@mcp.tool() -def echo(text: str) -> Annotated[CallToolResult, EchoResponse]: - """Echo the input text with structure and metadata""" - return CallToolResult( - content=[TextContent(type="text", text=text)], structured_content={"text": text}, _meta={"some": "metadata"} - ) +"""MCPServer Echo Server with direct CallToolResult return""" + +from typing import Annotated + +from pydantic import BaseModel + +from mcp.server.mcpserver import MCPServer +from mcp.types import CallToolResult, TextContent + +mcp = MCPServer("Echo Server") + + +class EchoResponse(BaseModel): + text: str + + +@mcp.tool() +def echo(text: str) -> Annotated[CallToolResult, EchoResponse]: + """Echo the input text with structure and metadata""" + return CallToolResult( + content=[TextContent(type="text", text=text)], structured_content={"text": text}, _meta={"some": "metadata"} + ) diff --git a/examples/mcpserver/echo.py b/examples/mcpserver/echo.py index 501c47069..92ae1b1e5 100644 --- a/examples/mcpserver/echo.py +++ b/examples/mcpserver/echo.py @@ -1,28 +1,28 @@ -"""MCPServer Echo Server""" - -from mcp.server.mcpserver import MCPServer - -# Create server -mcp = MCPServer("Echo Server") - - -@mcp.tool() -def echo_tool(text: str) -> str: - """Echo the input text""" - return text - - -@mcp.resource("echo://static") -def echo_resource() -> str: - return "Echo!" - - -@mcp.resource("echo://{text}") -def echo_template(text: str) -> str: - """Echo the input text""" - return f"Echo: {text}" - - -@mcp.prompt("echo") -def echo_prompt(text: str) -> str: - return text +"""MCPServer Echo Server""" + +from mcp.server.mcpserver import MCPServer + +# Create server +mcp = MCPServer("Echo Server") + + +@mcp.tool() +def echo_tool(text: str) -> str: + """Echo the input text""" + return text + + +@mcp.resource("echo://static") +def echo_resource() -> str: + return "Echo!" + + +@mcp.resource("echo://{text}") +def echo_template(text: str) -> str: + """Echo the input text""" + return f"Echo: {text}" + + +@mcp.prompt("echo") +def echo_prompt(text: str) -> str: + return text diff --git a/examples/mcpserver/icons_demo.py b/examples/mcpserver/icons_demo.py index f50389f32..895ef148e 100644 --- a/examples/mcpserver/icons_demo.py +++ b/examples/mcpserver/icons_demo.py @@ -1,56 +1,56 @@ -"""MCPServer Icons Demo Server - -Demonstrates using icons with tools, resources, prompts, and implementation. -""" - -import base64 -from pathlib import Path - -from mcp.server.mcpserver import Icon, MCPServer - -# Load the icon file and convert to data URI -icon_path = Path(__file__).parent / "mcp.png" -icon_data = base64.standard_b64encode(icon_path.read_bytes()).decode() -icon_data_uri = f"data:image/png;base64,{icon_data}" - -icon_data = Icon(src=icon_data_uri, mime_type="image/png", sizes=["64x64"]) - -# Create server with icons in implementation -mcp = MCPServer( - "Icons Demo Server", website_url="https://github.com/modelcontextprotocol/python-sdk", icons=[icon_data] -) - - -@mcp.tool(icons=[icon_data]) -def demo_tool(message: str) -> str: - """A demo tool with an icon.""" - return message - - -@mcp.resource("demo://readme", icons=[icon_data]) -def readme_resource() -> str: - """A demo resource with an icon""" - return "This resource has an icon" - - -@mcp.prompt("prompt_with_icon", icons=[icon_data]) -def prompt_with_icon(text: str) -> str: - """A demo prompt with an icon""" - return text - - -@mcp.tool( - icons=[ - Icon(src=icon_data_uri, mime_type="image/png", sizes=["16x16"]), - Icon(src=icon_data_uri, mime_type="image/png", sizes=["32x32"]), - Icon(src=icon_data_uri, mime_type="image/png", sizes=["64x64"]), - ] -) -def multi_icon_tool(action: str) -> str: - """A tool demonstrating multiple icons.""" - return "multi_icon_tool" - - -if __name__ == "__main__": - # Run the server - mcp.run() +"""MCPServer Icons Demo Server + +Demonstrates using icons with tools, resources, prompts, and implementation. +""" + +import base64 +from pathlib import Path + +from mcp.server.mcpserver import Icon, MCPServer + +# Load the icon file and convert to data URI +icon_path = Path(__file__).parent / "mcp.png" +icon_data = base64.standard_b64encode(icon_path.read_bytes()).decode() +icon_data_uri = f"data:image/png;base64,{icon_data}" + +icon_data = Icon(src=icon_data_uri, mime_type="image/png", sizes=["64x64"]) + +# Create server with icons in implementation +mcp = MCPServer( + "Icons Demo Server", website_url="https://github.com/modelcontextprotocol/python-sdk", icons=[icon_data] +) + + +@mcp.tool(icons=[icon_data]) +def demo_tool(message: str) -> str: + """A demo tool with an icon.""" + return message + + +@mcp.resource("demo://readme", icons=[icon_data]) +def readme_resource() -> str: + """A demo resource with an icon""" + return "This resource has an icon" + + +@mcp.prompt("prompt_with_icon", icons=[icon_data]) +def prompt_with_icon(text: str) -> str: + """A demo prompt with an icon""" + return text + + +@mcp.tool( + icons=[ + Icon(src=icon_data_uri, mime_type="image/png", sizes=["16x16"]), + Icon(src=icon_data_uri, mime_type="image/png", sizes=["32x32"]), + Icon(src=icon_data_uri, mime_type="image/png", sizes=["64x64"]), + ] +) +def multi_icon_tool(action: str) -> str: + """A tool demonstrating multiple icons.""" + return "multi_icon_tool" + + +if __name__ == "__main__": + # Run the server + mcp.run() diff --git a/examples/mcpserver/logging_and_progress.py b/examples/mcpserver/logging_and_progress.py index b157f9dd0..c854afb33 100644 --- a/examples/mcpserver/logging_and_progress.py +++ b/examples/mcpserver/logging_and_progress.py @@ -1,31 +1,31 @@ -"""MCPServer Echo Server that sends log messages and progress updates to the client""" - -import asyncio - -from mcp.server.mcpserver import Context, MCPServer - -# Create server -mcp = MCPServer("Echo Server with logging and progress updates") - - -@mcp.tool() -async def echo(text: str, ctx: Context) -> str: - """Echo the input text sending log messages and progress updates during processing.""" - await ctx.report_progress(progress=0, total=100) - await ctx.info("Starting to process echo for input: " + text) - - await asyncio.sleep(2) - - await ctx.info("Halfway through processing echo for input: " + text) - await ctx.report_progress(progress=50, total=100) - - await asyncio.sleep(2) - - await ctx.info("Finished processing echo for input: " + text) - await ctx.report_progress(progress=100, total=100) - - # Progress notifications are process asynchronously by the client. - # A small delay here helps ensure the last notification is processed by the client. - await asyncio.sleep(0.1) - - return text +"""MCPServer Echo Server that sends log messages and progress updates to the client""" + +import asyncio + +from mcp.server.mcpserver import Context, MCPServer + +# Create server +mcp = MCPServer("Echo Server with logging and progress updates") + + +@mcp.tool() +async def echo(text: str, ctx: Context) -> str: + """Echo the input text sending log messages and progress updates during processing.""" + await ctx.report_progress(progress=0, total=100) + await ctx.info("Starting to process echo for input: " + text) + + await asyncio.sleep(2) + + await ctx.info("Halfway through processing echo for input: " + text) + await ctx.report_progress(progress=50, total=100) + + await asyncio.sleep(2) + + await ctx.info("Finished processing echo for input: " + text) + await ctx.report_progress(progress=100, total=100) + + # Progress notifications are process asynchronously by the client. + # A small delay here helps ensure the last notification is processed by the client. + await asyncio.sleep(0.1) + + return text diff --git a/examples/mcpserver/memory.py b/examples/mcpserver/memory.py index fd0bd9362..b86800f88 100644 --- a/examples/mcpserver/memory.py +++ b/examples/mcpserver/memory.py @@ -1,324 +1,324 @@ -# /// script -# dependencies = ["pydantic-ai-slim[openai]", "asyncpg", "numpy", "pgvector"] -# /// - -# uv pip install 'pydantic-ai-slim[openai]' asyncpg numpy pgvector - -"""Recursive memory system inspired by the human brain's clustering of memories. -Uses OpenAI's 'text-embedding-3-small' model and pgvector for efficient -similarity search. -""" - -import asyncio -import math -import os -from dataclasses import dataclass -from datetime import datetime, timezone -from pathlib import Path -from typing import Annotated, Self, TypeVar - -import asyncpg -import numpy as np -from openai import AsyncOpenAI -from pgvector.asyncpg import register_vector # Import register_vector -from pydantic import BaseModel, Field -from pydantic_ai import Agent - -from mcp.server.mcpserver import MCPServer - -MAX_DEPTH = 5 -SIMILARITY_THRESHOLD = 0.7 -DECAY_FACTOR = 0.99 -REINFORCEMENT_FACTOR = 1.1 - -DEFAULT_LLM_MODEL = "openai:gpt-4o" -DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small" - -T = TypeVar("T") - -mcp = MCPServer("memory") - -DB_DSN = "postgresql://postgres:postgres@localhost:54320/memory_db" -# reset memory with rm ~/.mcp/{USER}/memory/* -PROFILE_DIR = (Path.home() / ".mcp" / os.environ.get("USER", "anon") / "memory").resolve() -PROFILE_DIR.mkdir(parents=True, exist_ok=True) - - -def cosine_similarity(a: list[float], b: list[float]) -> float: - a_array = np.array(a, dtype=np.float64) - b_array = np.array(b, dtype=np.float64) - return np.dot(a_array, b_array) / (np.linalg.norm(a_array) * np.linalg.norm(b_array)) - - -async def do_ai( - user_prompt: str, - system_prompt: str, - result_type: type[T] | Annotated, - deps=None, -) -> T: - agent = Agent( - DEFAULT_LLM_MODEL, - system_prompt=system_prompt, - result_type=result_type, - ) - result = await agent.run(user_prompt, deps=deps) - return result.data - - -@dataclass -class Deps: - openai: AsyncOpenAI - pool: asyncpg.Pool - - -async def get_db_pool() -> asyncpg.Pool: - async def init(conn): - await conn.execute("CREATE EXTENSION IF NOT EXISTS vector;") - await register_vector(conn) - - pool = await asyncpg.create_pool(DB_DSN, init=init) - return pool - - -class MemoryNode(BaseModel): - id: int | None = None - content: str - summary: str = "" - importance: float = 1.0 - access_count: int = 0 - timestamp: float = Field(default_factory=lambda: datetime.now(timezone.utc).timestamp()) - embedding: list[float] - - @classmethod - async def from_content(cls, content: str, deps: Deps): - embedding = await get_embedding(content, deps) - return cls(content=content, embedding=embedding) - - async def save(self, deps: Deps): - async with deps.pool.acquire() as conn: - if self.id is None: - result = await conn.fetchrow( - """ - INSERT INTO memories (content, summary, importance, access_count, - timestamp, embedding) - VALUES ($1, $2, $3, $4, $5, $6) - RETURNING id - """, - self.content, - self.summary, - self.importance, - self.access_count, - self.timestamp, - self.embedding, - ) - self.id = result["id"] - else: - await conn.execute( - """ - UPDATE memories - SET content = $1, summary = $2, importance = $3, - access_count = $4, timestamp = $5, embedding = $6 - WHERE id = $7 - """, - self.content, - self.summary, - self.importance, - self.access_count, - self.timestamp, - self.embedding, - self.id, - ) - - async def merge_with(self, other: Self, deps: Deps): - self.content = await do_ai( - f"{self.content}\n\n{other.content}", - "Combine the following two texts into a single, coherent text.", - str, - deps, - ) - self.importance += other.importance - self.access_count += other.access_count - self.embedding = [(a + b) / 2 for a, b in zip(self.embedding, other.embedding)] - self.summary = await do_ai(self.content, "Summarize the following text concisely.", str, deps) - await self.save(deps) - # Delete the merged node from the database - if other.id is not None: - await delete_memory(other.id, deps) - - def get_effective_importance(self): - return self.importance * (1 + math.log(self.access_count + 1)) - - -async def get_embedding(text: str, deps: Deps) -> list[float]: - embedding_response = await deps.openai.embeddings.create( - input=text, - model=DEFAULT_EMBEDDING_MODEL, - ) - return embedding_response.data[0].embedding - - -async def delete_memory(memory_id: int, deps: Deps): - async with deps.pool.acquire() as conn: - await conn.execute("DELETE FROM memories WHERE id = $1", memory_id) - - -async def add_memory(content: str, deps: Deps): - new_memory = await MemoryNode.from_content(content, deps) - await new_memory.save(deps) - - similar_memories = await find_similar_memories(new_memory.embedding, deps) - for memory in similar_memories: - if memory.id != new_memory.id: - await new_memory.merge_with(memory, deps) - - await update_importance(new_memory.embedding, deps) - - await prune_memories(deps) - - return f"Remembered: {content}" - - -async def find_similar_memories(embedding: list[float], deps: Deps) -> list[MemoryNode]: - async with deps.pool.acquire() as conn: - rows = await conn.fetch( - """ - SELECT id, content, summary, importance, access_count, timestamp, embedding - FROM memories - ORDER BY embedding <-> $1 - LIMIT 5 - """, - embedding, - ) - memories = [ - MemoryNode( - id=row["id"], - content=row["content"], - summary=row["summary"], - importance=row["importance"], - access_count=row["access_count"], - timestamp=row["timestamp"], - embedding=row["embedding"], - ) - for row in rows - ] - return memories - - -async def update_importance(user_embedding: list[float], deps: Deps): - async with deps.pool.acquire() as conn: - rows = await conn.fetch("SELECT id, importance, access_count, embedding FROM memories") - for row in rows: - memory_embedding = row["embedding"] - similarity = cosine_similarity(user_embedding, memory_embedding) - if similarity > SIMILARITY_THRESHOLD: - new_importance = row["importance"] * REINFORCEMENT_FACTOR - new_access_count = row["access_count"] + 1 - else: - new_importance = row["importance"] * DECAY_FACTOR - new_access_count = row["access_count"] - await conn.execute( - """ - UPDATE memories - SET importance = $1, access_count = $2 - WHERE id = $3 - """, - new_importance, - new_access_count, - row["id"], - ) - - -async def prune_memories(deps: Deps): - async with deps.pool.acquire() as conn: - rows = await conn.fetch( - """ - SELECT id, importance, access_count - FROM memories - ORDER BY importance DESC - OFFSET $1 - """, - MAX_DEPTH, - ) - for row in rows: - await conn.execute("DELETE FROM memories WHERE id = $1", row["id"]) - - -async def display_memory_tree(deps: Deps) -> str: - async with deps.pool.acquire() as conn: - rows = await conn.fetch( - """ - SELECT content, summary, importance, access_count - FROM memories - ORDER BY importance DESC - LIMIT $1 - """, - MAX_DEPTH, - ) - result = "" - for row in rows: - effective_importance = row["importance"] * (1 + math.log(row["access_count"] + 1)) - summary = row["summary"] or row["content"] - result += f"- {summary} (Importance: {effective_importance:.2f})\n" - return result - - -@mcp.tool() -async def remember( - contents: list[str] = Field(description="List of observations or memories to store"), -): - deps = Deps(openai=AsyncOpenAI(), pool=await get_db_pool()) - try: - return "\n".join(await asyncio.gather(*[add_memory(content, deps) for content in contents])) - finally: - await deps.pool.close() - - -@mcp.tool() -async def read_profile() -> str: - deps = Deps(openai=AsyncOpenAI(), pool=await get_db_pool()) - profile = await display_memory_tree(deps) - await deps.pool.close() - return profile - - -async def initialize_database(): - pool = await asyncpg.create_pool("postgresql://postgres:postgres@localhost:54320/postgres") - try: - async with pool.acquire() as conn: - await conn.execute(""" - SELECT pg_terminate_backend(pg_stat_activity.pid) - FROM pg_stat_activity - WHERE pg_stat_activity.datname = 'memory_db' - AND pid <> pg_backend_pid(); - """) - await conn.execute("DROP DATABASE IF EXISTS memory_db;") - await conn.execute("CREATE DATABASE memory_db;") - finally: - await pool.close() - - pool = await asyncpg.create_pool(DB_DSN) - try: - async with pool.acquire() as conn: - await conn.execute("CREATE EXTENSION IF NOT EXISTS vector;") - - await register_vector(conn) - - await conn.execute(""" - CREATE TABLE IF NOT EXISTS memories ( - id SERIAL PRIMARY KEY, - content TEXT NOT NULL, - summary TEXT, - importance REAL NOT NULL, - access_count INT NOT NULL, - timestamp DOUBLE PRECISION NOT NULL, - embedding vector(1536) NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_memories_embedding ON memories - USING hnsw (embedding vector_l2_ops); - """) - finally: - await pool.close() - - -if __name__ == "__main__": - asyncio.run(initialize_database()) +# /// script +# dependencies = ["pydantic-ai-slim[openai]", "asyncpg", "numpy", "pgvector"] +# /// + +# uv pip install 'pydantic-ai-slim[openai]' asyncpg numpy pgvector + +"""Recursive memory system inspired by the human brain's clustering of memories. +Uses OpenAI's 'text-embedding-3-small' model and pgvector for efficient +similarity search. +""" + +import asyncio +import math +import os +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Annotated, Self, TypeVar + +import asyncpg +import numpy as np +from openai import AsyncOpenAI +from pgvector.asyncpg import register_vector # Import register_vector +from pydantic import BaseModel, Field +from pydantic_ai import Agent + +from mcp.server.mcpserver import MCPServer + +MAX_DEPTH = 5 +SIMILARITY_THRESHOLD = 0.7 +DECAY_FACTOR = 0.99 +REINFORCEMENT_FACTOR = 1.1 + +DEFAULT_LLM_MODEL = "openai:gpt-4o" +DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small" + +T = TypeVar("T") + +mcp = MCPServer("memory") + +DB_DSN = "postgresql://postgres:postgres@localhost:54320/memory_db" +# reset memory with rm ~/.mcp/{USER}/memory/* +PROFILE_DIR = (Path.home() / ".mcp" / os.environ.get("USER", "anon") / "memory").resolve() +PROFILE_DIR.mkdir(parents=True, exist_ok=True) + + +def cosine_similarity(a: list[float], b: list[float]) -> float: + a_array = np.array(a, dtype=np.float64) + b_array = np.array(b, dtype=np.float64) + return np.dot(a_array, b_array) / (np.linalg.norm(a_array) * np.linalg.norm(b_array)) + + +async def do_ai( + user_prompt: str, + system_prompt: str, + result_type: type[T] | Annotated, + deps=None, +) -> T: + agent = Agent( + DEFAULT_LLM_MODEL, + system_prompt=system_prompt, + result_type=result_type, + ) + result = await agent.run(user_prompt, deps=deps) + return result.data + + +@dataclass +class Deps: + openai: AsyncOpenAI + pool: asyncpg.Pool + + +async def get_db_pool() -> asyncpg.Pool: + async def init(conn): + await conn.execute("CREATE EXTENSION IF NOT EXISTS vector;") + await register_vector(conn) + + pool = await asyncpg.create_pool(DB_DSN, init=init) + return pool + + +class MemoryNode(BaseModel): + id: int | None = None + content: str + summary: str = "" + importance: float = 1.0 + access_count: int = 0 + timestamp: float = Field(default_factory=lambda: datetime.now(timezone.utc).timestamp()) + embedding: list[float] + + @classmethod + async def from_content(cls, content: str, deps: Deps): + embedding = await get_embedding(content, deps) + return cls(content=content, embedding=embedding) + + async def save(self, deps: Deps): + async with deps.pool.acquire() as conn: + if self.id is None: + result = await conn.fetchrow( + """ + INSERT INTO memories (content, summary, importance, access_count, + timestamp, embedding) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id + """, + self.content, + self.summary, + self.importance, + self.access_count, + self.timestamp, + self.embedding, + ) + self.id = result["id"] + else: + await conn.execute( + """ + UPDATE memories + SET content = $1, summary = $2, importance = $3, + access_count = $4, timestamp = $5, embedding = $6 + WHERE id = $7 + """, + self.content, + self.summary, + self.importance, + self.access_count, + self.timestamp, + self.embedding, + self.id, + ) + + async def merge_with(self, other: Self, deps: Deps): + self.content = await do_ai( + f"{self.content}\n\n{other.content}", + "Combine the following two texts into a single, coherent text.", + str, + deps, + ) + self.importance += other.importance + self.access_count += other.access_count + self.embedding = [(a + b) / 2 for a, b in zip(self.embedding, other.embedding)] + self.summary = await do_ai(self.content, "Summarize the following text concisely.", str, deps) + await self.save(deps) + # Delete the merged node from the database + if other.id is not None: + await delete_memory(other.id, deps) + + def get_effective_importance(self): + return self.importance * (1 + math.log(self.access_count + 1)) + + +async def get_embedding(text: str, deps: Deps) -> list[float]: + embedding_response = await deps.openai.embeddings.create( + input=text, + model=DEFAULT_EMBEDDING_MODEL, + ) + return embedding_response.data[0].embedding + + +async def delete_memory(memory_id: int, deps: Deps): + async with deps.pool.acquire() as conn: + await conn.execute("DELETE FROM memories WHERE id = $1", memory_id) + + +async def add_memory(content: str, deps: Deps): + new_memory = await MemoryNode.from_content(content, deps) + await new_memory.save(deps) + + similar_memories = await find_similar_memories(new_memory.embedding, deps) + for memory in similar_memories: + if memory.id != new_memory.id: + await new_memory.merge_with(memory, deps) + + await update_importance(new_memory.embedding, deps) + + await prune_memories(deps) + + return f"Remembered: {content}" + + +async def find_similar_memories(embedding: list[float], deps: Deps) -> list[MemoryNode]: + async with deps.pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT id, content, summary, importance, access_count, timestamp, embedding + FROM memories + ORDER BY embedding <-> $1 + LIMIT 5 + """, + embedding, + ) + memories = [ + MemoryNode( + id=row["id"], + content=row["content"], + summary=row["summary"], + importance=row["importance"], + access_count=row["access_count"], + timestamp=row["timestamp"], + embedding=row["embedding"], + ) + for row in rows + ] + return memories + + +async def update_importance(user_embedding: list[float], deps: Deps): + async with deps.pool.acquire() as conn: + rows = await conn.fetch("SELECT id, importance, access_count, embedding FROM memories") + for row in rows: + memory_embedding = row["embedding"] + similarity = cosine_similarity(user_embedding, memory_embedding) + if similarity > SIMILARITY_THRESHOLD: + new_importance = row["importance"] * REINFORCEMENT_FACTOR + new_access_count = row["access_count"] + 1 + else: + new_importance = row["importance"] * DECAY_FACTOR + new_access_count = row["access_count"] + await conn.execute( + """ + UPDATE memories + SET importance = $1, access_count = $2 + WHERE id = $3 + """, + new_importance, + new_access_count, + row["id"], + ) + + +async def prune_memories(deps: Deps): + async with deps.pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT id, importance, access_count + FROM memories + ORDER BY importance DESC + OFFSET $1 + """, + MAX_DEPTH, + ) + for row in rows: + await conn.execute("DELETE FROM memories WHERE id = $1", row["id"]) + + +async def display_memory_tree(deps: Deps) -> str: + async with deps.pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT content, summary, importance, access_count + FROM memories + ORDER BY importance DESC + LIMIT $1 + """, + MAX_DEPTH, + ) + result = "" + for row in rows: + effective_importance = row["importance"] * (1 + math.log(row["access_count"] + 1)) + summary = row["summary"] or row["content"] + result += f"- {summary} (Importance: {effective_importance:.2f})\n" + return result + + +@mcp.tool() +async def remember( + contents: list[str] = Field(description="List of observations or memories to store"), +): + deps = Deps(openai=AsyncOpenAI(), pool=await get_db_pool()) + try: + return "\n".join(await asyncio.gather(*[add_memory(content, deps) for content in contents])) + finally: + await deps.pool.close() + + +@mcp.tool() +async def read_profile() -> str: + deps = Deps(openai=AsyncOpenAI(), pool=await get_db_pool()) + profile = await display_memory_tree(deps) + await deps.pool.close() + return profile + + +async def initialize_database(): + pool = await asyncpg.create_pool("postgresql://postgres:postgres@localhost:54320/postgres") + try: + async with pool.acquire() as conn: + await conn.execute(""" + SELECT pg_terminate_backend(pg_stat_activity.pid) + FROM pg_stat_activity + WHERE pg_stat_activity.datname = 'memory_db' + AND pid <> pg_backend_pid(); + """) + await conn.execute("DROP DATABASE IF EXISTS memory_db;") + await conn.execute("CREATE DATABASE memory_db;") + finally: + await pool.close() + + pool = await asyncpg.create_pool(DB_DSN) + try: + async with pool.acquire() as conn: + await conn.execute("CREATE EXTENSION IF NOT EXISTS vector;") + + await register_vector(conn) + + await conn.execute(""" + CREATE TABLE IF NOT EXISTS memories ( + id SERIAL PRIMARY KEY, + content TEXT NOT NULL, + summary TEXT, + importance REAL NOT NULL, + access_count INT NOT NULL, + timestamp DOUBLE PRECISION NOT NULL, + embedding vector(1536) NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_memories_embedding ON memories + USING hnsw (embedding vector_l2_ops); + """) + finally: + await pool.close() + + +if __name__ == "__main__": + asyncio.run(initialize_database()) diff --git a/examples/mcpserver/parameter_descriptions.py b/examples/mcpserver/parameter_descriptions.py index 59a1caf3f..606ff5162 100644 --- a/examples/mcpserver/parameter_descriptions.py +++ b/examples/mcpserver/parameter_descriptions.py @@ -1,19 +1,19 @@ -"""MCPServer Example showing parameter descriptions""" - -from pydantic import Field - -from mcp.server.mcpserver import MCPServer - -# Create server -mcp = MCPServer("Parameter Descriptions Server") - - -@mcp.tool() -def greet_user( - name: str = Field(description="The name of the person to greet"), - title: str = Field(description="Optional title like Mr/Ms/Dr", default=""), - times: int = Field(description="Number of times to repeat the greeting", default=1), -) -> str: - """Greet a user with optional title and repetition""" - greeting = f"Hello {title + ' ' if title else ''}{name}!" - return "\n".join([greeting] * times) +"""MCPServer Example showing parameter descriptions""" + +from pydantic import Field + +from mcp.server.mcpserver import MCPServer + +# Create server +mcp = MCPServer("Parameter Descriptions Server") + + +@mcp.tool() +def greet_user( + name: str = Field(description="The name of the person to greet"), + title: str = Field(description="Optional title like Mr/Ms/Dr", default=""), + times: int = Field(description="Number of times to repeat the greeting", default=1), +) -> str: + """Greet a user with optional title and repetition""" + greeting = f"Hello {title + ' ' if title else ''}{name}!" + return "\n".join([greeting] * times) diff --git a/examples/mcpserver/readme-quickstart.py b/examples/mcpserver/readme-quickstart.py index 864b774a9..cabba2621 100644 --- a/examples/mcpserver/readme-quickstart.py +++ b/examples/mcpserver/readme-quickstart.py @@ -1,18 +1,18 @@ -from mcp.server.mcpserver import MCPServer - -# Create an MCP server -mcp = MCPServer("Demo") - - -# Add an addition tool -@mcp.tool() -def sum(a: int, b: int) -> int: - """Add two numbers""" - return a + b - - -# Add a dynamic greeting resource -@mcp.resource("greeting://{name}") -def get_greeting(name: str) -> str: - """Get a personalized greeting""" - return f"Hello, {name}!" +from mcp.server.mcpserver import MCPServer + +# Create an MCP server +mcp = MCPServer("Demo") + + +# Add an addition tool +@mcp.tool() +def sum(a: int, b: int) -> int: + """Add two numbers""" + return a + b + + +# Add a dynamic greeting resource +@mcp.resource("greeting://{name}") +def get_greeting(name: str) -> str: + """Get a personalized greeting""" + return f"Hello, {name}!" diff --git a/examples/mcpserver/screenshot.py b/examples/mcpserver/screenshot.py index e7b3ee6fb..34794487b 100644 --- a/examples/mcpserver/screenshot.py +++ b/examples/mcpserver/screenshot.py @@ -1,27 +1,27 @@ -"""MCPServer Screenshot Example - -Give Claude a tool to capture and view screenshots. -""" - -import io - -from mcp.server.mcpserver import MCPServer -from mcp.server.mcpserver.utilities.types import Image - -# Create server -mcp = MCPServer("Screenshot Demo") - - -@mcp.tool() -def take_screenshot() -> Image: - """Take a screenshot of the user's screen and return it as an image. Use - this tool anytime the user wants you to look at something they're doing. - """ - import pyautogui - - buffer = io.BytesIO() - - # if the file exceeds ~1MB, it will be rejected by Claude - screenshot = pyautogui.screenshot() - screenshot.convert("RGB").save(buffer, format="JPEG", quality=60, optimize=True) - return Image(data=buffer.getvalue(), format="jpeg") +"""MCPServer Screenshot Example + +Give Claude a tool to capture and view screenshots. +""" + +import io + +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.utilities.types import Image + +# Create server +mcp = MCPServer("Screenshot Demo") + + +@mcp.tool() +def take_screenshot() -> Image: + """Take a screenshot of the user's screen and return it as an image. Use + this tool anytime the user wants you to look at something they're doing. + """ + import pyautogui + + buffer = io.BytesIO() + + # if the file exceeds ~1MB, it will be rejected by Claude + screenshot = pyautogui.screenshot() + screenshot.convert("RGB").save(buffer, format="JPEG", quality=60, optimize=True) + return Image(data=buffer.getvalue(), format="jpeg") diff --git a/examples/mcpserver/simple_echo.py b/examples/mcpserver/simple_echo.py index 3d8142a66..e022fc71f 100644 --- a/examples/mcpserver/simple_echo.py +++ b/examples/mcpserver/simple_echo.py @@ -1,12 +1,12 @@ -"""MCPServer Echo Server""" - -from mcp.server.mcpserver import MCPServer - -# Create server -mcp = MCPServer("Echo Server") - - -@mcp.tool() -def echo(text: str) -> str: - """Echo the input text""" - return text +"""MCPServer Echo Server""" + +from mcp.server.mcpserver import MCPServer + +# Create server +mcp = MCPServer("Echo Server") + + +@mcp.tool() +def echo(text: str) -> str: + """Echo the input text""" + return text diff --git a/examples/mcpserver/text_me.py b/examples/mcpserver/text_me.py index 7aeb54362..13ed1ad44 100644 --- a/examples/mcpserver/text_me.py +++ b/examples/mcpserver/text_me.py @@ -1,67 +1,67 @@ -# /// script -# dependencies = [] -# /// - -"""MCPServer Text Me Server --------------------------------- -This defines a simple MCPServer server that sends a text message to a phone number via https://surgemsg.com/. - -To run this example, create a `.env` file with the following values: - -SURGE_API_KEY=... -SURGE_ACCOUNT_ID=... -SURGE_MY_PHONE_NUMBER=... -SURGE_MY_FIRST_NAME=... -SURGE_MY_LAST_NAME=... - -Visit https://surgemsg.com/ and click "Get Started" to obtain these values. -""" - -from typing import Annotated - -import httpx -from pydantic import BeforeValidator -from pydantic_settings import BaseSettings, SettingsConfigDict - -from mcp.server.mcpserver import MCPServer - - -class SurgeSettings(BaseSettings): - model_config: SettingsConfigDict = SettingsConfigDict(env_prefix="SURGE_", env_file=".env") - - api_key: str - account_id: str - my_phone_number: Annotated[str, BeforeValidator(lambda v: "+" + v if not v.startswith("+") else v)] - my_first_name: str - my_last_name: str - - -# Create server -mcp = MCPServer("Text me") -surge_settings = SurgeSettings() # type: ignore - - -@mcp.tool(name="textme", description="Send a text message to me") -def text_me(text_content: str) -> str: - """Send a text message to a phone number via https://surgemsg.com/""" - with httpx.Client() as client: - response = client.post( - "https://api.surgemsg.com/messages", - headers={ - "Authorization": f"Bearer {surge_settings.api_key}", - "Surge-Account": surge_settings.account_id, - "Content-Type": "application/json", - }, - json={ - "body": text_content, - "conversation": { - "contact": { - "first_name": surge_settings.my_first_name, - "last_name": surge_settings.my_last_name, - "phone_number": surge_settings.my_phone_number, - } - }, - }, - ) - response.raise_for_status() - return f"Message sent: {text_content}" +# /// script +# dependencies = [] +# /// + +"""MCPServer Text Me Server +-------------------------------- +This defines a simple MCPServer server that sends a text message to a phone number via https://surgemsg.com/. + +To run this example, create a `.env` file with the following values: + +SURGE_API_KEY=... +SURGE_ACCOUNT_ID=... +SURGE_MY_PHONE_NUMBER=... +SURGE_MY_FIRST_NAME=... +SURGE_MY_LAST_NAME=... + +Visit https://surgemsg.com/ and click "Get Started" to obtain these values. +""" + +from typing import Annotated + +import httpx +from pydantic import BeforeValidator +from pydantic_settings import BaseSettings, SettingsConfigDict + +from mcp.server.mcpserver import MCPServer + + +class SurgeSettings(BaseSettings): + model_config: SettingsConfigDict = SettingsConfigDict(env_prefix="SURGE_", env_file=".env") + + api_key: str + account_id: str + my_phone_number: Annotated[str, BeforeValidator(lambda v: "+" + v if not v.startswith("+") else v)] + my_first_name: str + my_last_name: str + + +# Create server +mcp = MCPServer("Text me") +surge_settings = SurgeSettings() # type: ignore + + +@mcp.tool(name="textme", description="Send a text message to me") +def text_me(text_content: str) -> str: + """Send a text message to a phone number via https://surgemsg.com/""" + with httpx.Client() as client: + response = client.post( + "https://api.surgemsg.com/messages", + headers={ + "Authorization": f"Bearer {surge_settings.api_key}", + "Surge-Account": surge_settings.account_id, + "Content-Type": "application/json", + }, + json={ + "body": text_content, + "conversation": { + "contact": { + "first_name": surge_settings.my_first_name, + "last_name": surge_settings.my_last_name, + "phone_number": surge_settings.my_phone_number, + } + }, + }, + ) + response.raise_for_status() + return f"Message sent: {text_content}" diff --git a/examples/mcpserver/unicode_example.py b/examples/mcpserver/unicode_example.py index 012633ec7..818e663ec 100644 --- a/examples/mcpserver/unicode_example.py +++ b/examples/mcpserver/unicode_example.py @@ -1,59 +1,59 @@ -"""Example MCPServer server that uses Unicode characters in various places to help test -Unicode handling in tools and inspectors. -""" - -from mcp.server.mcpserver import MCPServer - -mcp = MCPServer() - - -@mcp.tool(description="🌟 A tool that uses various Unicode characters in its description: á é í ó ú ñ 漢字 🎉") -def hello_unicode(name: str = "世界", greeting: str = "¡Hola") -> str: - """A simple tool that demonstrates Unicode handling in: - - Tool description (emojis, accents, CJK characters) - - Parameter defaults (CJK characters) - - Return values (Spanish punctuation, emojis) - """ - return f"{greeting}, {name}! 👋" - - -@mcp.tool(description="🎨 Tool that returns a list of emoji categories") -def list_emoji_categories() -> list[str]: - """Returns a list of emoji categories with emoji examples.""" - return [ - "😀 Smileys & Emotion", - "👋 People & Body", - "🐶 Animals & Nature", - "🍎 Food & Drink", - "⚽ Activities", - "🌍 Travel & Places", - "💡 Objects", - "❤️ Symbols", - "🚩 Flags", - ] - - -@mcp.tool(description="🔤 Tool that returns text in different scripts") -def multilingual_hello() -> str: - """Returns hello in different scripts and writing systems.""" - return "\n".join( - [ - "English: Hello!", - "Spanish: ¡Hola!", - "French: Bonjour!", - "German: Grüß Gott!", - "Russian: Привет!", - "Greek: Γεια σας!", - "Hebrew: !שָׁלוֹם", - "Arabic: !مرحبا", - "Hindi: नमस्ते!", - "Chinese: 你好!", - "Japanese: こんにちは!", - "Korean: 안녕하세요!", - "Thai: สวัสดี!", - ] - ) - - -if __name__ == "__main__": - mcp.run() +"""Example MCPServer server that uses Unicode characters in various places to help test +Unicode handling in tools and inspectors. +""" + +from mcp.server.mcpserver import MCPServer + +mcp = MCPServer() + + +@mcp.tool(description="🌟 A tool that uses various Unicode characters in its description: á é í ó ú ñ 漢字 🎉") +def hello_unicode(name: str = "世界", greeting: str = "¡Hola") -> str: + """A simple tool that demonstrates Unicode handling in: + - Tool description (emojis, accents, CJK characters) + - Parameter defaults (CJK characters) + - Return values (Spanish punctuation, emojis) + """ + return f"{greeting}, {name}! 👋" + + +@mcp.tool(description="🎨 Tool that returns a list of emoji categories") +def list_emoji_categories() -> list[str]: + """Returns a list of emoji categories with emoji examples.""" + return [ + "😀 Smileys & Emotion", + "👋 People & Body", + "🐶 Animals & Nature", + "🍎 Food & Drink", + "⚽ Activities", + "🌍 Travel & Places", + "💡 Objects", + "❤️ Symbols", + "🚩 Flags", + ] + + +@mcp.tool(description="🔤 Tool that returns text in different scripts") +def multilingual_hello() -> str: + """Returns hello in different scripts and writing systems.""" + return "\n".join( + [ + "English: Hello!", + "Spanish: ¡Hola!", + "French: Bonjour!", + "German: Grüß Gott!", + "Russian: Привет!", + "Greek: Γεια σας!", + "Hebrew: !שָׁלוֹם", + "Arabic: !مرحبا", + "Hindi: नमस्ते!", + "Chinese: 你好!", + "Japanese: こんにちは!", + "Korean: 안녕하세요!", + "Thai: สวัสดี!", + ] + ) + + +if __name__ == "__main__": + mcp.run() diff --git a/examples/mcpserver/weather_structured.py b/examples/mcpserver/weather_structured.py index 958c7d319..c6d83d5a4 100644 --- a/examples/mcpserver/weather_structured.py +++ b/examples/mcpserver/weather_structured.py @@ -1,224 +1,224 @@ -"""MCPServer Weather Example with Structured Output - -Demonstrates how to use structured output with tools to return -well-typed, validated data that clients can easily process. -""" - -import asyncio -import json -import sys -from dataclasses import dataclass -from datetime import datetime -from typing import TypedDict - -from pydantic import BaseModel, Field - -from mcp.client import Client -from mcp.server.mcpserver import MCPServer - -# Create server -mcp = MCPServer("Weather Service") - - -# Example 1: Using a Pydantic model for structured output -class WeatherData(BaseModel): - """Structured weather data response""" - - temperature: float = Field(description="Temperature in Celsius") - humidity: float = Field(description="Humidity percentage (0-100)") - condition: str = Field(description="Weather condition (sunny, cloudy, rainy, etc.)") - wind_speed: float = Field(description="Wind speed in km/h") - location: str = Field(description="Location name") - timestamp: datetime = Field(default_factory=datetime.now, description="Observation time") - - -@mcp.tool() -def get_weather(city: str) -> WeatherData: - """Get current weather for a city with full structured data""" - # In a real implementation, this would fetch from a weather API - return WeatherData(temperature=22.5, humidity=65.0, condition="partly cloudy", wind_speed=12.3, location=city) - - -# Example 2: Using TypedDict for a simpler structure -class WeatherSummary(TypedDict): - """Simple weather summary""" - - city: str - temp_c: float - description: str - - -@mcp.tool() -def get_weather_summary(city: str) -> WeatherSummary: - """Get a brief weather summary for a city""" - return WeatherSummary(city=city, temp_c=22.5, description="Partly cloudy with light breeze") - - -# Example 3: Using dict[str, Any] for flexible schemas -@mcp.tool() -def get_weather_metrics(cities: list[str]) -> dict[str, dict[str, float]]: - """Get weather metrics for multiple cities - - Returns a dictionary mapping city names to their metrics - """ - # Returns nested dictionaries with weather metrics - return { - city: {"temperature": 20.0 + i * 2, "humidity": 60.0 + i * 5, "pressure": 1013.0 + i * 0.5} - for i, city in enumerate(cities) - } - - -# Example 4: Using dataclass for weather alerts -@dataclass -class WeatherAlert: - """Weather alert information""" - - severity: str # "low", "medium", "high" - title: str - description: str - affected_areas: list[str] - valid_until: datetime - - -@mcp.tool() -def get_weather_alerts(region: str) -> list[WeatherAlert]: - """Get active weather alerts for a region""" - # In production, this would fetch real alerts - if region.lower() == "california": - return [ - WeatherAlert( - severity="high", - title="Heat Wave Warning", - description="Temperatures expected to exceed 40 degrees", - affected_areas=["Los Angeles", "San Diego", "Riverside"], - valid_until=datetime(2024, 7, 15, 18, 0), - ), - WeatherAlert( - severity="medium", - title="Air Quality Advisory", - description="Poor air quality due to wildfire smoke", - affected_areas=["San Francisco Bay Area"], - valid_until=datetime(2024, 7, 14, 12, 0), - ), - ] - return [] - - -# Example 5: Returning primitives with structured output -@mcp.tool() -def get_temperature(city: str, unit: str = "celsius") -> float: - """Get just the temperature for a city - - When returning primitives as structured output, - the result is wrapped in {"result": value} - """ - base_temp = 22.5 - if unit.lower() == "fahrenheit": - return base_temp * 9 / 5 + 32 - return base_temp - - -# Example 6: Weather statistics with nested models -class DailyStats(BaseModel): - """Statistics for a single day""" - - high: float - low: float - mean: float - - -class WeatherStats(BaseModel): - """Weather statistics over a period""" - - location: str - period_days: int - temperature: DailyStats - humidity: DailyStats - precipitation_mm: float = Field(description="Total precipitation in millimeters") - - -@mcp.tool() -def get_weather_stats(city: str, days: int = 7) -> WeatherStats: - """Get weather statistics for the past N days""" - return WeatherStats( - location=city, - period_days=days, - temperature=DailyStats(high=28.5, low=15.2, mean=21.8), - humidity=DailyStats(high=85.0, low=45.0, mean=65.0), - precipitation_mm=12.4, - ) - - -if __name__ == "__main__": - - async def test() -> None: - """Test the tools by calling them through the server as a client would""" - print("Testing Weather Service Tools (via MCP protocol)\n") - print("=" * 80) - - async with Client(mcp) as client: - # Test get_weather - result = await client.call_tool("get_weather", {"city": "London"}) - print("\nWeather in London:") - print(json.dumps(result.structured_content, indent=2)) - - # Test get_weather_summary - result = await client.call_tool("get_weather_summary", {"city": "Paris"}) - print("\nWeather summary for Paris:") - print(json.dumps(result.structured_content, indent=2)) - - # Test get_weather_metrics - result = await client.call_tool("get_weather_metrics", {"cities": ["Tokyo", "Sydney", "Mumbai"]}) - print("\nWeather metrics:") - print(json.dumps(result.structured_content, indent=2)) - - # Test get_weather_alerts - result = await client.call_tool("get_weather_alerts", {"region": "California"}) - print("\nWeather alerts for California:") - print(json.dumps(result.structured_content, indent=2)) - - # Test get_temperature - result = await client.call_tool("get_temperature", {"city": "Berlin", "unit": "fahrenheit"}) - print("\nTemperature in Berlin:") - print(json.dumps(result.structured_content, indent=2)) - - # Test get_weather_stats - result = await client.call_tool("get_weather_stats", {"city": "Seattle", "days": 30}) - print("\nWeather stats for Seattle (30 days):") - print(json.dumps(result.structured_content, indent=2)) - - # Also show the text content for comparison - print("\nText content for last result:") - for content in result.content: - if content.type == "text": - print(content.text) - - async def print_schemas() -> None: - """Print all tool schemas""" - print("Tool Schemas for Weather Service\n") - print("=" * 80) - - tools = await mcp.list_tools() - for tool in tools: - print(f"\nTool: {tool.name}") - print(f"Description: {tool.description}") - print("Input Schema:") - print(json.dumps(tool.input_schema, indent=2)) - - if tool.output_schema: - print("Output Schema:") - print(json.dumps(tool.output_schema, indent=2)) - else: - print("Output Schema: None (returns unstructured content)") - - print("-" * 80) - - # Check command line arguments - if len(sys.argv) > 1 and sys.argv[1] == "--schemas": - asyncio.run(print_schemas()) - else: - print("Usage:") - print(" python weather_structured.py # Run tool tests") - print(" python weather_structured.py --schemas # Print tool schemas") - print() - asyncio.run(test()) +"""MCPServer Weather Example with Structured Output + +Demonstrates how to use structured output with tools to return +well-typed, validated data that clients can easily process. +""" + +import asyncio +import json +import sys +from dataclasses import dataclass +from datetime import datetime +from typing import TypedDict + +from pydantic import BaseModel, Field + +from mcp.client import Client +from mcp.server.mcpserver import MCPServer + +# Create server +mcp = MCPServer("Weather Service") + + +# Example 1: Using a Pydantic model for structured output +class WeatherData(BaseModel): + """Structured weather data response""" + + temperature: float = Field(description="Temperature in Celsius") + humidity: float = Field(description="Humidity percentage (0-100)") + condition: str = Field(description="Weather condition (sunny, cloudy, rainy, etc.)") + wind_speed: float = Field(description="Wind speed in km/h") + location: str = Field(description="Location name") + timestamp: datetime = Field(default_factory=datetime.now, description="Observation time") + + +@mcp.tool() +def get_weather(city: str) -> WeatherData: + """Get current weather for a city with full structured data""" + # In a real implementation, this would fetch from a weather API + return WeatherData(temperature=22.5, humidity=65.0, condition="partly cloudy", wind_speed=12.3, location=city) + + +# Example 2: Using TypedDict for a simpler structure +class WeatherSummary(TypedDict): + """Simple weather summary""" + + city: str + temp_c: float + description: str + + +@mcp.tool() +def get_weather_summary(city: str) -> WeatherSummary: + """Get a brief weather summary for a city""" + return WeatherSummary(city=city, temp_c=22.5, description="Partly cloudy with light breeze") + + +# Example 3: Using dict[str, Any] for flexible schemas +@mcp.tool() +def get_weather_metrics(cities: list[str]) -> dict[str, dict[str, float]]: + """Get weather metrics for multiple cities + + Returns a dictionary mapping city names to their metrics + """ + # Returns nested dictionaries with weather metrics + return { + city: {"temperature": 20.0 + i * 2, "humidity": 60.0 + i * 5, "pressure": 1013.0 + i * 0.5} + for i, city in enumerate(cities) + } + + +# Example 4: Using dataclass for weather alerts +@dataclass +class WeatherAlert: + """Weather alert information""" + + severity: str # "low", "medium", "high" + title: str + description: str + affected_areas: list[str] + valid_until: datetime + + +@mcp.tool() +def get_weather_alerts(region: str) -> list[WeatherAlert]: + """Get active weather alerts for a region""" + # In production, this would fetch real alerts + if region.lower() == "california": + return [ + WeatherAlert( + severity="high", + title="Heat Wave Warning", + description="Temperatures expected to exceed 40 degrees", + affected_areas=["Los Angeles", "San Diego", "Riverside"], + valid_until=datetime(2024, 7, 15, 18, 0), + ), + WeatherAlert( + severity="medium", + title="Air Quality Advisory", + description="Poor air quality due to wildfire smoke", + affected_areas=["San Francisco Bay Area"], + valid_until=datetime(2024, 7, 14, 12, 0), + ), + ] + return [] + + +# Example 5: Returning primitives with structured output +@mcp.tool() +def get_temperature(city: str, unit: str = "celsius") -> float: + """Get just the temperature for a city + + When returning primitives as structured output, + the result is wrapped in {"result": value} + """ + base_temp = 22.5 + if unit.lower() == "fahrenheit": + return base_temp * 9 / 5 + 32 + return base_temp + + +# Example 6: Weather statistics with nested models +class DailyStats(BaseModel): + """Statistics for a single day""" + + high: float + low: float + mean: float + + +class WeatherStats(BaseModel): + """Weather statistics over a period""" + + location: str + period_days: int + temperature: DailyStats + humidity: DailyStats + precipitation_mm: float = Field(description="Total precipitation in millimeters") + + +@mcp.tool() +def get_weather_stats(city: str, days: int = 7) -> WeatherStats: + """Get weather statistics for the past N days""" + return WeatherStats( + location=city, + period_days=days, + temperature=DailyStats(high=28.5, low=15.2, mean=21.8), + humidity=DailyStats(high=85.0, low=45.0, mean=65.0), + precipitation_mm=12.4, + ) + + +if __name__ == "__main__": + + async def test() -> None: + """Test the tools by calling them through the server as a client would""" + print("Testing Weather Service Tools (via MCP protocol)\n") + print("=" * 80) + + async with Client(mcp) as client: + # Test get_weather + result = await client.call_tool("get_weather", {"city": "London"}) + print("\nWeather in London:") + print(json.dumps(result.structured_content, indent=2)) + + # Test get_weather_summary + result = await client.call_tool("get_weather_summary", {"city": "Paris"}) + print("\nWeather summary for Paris:") + print(json.dumps(result.structured_content, indent=2)) + + # Test get_weather_metrics + result = await client.call_tool("get_weather_metrics", {"cities": ["Tokyo", "Sydney", "Mumbai"]}) + print("\nWeather metrics:") + print(json.dumps(result.structured_content, indent=2)) + + # Test get_weather_alerts + result = await client.call_tool("get_weather_alerts", {"region": "California"}) + print("\nWeather alerts for California:") + print(json.dumps(result.structured_content, indent=2)) + + # Test get_temperature + result = await client.call_tool("get_temperature", {"city": "Berlin", "unit": "fahrenheit"}) + print("\nTemperature in Berlin:") + print(json.dumps(result.structured_content, indent=2)) + + # Test get_weather_stats + result = await client.call_tool("get_weather_stats", {"city": "Seattle", "days": 30}) + print("\nWeather stats for Seattle (30 days):") + print(json.dumps(result.structured_content, indent=2)) + + # Also show the text content for comparison + print("\nText content for last result:") + for content in result.content: + if content.type == "text": + print(content.text) + + async def print_schemas() -> None: + """Print all tool schemas""" + print("Tool Schemas for Weather Service\n") + print("=" * 80) + + tools = await mcp.list_tools() + for tool in tools: + print(f"\nTool: {tool.name}") + print(f"Description: {tool.description}") + print("Input Schema:") + print(json.dumps(tool.input_schema, indent=2)) + + if tool.output_schema: + print("Output Schema:") + print(json.dumps(tool.output_schema, indent=2)) + else: + print("Output Schema: None (returns unstructured content)") + + print("-" * 80) + + # Check command line arguments + if len(sys.argv) > 1 and sys.argv[1] == "--schemas": + asyncio.run(print_schemas()) + else: + print("Usage:") + print(" python weather_structured.py # Run tool tests") + print(" python weather_structured.py --schemas # Print tool schemas") + print() + asyncio.run(test()) diff --git a/examples/servers/everything-server/README.md b/examples/servers/everything-server/README.md index 3512665cb..5b92c06c7 100644 --- a/examples/servers/everything-server/README.md +++ b/examples/servers/everything-server/README.md @@ -1,42 +1,42 @@ -# MCP Everything Server - -A comprehensive MCP server implementing all protocol features for conformance testing. - -## Overview - -The Everything Server is a reference implementation that demonstrates all features of the Model Context Protocol (MCP). It is designed to be used with the [MCP Conformance Test Framework](https://github.com/modelcontextprotocol/conformance) to validate MCP client and server implementations. - -## Installation - -From the python-sdk root directory: - -```bash -uv sync --frozen -``` - -## Usage - -### Running the Server - -Start the server with default settings (port 3001): - -```bash -uv run -m mcp_everything_server -``` - -Or with custom options: - -```bash -uv run -m mcp_everything_server --port 3001 --log-level DEBUG -``` - -The server will be available at: `http://localhost:3001/mcp` - -### Command-Line Options - -- `--port` - Port to listen on (default: 3001) -- `--log-level` - Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL (default: INFO) - -## Running Conformance Tests - -See the [MCP Conformance Test Framework](https://github.com/modelcontextprotocol/conformance) for instructions on running conformance tests against this server. +# MCP Everything Server + +A comprehensive MCP server implementing all protocol features for conformance testing. + +## Overview + +The Everything Server is a reference implementation that demonstrates all features of the Model Context Protocol (MCP). It is designed to be used with the [MCP Conformance Test Framework](https://github.com/modelcontextprotocol/conformance) to validate MCP client and server implementations. + +## Installation + +From the python-sdk root directory: + +```bash +uv sync --frozen +``` + +## Usage + +### Running the Server + +Start the server with default settings (port 3001): + +```bash +uv run -m mcp_everything_server +``` + +Or with custom options: + +```bash +uv run -m mcp_everything_server --port 3001 --log-level DEBUG +``` + +The server will be available at: `http://localhost:3001/mcp` + +### Command-Line Options + +- `--port` - Port to listen on (default: 3001) +- `--log-level` - Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL (default: INFO) + +## Running Conformance Tests + +See the [MCP Conformance Test Framework](https://github.com/modelcontextprotocol/conformance) for instructions on running conformance tests against this server. diff --git a/examples/servers/everything-server/mcp_everything_server/__init__.py b/examples/servers/everything-server/mcp_everything_server/__init__.py index d539062d4..669e9cee1 100644 --- a/examples/servers/everything-server/mcp_everything_server/__init__.py +++ b/examples/servers/everything-server/mcp_everything_server/__init__.py @@ -1,3 +1,3 @@ -"""MCP Everything Server - Comprehensive conformance test server.""" - -__version__ = "0.1.0" +"""MCP Everything Server - Comprehensive conformance test server.""" + +__version__ = "0.1.0" diff --git a/examples/servers/everything-server/mcp_everything_server/__main__.py b/examples/servers/everything-server/mcp_everything_server/__main__.py index 2eff688f0..dc197d5b9 100644 --- a/examples/servers/everything-server/mcp_everything_server/__main__.py +++ b/examples/servers/everything-server/mcp_everything_server/__main__.py @@ -1,6 +1,6 @@ -"""CLI entry point for the MCP Everything Server.""" - -from .server import main - -if __name__ == "__main__": - main() +"""CLI entry point for the MCP Everything Server.""" + +from .server import main + +if __name__ == "__main__": + main() diff --git a/examples/servers/everything-server/mcp_everything_server/server.py b/examples/servers/everything-server/mcp_everything_server/server.py index a0620b9c1..93fc929a7 100644 --- a/examples/servers/everything-server/mcp_everything_server/server.py +++ b/examples/servers/everything-server/mcp_everything_server/server.py @@ -1,466 +1,466 @@ -#!/usr/bin/env python3 -"""MCP Everything Server - Conformance Test Server - -Server implementing all MCP features for conformance testing based on Conformance Server Specification. -""" - -import asyncio -import base64 -import json -import logging - -import click -from mcp.server import ServerRequestContext -from mcp.server.mcpserver import Context, MCPServer -from mcp.server.mcpserver.prompts.base import UserMessage -from mcp.server.streamable_http import EventCallback, EventMessage, EventStore -from mcp.types import ( - AudioContent, - Completion, - CompletionArgument, - CompletionContext, - EmbeddedResource, - EmptyResult, - ImageContent, - JSONRPCMessage, - PromptReference, - ResourceTemplateReference, - SamplingMessage, - SetLevelRequestParams, - SubscribeRequestParams, - TextContent, - TextResourceContents, - UnsubscribeRequestParams, -) -from pydantic import BaseModel, Field - -logger = logging.getLogger(__name__) - -# Type aliases for event store -StreamId = str -EventId = str - - -class InMemoryEventStore(EventStore): - """Simple in-memory event store for SSE resumability testing.""" - - def __init__(self) -> None: - self._events: list[tuple[StreamId, EventId, JSONRPCMessage | None]] = [] - self._event_id_counter = 0 - - async def store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId: - """Store an event and return its ID.""" - self._event_id_counter += 1 - event_id = str(self._event_id_counter) - self._events.append((stream_id, event_id, message)) - return event_id - - async def replay_events_after(self, last_event_id: EventId, send_callback: EventCallback) -> StreamId | None: - """Replay events after the specified ID.""" - target_stream_id = None - for stream_id, event_id, _ in self._events: - if event_id == last_event_id: - target_stream_id = stream_id - break - if target_stream_id is None: - return None - last_event_id_int = int(last_event_id) - for stream_id, event_id, message in self._events: - if stream_id == target_stream_id and int(event_id) > last_event_id_int: - # Skip priming events (None message) - if message is not None: - await send_callback(EventMessage(message, event_id)) - return target_stream_id - - -# Test data -TEST_IMAGE_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==" -TEST_AUDIO_BASE64 = "UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA=" - -# Server state -resource_subscriptions: set[str] = set() -watched_resource_content = "Watched resource content" - -# Create event store for SSE resumability (SEP-1699) -event_store = InMemoryEventStore() - -mcp = MCPServer( - name="mcp-conformance-test-server", -) - - -# Tools -@mcp.tool() -def test_simple_text() -> str: - """Tests simple text content response""" - return "This is a simple text response for testing." - - -@mcp.tool() -def test_image_content() -> list[ImageContent]: - """Tests image content response""" - return [ImageContent(type="image", data=TEST_IMAGE_BASE64, mime_type="image/png")] - - -@mcp.tool() -def test_audio_content() -> list[AudioContent]: - """Tests audio content response""" - return [AudioContent(type="audio", data=TEST_AUDIO_BASE64, mime_type="audio/wav")] - - -@mcp.tool() -def test_embedded_resource() -> list[EmbeddedResource]: - """Tests embedded resource content response""" - return [ - EmbeddedResource( - type="resource", - resource=TextResourceContents( - uri="test://embedded-resource", - mime_type="text/plain", - text="This is an embedded resource content.", - ), - ) - ] - - -@mcp.tool() -def test_multiple_content_types() -> list[TextContent | ImageContent | EmbeddedResource]: - """Tests response with multiple content types (text, image, resource)""" - return [ - TextContent(type="text", text="Multiple content types test:"), - ImageContent(type="image", data=TEST_IMAGE_BASE64, mime_type="image/png"), - EmbeddedResource( - type="resource", - resource=TextResourceContents( - uri="test://mixed-content-resource", - mime_type="application/json", - text='{"test": "data", "value": 123}', - ), - ), - ] - - -@mcp.tool() -async def test_tool_with_logging(ctx: Context) -> str: - """Tests tool that emits log messages during execution""" - await ctx.info("Tool execution started") - await asyncio.sleep(0.05) - - await ctx.info("Tool processing data") - await asyncio.sleep(0.05) - - await ctx.info("Tool execution completed") - return "Tool with logging executed successfully" - - -@mcp.tool() -async def test_tool_with_progress(ctx: Context) -> str: - """Tests tool that reports progress notifications""" - await ctx.report_progress(progress=0, total=100, message="Completed step 0 of 100") - await asyncio.sleep(0.05) - - await ctx.report_progress(progress=50, total=100, message="Completed step 50 of 100") - await asyncio.sleep(0.05) - - await ctx.report_progress(progress=100, total=100, message="Completed step 100 of 100") - - # Return progress token as string - progress_token = ( - ctx.request_context.meta.get("progress_token") if ctx.request_context and ctx.request_context.meta else 0 - ) - return str(progress_token) - - -@mcp.tool() -async def test_sampling(prompt: str, ctx: Context) -> str: - """Tests server-initiated sampling (LLM completion request)""" - try: - # Request sampling from client - result = await ctx.session.create_message( - messages=[SamplingMessage(role="user", content=TextContent(type="text", text=prompt))], - max_tokens=100, - ) - - # Since we're not passing tools param, result.content is single content - if result.content.type == "text": - model_response = result.content.text - else: - model_response = "No response" - - return f"LLM response: {model_response}" - except Exception as e: - return f"Sampling not supported or error: {str(e)}" - - -class UserResponse(BaseModel): - response: str = Field(description="User's response") - - -@mcp.tool() -async def test_elicitation(message: str, ctx: Context) -> str: - """Tests server-initiated elicitation (user input request)""" - try: - # Request user input from client - result = await ctx.elicit(message=message, schema=UserResponse) - - # Type-safe discriminated union narrowing using action field - if result.action == "accept": - content = result.data.model_dump_json() - else: # decline or cancel - content = "{}" - - return f"User response: action={result.action}, content={content}" - except Exception as e: - return f"Elicitation not supported or error: {str(e)}" - - -class SEP1034DefaultsSchema(BaseModel): - """Schema for testing SEP-1034 elicitation with default values for all primitive types""" - - name: str = Field(default="John Doe", description="User name") - age: int = Field(default=30, description="User age") - score: float = Field(default=95.5, description="User score") - status: str = Field( - default="active", - description="User status", - json_schema_extra={"enum": ["active", "inactive", "pending"]}, - ) - verified: bool = Field(default=True, description="Verification status") - - -@mcp.tool() -async def test_elicitation_sep1034_defaults(ctx: Context) -> str: - """Tests elicitation with default values for all primitive types (SEP-1034)""" - try: - # Request user input with defaults for all primitive types - result = await ctx.elicit(message="Please provide user information", schema=SEP1034DefaultsSchema) - - # Type-safe discriminated union narrowing using action field - if result.action == "accept": - content = result.data.model_dump_json() - else: # decline or cancel - content = "{}" - - return f"Elicitation result: action={result.action}, content={content}" - except Exception as e: - return f"Elicitation not supported or error: {str(e)}" - - -class EnumSchemasTestSchema(BaseModel): - """Schema for testing enum schema variations (SEP-1330)""" - - untitledSingle: str = Field( - description="Simple enum without titles", json_schema_extra={"enum": ["active", "inactive", "pending"]} - ) - titledSingle: str = Field( - description="Enum with titled options (oneOf)", - json_schema_extra={ - "oneOf": [ - {"const": "low", "title": "Low Priority"}, - {"const": "medium", "title": "Medium Priority"}, - {"const": "high", "title": "High Priority"}, - ] - }, - ) - untitledMulti: list[str] = Field( - description="Multi-select without titles", - json_schema_extra={"items": {"type": "string", "enum": ["read", "write", "execute"]}}, - ) - titledMulti: list[str] = Field( - description="Multi-select with titled options", - json_schema_extra={ - "items": { - "anyOf": [ - {"const": "feature", "title": "New Feature"}, - {"const": "bug", "title": "Bug Fix"}, - {"const": "docs", "title": "Documentation"}, - ] - } - }, - ) - legacyEnum: str = Field( - description="Legacy enum with enumNames", - json_schema_extra={ - "enum": ["small", "medium", "large"], - "enumNames": ["Small Size", "Medium Size", "Large Size"], - }, - ) - - -@mcp.tool() -async def test_elicitation_sep1330_enums(ctx: Context) -> str: - """Tests elicitation with enum schema variations per SEP-1330""" - try: - result = await ctx.elicit( - message="Please select values using different enum schema types", schema=EnumSchemasTestSchema - ) - - if result.action == "accept": - content = result.data.model_dump_json() - else: - content = "{}" - - return f"Elicitation completed: action={result.action}, content={content}" - except Exception as e: - return f"Elicitation not supported or error: {str(e)}" - - -@mcp.tool() -def test_error_handling() -> str: - """Tests error response handling""" - raise RuntimeError("This tool intentionally returns an error for testing") - - -@mcp.tool() -async def test_reconnection(ctx: Context) -> str: - """Tests SSE polling by closing stream mid-call (SEP-1699)""" - await ctx.info("Before disconnect") - - await ctx.close_sse_stream() - - await asyncio.sleep(0.2) # Wait for client to reconnect - - await ctx.info("After reconnect") - return "Reconnection test completed" - - -# Resources -@mcp.resource("test://static-text") -def static_text_resource() -> str: - """A static text resource for testing""" - return "This is the content of the static text resource." - - -@mcp.resource("test://static-binary") -def static_binary_resource() -> bytes: - """A static binary resource (image) for testing""" - return base64.b64decode(TEST_IMAGE_BASE64) - - -@mcp.resource("test://template/{id}/data") -def template_resource(id: str) -> str: - """A resource template with parameter substitution""" - return json.dumps({"id": id, "templateTest": True, "data": f"Data for ID: {id}"}) - - -@mcp.resource("test://watched-resource") -def watched_resource() -> str: - """A resource that can be subscribed to for updates""" - return watched_resource_content - - -# Prompts -@mcp.prompt() -def test_simple_prompt() -> list[UserMessage]: - """A simple prompt without arguments""" - return [UserMessage(role="user", content=TextContent(type="text", text="This is a simple prompt for testing."))] - - -@mcp.prompt() -def test_prompt_with_arguments(arg1: str, arg2: str) -> list[UserMessage]: - """A prompt with required arguments""" - return [ - UserMessage( - role="user", content=TextContent(type="text", text=f"Prompt with arguments: arg1='{arg1}', arg2='{arg2}'") - ) - ] - - -@mcp.prompt() -def test_prompt_with_embedded_resource(resourceUri: str) -> list[UserMessage]: - """A prompt that includes an embedded resource""" - return [ - UserMessage( - role="user", - content=EmbeddedResource( - type="resource", - resource=TextResourceContents( - uri=resourceUri, - mime_type="text/plain", - text="Embedded resource content for testing.", - ), - ), - ), - UserMessage(role="user", content=TextContent(type="text", text="Please process the embedded resource above.")), - ] - - -@mcp.prompt() -def test_prompt_with_image() -> list[UserMessage]: - """A prompt that includes image content""" - return [ - UserMessage(role="user", content=ImageContent(type="image", data=TEST_IMAGE_BASE64, mime_type="image/png")), - UserMessage(role="user", content=TextContent(type="text", text="Please analyze the image above.")), - ] - - -# Custom request handlers -# TODO(felix): Add public APIs to MCPServer for subscribe_resource, unsubscribe_resource, -# and set_logging_level to avoid accessing protected _lowlevel_server attribute. -async def handle_set_logging_level(ctx: ServerRequestContext, params: SetLevelRequestParams) -> EmptyResult: - """Handle logging level changes""" - logger.info(f"Log level set to: {params.level}") - return EmptyResult() - - -async def handle_subscribe(ctx: ServerRequestContext, params: SubscribeRequestParams) -> EmptyResult: - """Handle resource subscription""" - resource_subscriptions.add(str(params.uri)) - logger.info(f"Subscribed to resource: {params.uri}") - return EmptyResult() - - -async def handle_unsubscribe(ctx: ServerRequestContext, params: UnsubscribeRequestParams) -> EmptyResult: - """Handle resource unsubscription""" - resource_subscriptions.discard(str(params.uri)) - logger.info(f"Unsubscribed from resource: {params.uri}") - return EmptyResult() - - -mcp._lowlevel_server._add_request_handler("logging/setLevel", handle_set_logging_level) # pyright: ignore[reportPrivateUsage] -mcp._lowlevel_server._add_request_handler("resources/subscribe", handle_subscribe) # pyright: ignore[reportPrivateUsage] -mcp._lowlevel_server._add_request_handler("resources/unsubscribe", handle_unsubscribe) # pyright: ignore[reportPrivateUsage] - - -@mcp.completion() -async def _handle_completion( - ref: PromptReference | ResourceTemplateReference, - argument: CompletionArgument, - context: CompletionContext | None, -) -> Completion: - """Handle completion requests""" - # Basic completion support - returns empty array for conformance - # Real implementations would provide contextual suggestions - return Completion(values=[], total=0, has_more=False) - - -# CLI -@click.command() -@click.option("--port", default=3001, help="Port to listen on for HTTP") -@click.option( - "--log-level", - default="INFO", - help="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)", -) -def main(port: int, log_level: str) -> int: - """Run the MCP Everything Server.""" - logging.basicConfig( - level=getattr(logging, log_level.upper()), - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - ) - - logger.info(f"Starting MCP Everything Server on port {port}") - logger.info(f"Endpoint will be: http://localhost:{port}/mcp") - - mcp.run( - transport="streamable-http", - port=port, - event_store=event_store, - retry_interval=100, # 100ms retry interval for SSE polling - ) - - return 0 - - -if __name__ == "__main__": - main() +#!/usr/bin/env python3 +"""MCP Everything Server - Conformance Test Server + +Server implementing all MCP features for conformance testing based on Conformance Server Specification. +""" + +import asyncio +import base64 +import json +import logging + +import click +from mcp.server import ServerRequestContext +from mcp.server.mcpserver import Context, MCPServer +from mcp.server.mcpserver.prompts.base import UserMessage +from mcp.server.streamable_http import EventCallback, EventMessage, EventStore +from mcp.types import ( + AudioContent, + Completion, + CompletionArgument, + CompletionContext, + EmbeddedResource, + EmptyResult, + ImageContent, + JSONRPCMessage, + PromptReference, + ResourceTemplateReference, + SamplingMessage, + SetLevelRequestParams, + SubscribeRequestParams, + TextContent, + TextResourceContents, + UnsubscribeRequestParams, +) +from pydantic import BaseModel, Field + +logger = logging.getLogger(__name__) + +# Type aliases for event store +StreamId = str +EventId = str + + +class InMemoryEventStore(EventStore): + """Simple in-memory event store for SSE resumability testing.""" + + def __init__(self) -> None: + self._events: list[tuple[StreamId, EventId, JSONRPCMessage | None]] = [] + self._event_id_counter = 0 + + async def store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId: + """Store an event and return its ID.""" + self._event_id_counter += 1 + event_id = str(self._event_id_counter) + self._events.append((stream_id, event_id, message)) + return event_id + + async def replay_events_after(self, last_event_id: EventId, send_callback: EventCallback) -> StreamId | None: + """Replay events after the specified ID.""" + target_stream_id = None + for stream_id, event_id, _ in self._events: + if event_id == last_event_id: + target_stream_id = stream_id + break + if target_stream_id is None: + return None + last_event_id_int = int(last_event_id) + for stream_id, event_id, message in self._events: + if stream_id == target_stream_id and int(event_id) > last_event_id_int: + # Skip priming events (None message) + if message is not None: + await send_callback(EventMessage(message, event_id)) + return target_stream_id + + +# Test data +TEST_IMAGE_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==" +TEST_AUDIO_BASE64 = "UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA=" + +# Server state +resource_subscriptions: set[str] = set() +watched_resource_content = "Watched resource content" + +# Create event store for SSE resumability (SEP-1699) +event_store = InMemoryEventStore() + +mcp = MCPServer( + name="mcp-conformance-test-server", +) + + +# Tools +@mcp.tool() +def test_simple_text() -> str: + """Tests simple text content response""" + return "This is a simple text response for testing." + + +@mcp.tool() +def test_image_content() -> list[ImageContent]: + """Tests image content response""" + return [ImageContent(type="image", data=TEST_IMAGE_BASE64, mime_type="image/png")] + + +@mcp.tool() +def test_audio_content() -> list[AudioContent]: + """Tests audio content response""" + return [AudioContent(type="audio", data=TEST_AUDIO_BASE64, mime_type="audio/wav")] + + +@mcp.tool() +def test_embedded_resource() -> list[EmbeddedResource]: + """Tests embedded resource content response""" + return [ + EmbeddedResource( + type="resource", + resource=TextResourceContents( + uri="test://embedded-resource", + mime_type="text/plain", + text="This is an embedded resource content.", + ), + ) + ] + + +@mcp.tool() +def test_multiple_content_types() -> list[TextContent | ImageContent | EmbeddedResource]: + """Tests response with multiple content types (text, image, resource)""" + return [ + TextContent(type="text", text="Multiple content types test:"), + ImageContent(type="image", data=TEST_IMAGE_BASE64, mime_type="image/png"), + EmbeddedResource( + type="resource", + resource=TextResourceContents( + uri="test://mixed-content-resource", + mime_type="application/json", + text='{"test": "data", "value": 123}', + ), + ), + ] + + +@mcp.tool() +async def test_tool_with_logging(ctx: Context) -> str: + """Tests tool that emits log messages during execution""" + await ctx.info("Tool execution started") + await asyncio.sleep(0.05) + + await ctx.info("Tool processing data") + await asyncio.sleep(0.05) + + await ctx.info("Tool execution completed") + return "Tool with logging executed successfully" + + +@mcp.tool() +async def test_tool_with_progress(ctx: Context) -> str: + """Tests tool that reports progress notifications""" + await ctx.report_progress(progress=0, total=100, message="Completed step 0 of 100") + await asyncio.sleep(0.05) + + await ctx.report_progress(progress=50, total=100, message="Completed step 50 of 100") + await asyncio.sleep(0.05) + + await ctx.report_progress(progress=100, total=100, message="Completed step 100 of 100") + + # Return progress token as string + progress_token = ( + ctx.request_context.meta.get("progress_token") if ctx.request_context and ctx.request_context.meta else 0 + ) + return str(progress_token) + + +@mcp.tool() +async def test_sampling(prompt: str, ctx: Context) -> str: + """Tests server-initiated sampling (LLM completion request)""" + try: + # Request sampling from client + result = await ctx.session.create_message( + messages=[SamplingMessage(role="user", content=TextContent(type="text", text=prompt))], + max_tokens=100, + ) + + # Since we're not passing tools param, result.content is single content + if result.content.type == "text": + model_response = result.content.text + else: + model_response = "No response" + + return f"LLM response: {model_response}" + except Exception as e: + return f"Sampling not supported or error: {str(e)}" + + +class UserResponse(BaseModel): + response: str = Field(description="User's response") + + +@mcp.tool() +async def test_elicitation(message: str, ctx: Context) -> str: + """Tests server-initiated elicitation (user input request)""" + try: + # Request user input from client + result = await ctx.elicit(message=message, schema=UserResponse) + + # Type-safe discriminated union narrowing using action field + if result.action == "accept": + content = result.data.model_dump_json() + else: # decline or cancel + content = "{}" + + return f"User response: action={result.action}, content={content}" + except Exception as e: + return f"Elicitation not supported or error: {str(e)}" + + +class SEP1034DefaultsSchema(BaseModel): + """Schema for testing SEP-1034 elicitation with default values for all primitive types""" + + name: str = Field(default="John Doe", description="User name") + age: int = Field(default=30, description="User age") + score: float = Field(default=95.5, description="User score") + status: str = Field( + default="active", + description="User status", + json_schema_extra={"enum": ["active", "inactive", "pending"]}, + ) + verified: bool = Field(default=True, description="Verification status") + + +@mcp.tool() +async def test_elicitation_sep1034_defaults(ctx: Context) -> str: + """Tests elicitation with default values for all primitive types (SEP-1034)""" + try: + # Request user input with defaults for all primitive types + result = await ctx.elicit(message="Please provide user information", schema=SEP1034DefaultsSchema) + + # Type-safe discriminated union narrowing using action field + if result.action == "accept": + content = result.data.model_dump_json() + else: # decline or cancel + content = "{}" + + return f"Elicitation result: action={result.action}, content={content}" + except Exception as e: + return f"Elicitation not supported or error: {str(e)}" + + +class EnumSchemasTestSchema(BaseModel): + """Schema for testing enum schema variations (SEP-1330)""" + + untitledSingle: str = Field( + description="Simple enum without titles", json_schema_extra={"enum": ["active", "inactive", "pending"]} + ) + titledSingle: str = Field( + description="Enum with titled options (oneOf)", + json_schema_extra={ + "oneOf": [ + {"const": "low", "title": "Low Priority"}, + {"const": "medium", "title": "Medium Priority"}, + {"const": "high", "title": "High Priority"}, + ] + }, + ) + untitledMulti: list[str] = Field( + description="Multi-select without titles", + json_schema_extra={"items": {"type": "string", "enum": ["read", "write", "execute"]}}, + ) + titledMulti: list[str] = Field( + description="Multi-select with titled options", + json_schema_extra={ + "items": { + "anyOf": [ + {"const": "feature", "title": "New Feature"}, + {"const": "bug", "title": "Bug Fix"}, + {"const": "docs", "title": "Documentation"}, + ] + } + }, + ) + legacyEnum: str = Field( + description="Legacy enum with enumNames", + json_schema_extra={ + "enum": ["small", "medium", "large"], + "enumNames": ["Small Size", "Medium Size", "Large Size"], + }, + ) + + +@mcp.tool() +async def test_elicitation_sep1330_enums(ctx: Context) -> str: + """Tests elicitation with enum schema variations per SEP-1330""" + try: + result = await ctx.elicit( + message="Please select values using different enum schema types", schema=EnumSchemasTestSchema + ) + + if result.action == "accept": + content = result.data.model_dump_json() + else: + content = "{}" + + return f"Elicitation completed: action={result.action}, content={content}" + except Exception as e: + return f"Elicitation not supported or error: {str(e)}" + + +@mcp.tool() +def test_error_handling() -> str: + """Tests error response handling""" + raise RuntimeError("This tool intentionally returns an error for testing") + + +@mcp.tool() +async def test_reconnection(ctx: Context) -> str: + """Tests SSE polling by closing stream mid-call (SEP-1699)""" + await ctx.info("Before disconnect") + + await ctx.close_sse_stream() + + await asyncio.sleep(0.2) # Wait for client to reconnect + + await ctx.info("After reconnect") + return "Reconnection test completed" + + +# Resources +@mcp.resource("test://static-text") +def static_text_resource() -> str: + """A static text resource for testing""" + return "This is the content of the static text resource." + + +@mcp.resource("test://static-binary") +def static_binary_resource() -> bytes: + """A static binary resource (image) for testing""" + return base64.b64decode(TEST_IMAGE_BASE64) + + +@mcp.resource("test://template/{id}/data") +def template_resource(id: str) -> str: + """A resource template with parameter substitution""" + return json.dumps({"id": id, "templateTest": True, "data": f"Data for ID: {id}"}) + + +@mcp.resource("test://watched-resource") +def watched_resource() -> str: + """A resource that can be subscribed to for updates""" + return watched_resource_content + + +# Prompts +@mcp.prompt() +def test_simple_prompt() -> list[UserMessage]: + """A simple prompt without arguments""" + return [UserMessage(role="user", content=TextContent(type="text", text="This is a simple prompt for testing."))] + + +@mcp.prompt() +def test_prompt_with_arguments(arg1: str, arg2: str) -> list[UserMessage]: + """A prompt with required arguments""" + return [ + UserMessage( + role="user", content=TextContent(type="text", text=f"Prompt with arguments: arg1='{arg1}', arg2='{arg2}'") + ) + ] + + +@mcp.prompt() +def test_prompt_with_embedded_resource(resourceUri: str) -> list[UserMessage]: + """A prompt that includes an embedded resource""" + return [ + UserMessage( + role="user", + content=EmbeddedResource( + type="resource", + resource=TextResourceContents( + uri=resourceUri, + mime_type="text/plain", + text="Embedded resource content for testing.", + ), + ), + ), + UserMessage(role="user", content=TextContent(type="text", text="Please process the embedded resource above.")), + ] + + +@mcp.prompt() +def test_prompt_with_image() -> list[UserMessage]: + """A prompt that includes image content""" + return [ + UserMessage(role="user", content=ImageContent(type="image", data=TEST_IMAGE_BASE64, mime_type="image/png")), + UserMessage(role="user", content=TextContent(type="text", text="Please analyze the image above.")), + ] + + +# Custom request handlers +# TODO(felix): Add public APIs to MCPServer for subscribe_resource, unsubscribe_resource, +# and set_logging_level to avoid accessing protected _lowlevel_server attribute. +async def handle_set_logging_level(ctx: ServerRequestContext, params: SetLevelRequestParams) -> EmptyResult: + """Handle logging level changes""" + logger.info(f"Log level set to: {params.level}") + return EmptyResult() + + +async def handle_subscribe(ctx: ServerRequestContext, params: SubscribeRequestParams) -> EmptyResult: + """Handle resource subscription""" + resource_subscriptions.add(str(params.uri)) + logger.info(f"Subscribed to resource: {params.uri}") + return EmptyResult() + + +async def handle_unsubscribe(ctx: ServerRequestContext, params: UnsubscribeRequestParams) -> EmptyResult: + """Handle resource unsubscription""" + resource_subscriptions.discard(str(params.uri)) + logger.info(f"Unsubscribed from resource: {params.uri}") + return EmptyResult() + + +mcp._lowlevel_server._add_request_handler("logging/setLevel", handle_set_logging_level) # pyright: ignore[reportPrivateUsage] +mcp._lowlevel_server._add_request_handler("resources/subscribe", handle_subscribe) # pyright: ignore[reportPrivateUsage] +mcp._lowlevel_server._add_request_handler("resources/unsubscribe", handle_unsubscribe) # pyright: ignore[reportPrivateUsage] + + +@mcp.completion() +async def _handle_completion( + ref: PromptReference | ResourceTemplateReference, + argument: CompletionArgument, + context: CompletionContext | None, +) -> Completion: + """Handle completion requests""" + # Basic completion support - returns empty array for conformance + # Real implementations would provide contextual suggestions + return Completion(values=[], total=0, has_more=False) + + +# CLI +@click.command() +@click.option("--port", default=3001, help="Port to listen on for HTTP") +@click.option( + "--log-level", + default="INFO", + help="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)", +) +def main(port: int, log_level: str) -> int: + """Run the MCP Everything Server.""" + logging.basicConfig( + level=getattr(logging, log_level.upper()), + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + + logger.info(f"Starting MCP Everything Server on port {port}") + logger.info(f"Endpoint will be: http://localhost:{port}/mcp") + + mcp.run( + transport="streamable-http", + port=port, + event_store=event_store, + retry_interval=100, # 100ms retry interval for SSE polling + ) + + return 0 + + +if __name__ == "__main__": + main() diff --git a/examples/servers/everything-server/pyproject.toml b/examples/servers/everything-server/pyproject.toml index f68a9d282..f016e6f64 100644 --- a/examples/servers/everything-server/pyproject.toml +++ b/examples/servers/everything-server/pyproject.toml @@ -1,36 +1,36 @@ -[project] -name = "mcp-everything-server" -version = "0.1.0" -description = "Comprehensive MCP server implementing all protocol features for conformance testing" -readme = "README.md" -requires-python = ">=3.10" -authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] -keywords = ["mcp", "llm", "automation", "conformance", "testing"] -license = { text = "MIT" } -dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp", "starlette", "uvicorn"] - -[project.scripts] -mcp-everything-server = "mcp_everything_server.server:main" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["mcp_everything_server"] - -[tool.pyright] -include = ["mcp_everything_server"] -venvPath = "." -venv = ".venv" - -[tool.ruff.lint] -select = ["E", "F", "I"] -ignore = [] - -[tool.ruff] -line-length = 120 -target-version = "py310" - -[dependency-groups] -dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] +[project] +name = "mcp-everything-server" +version = "0.1.0" +description = "Comprehensive MCP server implementing all protocol features for conformance testing" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +keywords = ["mcp", "llm", "automation", "conformance", "testing"] +license = { text = "MIT" } +dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp", "starlette", "uvicorn"] + +[project.scripts] +mcp-everything-server = "mcp_everything_server.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_everything_server"] + +[tool.pyright] +include = ["mcp_everything_server"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/servers/simple-auth/README.md b/examples/servers/simple-auth/README.md index d4a10c43b..9f07e177d 100644 --- a/examples/servers/simple-auth/README.md +++ b/examples/servers/simple-auth/README.md @@ -1,135 +1,135 @@ -# MCP OAuth Authentication Demo - -This example demonstrates OAuth 2.0 authentication with the Model Context Protocol using **separate Authorization Server (AS) and Resource Server (RS)** to comply with the new RFC 9728 specification. - ---- - -## Running the Servers - -### Step 1: Start Authorization Server - -```bash -# Navigate to the simple-auth directory -cd examples/servers/simple-auth - -# Start Authorization Server on port 9000 -uv run mcp-simple-auth-as --port=9000 -``` - -**What it provides:** - -- OAuth 2.0 flows (registration, authorization, token exchange) -- Simple credential-based authentication (no external provider needed) -- Token introspection endpoint for Resource Servers (`/introspect`) - ---- - -### Step 2: Start Resource Server (MCP Server) - -```bash -# In another terminal, navigate to the simple-auth directory -cd examples/servers/simple-auth - -# Start Resource Server on port 8001, connected to Authorization Server -uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --transport=streamable-http - -# With RFC 8707 strict resource validation (recommended for production) -uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --transport=streamable-http --oauth-strict - -``` - -### Step 3: Test with Client - -```bash -cd examples/clients/simple-auth-client -# Start client with streamable HTTP -MCP_SERVER_PORT=8001 MCP_TRANSPORT_TYPE=streamable-http uv run mcp-simple-auth-client -``` - -## How It Works - -### RFC 9728 Discovery - -**Client → Resource Server:** - -```bash -curl http://localhost:8001/.well-known/oauth-protected-resource -``` - -```json -{ - "resource": "http://localhost:8001", - "authorization_servers": ["http://localhost:9000"] -} -``` - -**Client → Authorization Server:** - -```bash -curl http://localhost:9000/.well-known/oauth-authorization-server -``` - -```json -{ - "issuer": "http://localhost:9000", - "authorization_endpoint": "http://localhost:9000/authorize", - "token_endpoint": "http://localhost:9000/token" -} -``` - -## Legacy MCP Server as Authorization Server (Backwards Compatibility) - -For backwards compatibility with older MCP implementations, a legacy server is provided that acts as an Authorization Server (following the old spec where MCP servers could optionally provide OAuth): - -### Running the Legacy Server - -```bash -# Start legacy server on port 8000 (the default) -cd examples/servers/simple-auth -uv run mcp-simple-auth-legacy --port=8000 --transport=streamable-http -``` - -**Differences from the new architecture:** - -- **MCP server acts as AS:** The MCP server itself provides OAuth endpoints (old spec behavior) -- **No separate RS:** The server handles both authentication and MCP tools -- **Local token validation:** Tokens are validated internally without introspection -- **No RFC 9728 support:** Does not provide `/.well-known/oauth-protected-resource` -- **Direct OAuth discovery:** OAuth metadata is at the MCP server's URL - -### Testing with Legacy Server - -```bash -# Test with client (will automatically fall back to legacy discovery) -cd examples/clients/simple-auth-client -MCP_SERVER_PORT=8000 MCP_TRANSPORT_TYPE=streamable-http uv run mcp-simple-auth-client -``` - -The client will: - -1. Try RFC 9728 discovery at `/.well-known/oauth-protected-resource` (404 on legacy server) -2. Fall back to direct OAuth discovery at `/.well-known/oauth-authorization-server` -3. Complete authentication with the MCP server acting as its own AS - -This ensures existing MCP servers (which could optionally act as Authorization Servers under the old spec) continue to work while the ecosystem transitions to the new architecture where MCP servers are Resource Servers only. - -## Manual Testing - -### Test Discovery - -```bash -# Test Resource Server discovery endpoint (new architecture) -curl -v http://localhost:8001/.well-known/oauth-protected-resource - -# Test Authorization Server metadata -curl -v http://localhost:9000/.well-known/oauth-authorization-server -``` - -### Test Token Introspection - -```bash -# After getting a token through OAuth flow: -curl -X POST http://localhost:9000/introspect \ - -H "Content-Type: application/x-www-form-urlencoded" \ - -d "token=your_access_token" -``` +# MCP OAuth Authentication Demo + +This example demonstrates OAuth 2.0 authentication with the Model Context Protocol using **separate Authorization Server (AS) and Resource Server (RS)** to comply with the new RFC 9728 specification. + +--- + +## Running the Servers + +### Step 1: Start Authorization Server + +```bash +# Navigate to the simple-auth directory +cd examples/servers/simple-auth + +# Start Authorization Server on port 9000 +uv run mcp-simple-auth-as --port=9000 +``` + +**What it provides:** + +- OAuth 2.0 flows (registration, authorization, token exchange) +- Simple credential-based authentication (no external provider needed) +- Token introspection endpoint for Resource Servers (`/introspect`) + +--- + +### Step 2: Start Resource Server (MCP Server) + +```bash +# In another terminal, navigate to the simple-auth directory +cd examples/servers/simple-auth + +# Start Resource Server on port 8001, connected to Authorization Server +uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --transport=streamable-http + +# With RFC 8707 strict resource validation (recommended for production) +uv run mcp-simple-auth-rs --port=8001 --auth-server=http://localhost:9000 --transport=streamable-http --oauth-strict + +``` + +### Step 3: Test with Client + +```bash +cd examples/clients/simple-auth-client +# Start client with streamable HTTP +MCP_SERVER_PORT=8001 MCP_TRANSPORT_TYPE=streamable-http uv run mcp-simple-auth-client +``` + +## How It Works + +### RFC 9728 Discovery + +**Client → Resource Server:** + +```bash +curl http://localhost:8001/.well-known/oauth-protected-resource +``` + +```json +{ + "resource": "http://localhost:8001", + "authorization_servers": ["http://localhost:9000"] +} +``` + +**Client → Authorization Server:** + +```bash +curl http://localhost:9000/.well-known/oauth-authorization-server +``` + +```json +{ + "issuer": "http://localhost:9000", + "authorization_endpoint": "http://localhost:9000/authorize", + "token_endpoint": "http://localhost:9000/token" +} +``` + +## Legacy MCP Server as Authorization Server (Backwards Compatibility) + +For backwards compatibility with older MCP implementations, a legacy server is provided that acts as an Authorization Server (following the old spec where MCP servers could optionally provide OAuth): + +### Running the Legacy Server + +```bash +# Start legacy server on port 8000 (the default) +cd examples/servers/simple-auth +uv run mcp-simple-auth-legacy --port=8000 --transport=streamable-http +``` + +**Differences from the new architecture:** + +- **MCP server acts as AS:** The MCP server itself provides OAuth endpoints (old spec behavior) +- **No separate RS:** The server handles both authentication and MCP tools +- **Local token validation:** Tokens are validated internally without introspection +- **No RFC 9728 support:** Does not provide `/.well-known/oauth-protected-resource` +- **Direct OAuth discovery:** OAuth metadata is at the MCP server's URL + +### Testing with Legacy Server + +```bash +# Test with client (will automatically fall back to legacy discovery) +cd examples/clients/simple-auth-client +MCP_SERVER_PORT=8000 MCP_TRANSPORT_TYPE=streamable-http uv run mcp-simple-auth-client +``` + +The client will: + +1. Try RFC 9728 discovery at `/.well-known/oauth-protected-resource` (404 on legacy server) +2. Fall back to direct OAuth discovery at `/.well-known/oauth-authorization-server` +3. Complete authentication with the MCP server acting as its own AS + +This ensures existing MCP servers (which could optionally act as Authorization Servers under the old spec) continue to work while the ecosystem transitions to the new architecture where MCP servers are Resource Servers only. + +## Manual Testing + +### Test Discovery + +```bash +# Test Resource Server discovery endpoint (new architecture) +curl -v http://localhost:8001/.well-known/oauth-protected-resource + +# Test Authorization Server metadata +curl -v http://localhost:9000/.well-known/oauth-authorization-server +``` + +### Test Token Introspection + +```bash +# After getting a token through OAuth flow: +curl -X POST http://localhost:9000/introspect \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "token=your_access_token" +``` diff --git a/examples/servers/simple-auth/mcp_simple_auth/__init__.py b/examples/servers/simple-auth/mcp_simple_auth/__init__.py index 3e12b3183..10c76efc1 100644 --- a/examples/servers/simple-auth/mcp_simple_auth/__init__.py +++ b/examples/servers/simple-auth/mcp_simple_auth/__init__.py @@ -1 +1 @@ -"""Simple MCP server with GitHub OAuth authentication.""" +"""Simple MCP server with GitHub OAuth authentication.""" diff --git a/examples/servers/simple-auth/mcp_simple_auth/__main__.py b/examples/servers/simple-auth/mcp_simple_auth/__main__.py index 2365ff5a1..2306c5ecb 100644 --- a/examples/servers/simple-auth/mcp_simple_auth/__main__.py +++ b/examples/servers/simple-auth/mcp_simple_auth/__main__.py @@ -1,7 +1,7 @@ -"""Main entry point for simple MCP server with GitHub OAuth authentication.""" - -import sys - -from mcp_simple_auth.server import main - -sys.exit(main()) # type: ignore[call-arg] +"""Main entry point for simple MCP server with GitHub OAuth authentication.""" + +import sys + +from mcp_simple_auth.server import main + +sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/servers/simple-auth/mcp_simple_auth/auth_server.py b/examples/servers/simple-auth/mcp_simple_auth/auth_server.py index 9d13fffe4..a1531606c 100644 --- a/examples/servers/simple-auth/mcp_simple_auth/auth_server.py +++ b/examples/servers/simple-auth/mcp_simple_auth/auth_server.py @@ -1,183 +1,183 @@ -"""Authorization Server for MCP Split Demo. - -This server handles OAuth flows, client registration, and token issuance. -Can be replaced with enterprise authorization servers like Auth0, Entra ID, etc. - -NOTE: this is a simplified example for demonstration purposes. -This is not a production-ready implementation. - -""" - -import asyncio -import logging -import time - -import click -from pydantic import AnyHttpUrl, BaseModel -from starlette.applications import Starlette -from starlette.exceptions import HTTPException -from starlette.requests import Request -from starlette.responses import JSONResponse, Response -from starlette.routing import Route -from uvicorn import Config, Server - -from mcp.server.auth.routes import cors_middleware, create_auth_routes -from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions - -from .simple_auth_provider import SimpleAuthSettings, SimpleOAuthProvider - -logger = logging.getLogger(__name__) - - -class AuthServerSettings(BaseModel): - """Settings for the Authorization Server.""" - - # Server settings - host: str = "localhost" - port: int = 9000 - server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000") - auth_callback_path: str = "http://localhost:9000/login/callback" - - -class SimpleAuthProvider(SimpleOAuthProvider): - """Authorization Server provider with simple demo authentication. - - This provider: - 1. Issues MCP tokens after simple credential authentication - 2. Stores token state for introspection by Resource Servers - """ - - def __init__(self, auth_settings: SimpleAuthSettings, auth_callback_path: str, server_url: str): - super().__init__(auth_settings, auth_callback_path, server_url) - - -def create_authorization_server(server_settings: AuthServerSettings, auth_settings: SimpleAuthSettings) -> Starlette: - """Create the Authorization Server application.""" - oauth_provider = SimpleAuthProvider( - auth_settings, server_settings.auth_callback_path, str(server_settings.server_url) - ) - - mcp_auth_settings = AuthSettings( - issuer_url=server_settings.server_url, - client_registration_options=ClientRegistrationOptions( - enabled=True, - valid_scopes=[auth_settings.mcp_scope], - default_scopes=[auth_settings.mcp_scope], - ), - required_scopes=[auth_settings.mcp_scope], - resource_server_url=None, - ) - - # Create OAuth routes - routes = create_auth_routes( - provider=oauth_provider, - issuer_url=mcp_auth_settings.issuer_url, - service_documentation_url=mcp_auth_settings.service_documentation_url, - client_registration_options=mcp_auth_settings.client_registration_options, - revocation_options=mcp_auth_settings.revocation_options, - ) - - # Add login page route (GET) - async def login_page_handler(request: Request) -> Response: - """Show login form.""" - state = request.query_params.get("state") - if not state: - raise HTTPException(400, "Missing state parameter") - return await oauth_provider.get_login_page(state) - - routes.append(Route("/login", endpoint=login_page_handler, methods=["GET"])) - - # Add login callback route (POST) - async def login_callback_handler(request: Request) -> Response: - """Handle simple authentication callback.""" - return await oauth_provider.handle_login_callback(request) - - routes.append(Route("/login/callback", endpoint=login_callback_handler, methods=["POST"])) - - # Add token introspection endpoint (RFC 7662) for Resource Servers - async def introspect_handler(request: Request) -> Response: - """Token introspection endpoint for Resource Servers. - - Resource Servers call this endpoint to validate tokens without - needing direct access to token storage. - """ - form = await request.form() - token = form.get("token") - if not token or not isinstance(token, str): - return JSONResponse({"active": False}, status_code=400) - - # Look up token in provider - access_token = await oauth_provider.load_access_token(token) - if not access_token: - return JSONResponse({"active": False}) - - return JSONResponse( - { - "active": True, - "client_id": access_token.client_id, - "scope": " ".join(access_token.scopes), - "exp": access_token.expires_at, - "iat": int(time.time()), - "token_type": "Bearer", - "aud": access_token.resource, # RFC 8707 audience claim - } - ) - - routes.append( - Route( - "/introspect", - endpoint=cors_middleware(introspect_handler, ["POST", "OPTIONS"]), - methods=["POST", "OPTIONS"], - ) - ) - - return Starlette(routes=routes) - - -async def run_server(server_settings: AuthServerSettings, auth_settings: SimpleAuthSettings): - """Run the Authorization Server.""" - auth_server = create_authorization_server(server_settings, auth_settings) - - config = Config( - auth_server, - host=server_settings.host, - port=server_settings.port, - log_level="info", - ) - server = Server(config) - - logger.info(f"🚀 MCP Authorization Server running on {server_settings.server_url}") - - await server.serve() - - -@click.command() -@click.option("--port", default=9000, help="Port to listen on") -def main(port: int) -> int: - """Run the MCP Authorization Server. - - This server handles OAuth flows and can be used by multiple Resource Servers. - - Uses simple hardcoded credentials for demo purposes. - """ - logging.basicConfig(level=logging.INFO) - - # Load simple auth settings - auth_settings = SimpleAuthSettings() - - # Create server settings - host = "localhost" - server_url = f"http://{host}:{port}" - server_settings = AuthServerSettings( - host=host, - port=port, - server_url=AnyHttpUrl(server_url), - auth_callback_path=f"{server_url}/login", - ) - - asyncio.run(run_server(server_settings, auth_settings)) - return 0 - - -if __name__ == "__main__": - main() # type: ignore[call-arg] +"""Authorization Server for MCP Split Demo. + +This server handles OAuth flows, client registration, and token issuance. +Can be replaced with enterprise authorization servers like Auth0, Entra ID, etc. + +NOTE: this is a simplified example for demonstration purposes. +This is not a production-ready implementation. + +""" + +import asyncio +import logging +import time + +import click +from pydantic import AnyHttpUrl, BaseModel +from starlette.applications import Starlette +from starlette.exceptions import HTTPException +from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from starlette.routing import Route +from uvicorn import Config, Server + +from mcp.server.auth.routes import cors_middleware, create_auth_routes +from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions + +from .simple_auth_provider import SimpleAuthSettings, SimpleOAuthProvider + +logger = logging.getLogger(__name__) + + +class AuthServerSettings(BaseModel): + """Settings for the Authorization Server.""" + + # Server settings + host: str = "localhost" + port: int = 9000 + server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000") + auth_callback_path: str = "http://localhost:9000/login/callback" + + +class SimpleAuthProvider(SimpleOAuthProvider): + """Authorization Server provider with simple demo authentication. + + This provider: + 1. Issues MCP tokens after simple credential authentication + 2. Stores token state for introspection by Resource Servers + """ + + def __init__(self, auth_settings: SimpleAuthSettings, auth_callback_path: str, server_url: str): + super().__init__(auth_settings, auth_callback_path, server_url) + + +def create_authorization_server(server_settings: AuthServerSettings, auth_settings: SimpleAuthSettings) -> Starlette: + """Create the Authorization Server application.""" + oauth_provider = SimpleAuthProvider( + auth_settings, server_settings.auth_callback_path, str(server_settings.server_url) + ) + + mcp_auth_settings = AuthSettings( + issuer_url=server_settings.server_url, + client_registration_options=ClientRegistrationOptions( + enabled=True, + valid_scopes=[auth_settings.mcp_scope], + default_scopes=[auth_settings.mcp_scope], + ), + required_scopes=[auth_settings.mcp_scope], + resource_server_url=None, + ) + + # Create OAuth routes + routes = create_auth_routes( + provider=oauth_provider, + issuer_url=mcp_auth_settings.issuer_url, + service_documentation_url=mcp_auth_settings.service_documentation_url, + client_registration_options=mcp_auth_settings.client_registration_options, + revocation_options=mcp_auth_settings.revocation_options, + ) + + # Add login page route (GET) + async def login_page_handler(request: Request) -> Response: + """Show login form.""" + state = request.query_params.get("state") + if not state: + raise HTTPException(400, "Missing state parameter") + return await oauth_provider.get_login_page(state) + + routes.append(Route("/login", endpoint=login_page_handler, methods=["GET"])) + + # Add login callback route (POST) + async def login_callback_handler(request: Request) -> Response: + """Handle simple authentication callback.""" + return await oauth_provider.handle_login_callback(request) + + routes.append(Route("/login/callback", endpoint=login_callback_handler, methods=["POST"])) + + # Add token introspection endpoint (RFC 7662) for Resource Servers + async def introspect_handler(request: Request) -> Response: + """Token introspection endpoint for Resource Servers. + + Resource Servers call this endpoint to validate tokens without + needing direct access to token storage. + """ + form = await request.form() + token = form.get("token") + if not token or not isinstance(token, str): + return JSONResponse({"active": False}, status_code=400) + + # Look up token in provider + access_token = await oauth_provider.load_access_token(token) + if not access_token: + return JSONResponse({"active": False}) + + return JSONResponse( + { + "active": True, + "client_id": access_token.client_id, + "scope": " ".join(access_token.scopes), + "exp": access_token.expires_at, + "iat": int(time.time()), + "token_type": "Bearer", + "aud": access_token.resource, # RFC 8707 audience claim + } + ) + + routes.append( + Route( + "/introspect", + endpoint=cors_middleware(introspect_handler, ["POST", "OPTIONS"]), + methods=["POST", "OPTIONS"], + ) + ) + + return Starlette(routes=routes) + + +async def run_server(server_settings: AuthServerSettings, auth_settings: SimpleAuthSettings): + """Run the Authorization Server.""" + auth_server = create_authorization_server(server_settings, auth_settings) + + config = Config( + auth_server, + host=server_settings.host, + port=server_settings.port, + log_level="info", + ) + server = Server(config) + + logger.info(f"🚀 MCP Authorization Server running on {server_settings.server_url}") + + await server.serve() + + +@click.command() +@click.option("--port", default=9000, help="Port to listen on") +def main(port: int) -> int: + """Run the MCP Authorization Server. + + This server handles OAuth flows and can be used by multiple Resource Servers. + + Uses simple hardcoded credentials for demo purposes. + """ + logging.basicConfig(level=logging.INFO) + + # Load simple auth settings + auth_settings = SimpleAuthSettings() + + # Create server settings + host = "localhost" + server_url = f"http://{host}:{port}" + server_settings = AuthServerSettings( + host=host, + port=port, + server_url=AnyHttpUrl(server_url), + auth_callback_path=f"{server_url}/login", + ) + + asyncio.run(run_server(server_settings, auth_settings)) + return 0 + + +if __name__ == "__main__": + main() # type: ignore[call-arg] diff --git a/examples/servers/simple-auth/mcp_simple_auth/legacy_as_server.py b/examples/servers/simple-auth/mcp_simple_auth/legacy_as_server.py index ab7773b5b..e8f4ac4c0 100644 --- a/examples/servers/simple-auth/mcp_simple_auth/legacy_as_server.py +++ b/examples/servers/simple-auth/mcp_simple_auth/legacy_as_server.py @@ -1,137 +1,137 @@ -"""Legacy Combined Authorization Server + Resource Server for MCP. - -This server implements the old spec where MCP servers could act as both AS and RS. -Used for backwards compatibility testing with the new split AS/RS architecture. - -NOTE: this is a simplified example for demonstration purposes. -This is not a production-ready implementation. - -""" - -import datetime -import logging -from typing import Any, Literal - -import click -from pydantic import AnyHttpUrl, BaseModel -from starlette.exceptions import HTTPException -from starlette.requests import Request -from starlette.responses import Response - -from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions -from mcp.server.mcpserver.server import MCPServer - -from .simple_auth_provider import SimpleAuthSettings, SimpleOAuthProvider - -logger = logging.getLogger(__name__) - - -class ServerSettings(BaseModel): - """Settings for the simple auth MCP server.""" - - # Server settings - host: str = "localhost" - port: int = 8000 - server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8000") - auth_callback_path: str = "http://localhost:8000/login/callback" - - -class LegacySimpleOAuthProvider(SimpleOAuthProvider): - """Simple OAuth provider for legacy MCP server.""" - - def __init__(self, auth_settings: SimpleAuthSettings, auth_callback_path: str, server_url: str): - super().__init__(auth_settings, auth_callback_path, server_url) - - -def create_simple_mcp_server(server_settings: ServerSettings, auth_settings: SimpleAuthSettings) -> MCPServer: - """Create a simple MCPServer server with simple authentication.""" - oauth_provider = LegacySimpleOAuthProvider( - auth_settings, server_settings.auth_callback_path, str(server_settings.server_url) - ) - - mcp_auth_settings = AuthSettings( - issuer_url=server_settings.server_url, - client_registration_options=ClientRegistrationOptions( - enabled=True, - valid_scopes=[auth_settings.mcp_scope], - default_scopes=[auth_settings.mcp_scope], - ), - required_scopes=[auth_settings.mcp_scope], - # No resource_server_url parameter in legacy mode - resource_server_url=None, - ) - - app = MCPServer( - name="Simple Auth MCP Server", - instructions="A simple MCP server with simple credential authentication", - auth_server_provider=oauth_provider, - debug=True, - auth=mcp_auth_settings, - ) - # Store server settings for later use in run() - app._server_settings = server_settings # type: ignore[attr-defined] - - @app.custom_route("/login", methods=["GET"]) - async def login_page_handler(request: Request) -> Response: - """Show login form.""" - state = request.query_params.get("state") - if not state: - raise HTTPException(400, "Missing state parameter") - return await oauth_provider.get_login_page(state) - - @app.custom_route("/login/callback", methods=["POST"]) - async def login_callback_handler(request: Request) -> Response: - """Handle simple authentication callback.""" - return await oauth_provider.handle_login_callback(request) - - @app.tool() - async def get_time() -> dict[str, Any]: - """Get the current server time. - - This tool demonstrates that system information can be protected - by OAuth authentication. User must be authenticated to access it. - """ - - now = datetime.datetime.now() - - return { - "current_time": now.isoformat(), - "timezone": "UTC", # Simplified for demo - "timestamp": now.timestamp(), - "formatted": now.strftime("%Y-%m-%d %H:%M:%S"), - } - - return app - - -@click.command() -@click.option("--port", default=8000, help="Port to listen on") -@click.option( - "--transport", - default="streamable-http", - type=click.Choice(["sse", "streamable-http"]), - help="Transport protocol to use ('sse' or 'streamable-http')", -) -def main(port: int, transport: Literal["sse", "streamable-http"]) -> int: - """Run the simple auth MCP server.""" - logging.basicConfig(level=logging.INFO) - - auth_settings = SimpleAuthSettings() - # Create server settings - host = "localhost" - server_url = f"http://{host}:{port}" - server_settings = ServerSettings( - host=host, - port=port, - server_url=AnyHttpUrl(server_url), - auth_callback_path=f"{server_url}/login", - ) - - mcp_server = create_simple_mcp_server(server_settings, auth_settings) - logger.info(f"🚀 MCP Legacy Server running on {server_url}") - mcp_server.run(transport=transport, host=host, port=port) - return 0 - - -if __name__ == "__main__": - main() # type: ignore[call-arg] +"""Legacy Combined Authorization Server + Resource Server for MCP. + +This server implements the old spec where MCP servers could act as both AS and RS. +Used for backwards compatibility testing with the new split AS/RS architecture. + +NOTE: this is a simplified example for demonstration purposes. +This is not a production-ready implementation. + +""" + +import datetime +import logging +from typing import Any, Literal + +import click +from pydantic import AnyHttpUrl, BaseModel +from starlette.exceptions import HTTPException +from starlette.requests import Request +from starlette.responses import Response + +from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions +from mcp.server.mcpserver.server import MCPServer + +from .simple_auth_provider import SimpleAuthSettings, SimpleOAuthProvider + +logger = logging.getLogger(__name__) + + +class ServerSettings(BaseModel): + """Settings for the simple auth MCP server.""" + + # Server settings + host: str = "localhost" + port: int = 8000 + server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8000") + auth_callback_path: str = "http://localhost:8000/login/callback" + + +class LegacySimpleOAuthProvider(SimpleOAuthProvider): + """Simple OAuth provider for legacy MCP server.""" + + def __init__(self, auth_settings: SimpleAuthSettings, auth_callback_path: str, server_url: str): + super().__init__(auth_settings, auth_callback_path, server_url) + + +def create_simple_mcp_server(server_settings: ServerSettings, auth_settings: SimpleAuthSettings) -> MCPServer: + """Create a simple MCPServer server with simple authentication.""" + oauth_provider = LegacySimpleOAuthProvider( + auth_settings, server_settings.auth_callback_path, str(server_settings.server_url) + ) + + mcp_auth_settings = AuthSettings( + issuer_url=server_settings.server_url, + client_registration_options=ClientRegistrationOptions( + enabled=True, + valid_scopes=[auth_settings.mcp_scope], + default_scopes=[auth_settings.mcp_scope], + ), + required_scopes=[auth_settings.mcp_scope], + # No resource_server_url parameter in legacy mode + resource_server_url=None, + ) + + app = MCPServer( + name="Simple Auth MCP Server", + instructions="A simple MCP server with simple credential authentication", + auth_server_provider=oauth_provider, + debug=True, + auth=mcp_auth_settings, + ) + # Store server settings for later use in run() + app._server_settings = server_settings # type: ignore[attr-defined] + + @app.custom_route("/login", methods=["GET"]) + async def login_page_handler(request: Request) -> Response: + """Show login form.""" + state = request.query_params.get("state") + if not state: + raise HTTPException(400, "Missing state parameter") + return await oauth_provider.get_login_page(state) + + @app.custom_route("/login/callback", methods=["POST"]) + async def login_callback_handler(request: Request) -> Response: + """Handle simple authentication callback.""" + return await oauth_provider.handle_login_callback(request) + + @app.tool() + async def get_time() -> dict[str, Any]: + """Get the current server time. + + This tool demonstrates that system information can be protected + by OAuth authentication. User must be authenticated to access it. + """ + + now = datetime.datetime.now() + + return { + "current_time": now.isoformat(), + "timezone": "UTC", # Simplified for demo + "timestamp": now.timestamp(), + "formatted": now.strftime("%Y-%m-%d %H:%M:%S"), + } + + return app + + +@click.command() +@click.option("--port", default=8000, help="Port to listen on") +@click.option( + "--transport", + default="streamable-http", + type=click.Choice(["sse", "streamable-http"]), + help="Transport protocol to use ('sse' or 'streamable-http')", +) +def main(port: int, transport: Literal["sse", "streamable-http"]) -> int: + """Run the simple auth MCP server.""" + logging.basicConfig(level=logging.INFO) + + auth_settings = SimpleAuthSettings() + # Create server settings + host = "localhost" + server_url = f"http://{host}:{port}" + server_settings = ServerSettings( + host=host, + port=port, + server_url=AnyHttpUrl(server_url), + auth_callback_path=f"{server_url}/login", + ) + + mcp_server = create_simple_mcp_server(server_settings, auth_settings) + logger.info(f"🚀 MCP Legacy Server running on {server_url}") + mcp_server.run(transport=transport, host=host, port=port) + return 0 + + +if __name__ == "__main__": + main() # type: ignore[call-arg] diff --git a/examples/servers/simple-auth/mcp_simple_auth/server.py b/examples/servers/simple-auth/mcp_simple_auth/server.py index 0320871b1..6fdd8e401 100644 --- a/examples/servers/simple-auth/mcp_simple_auth/server.py +++ b/examples/servers/simple-auth/mcp_simple_auth/server.py @@ -1,161 +1,161 @@ -"""MCP Resource Server with Token Introspection. - -This server validates tokens via Authorization Server introspection and serves MCP resources. -Demonstrates RFC 9728 Protected Resource Metadata for AS/RS separation. - -NOTE: this is a simplified example for demonstration purposes. -This is not a production-ready implementation. -""" - -import datetime -import logging -from typing import Any, Literal - -import click -from pydantic import AnyHttpUrl -from pydantic_settings import BaseSettings, SettingsConfigDict - -from mcp.server.auth.settings import AuthSettings -from mcp.server.mcpserver.server import MCPServer - -from .token_verifier import IntrospectionTokenVerifier - -logger = logging.getLogger(__name__) - - -class ResourceServerSettings(BaseSettings): - """Settings for the MCP Resource Server.""" - - model_config = SettingsConfigDict(env_prefix="MCP_RESOURCE_") - - # Server settings - host: str = "localhost" - port: int = 8001 - server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8001/mcp") - - # Authorization Server settings - auth_server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000") - auth_server_introspection_endpoint: str = "http://localhost:9000/introspect" - # No user endpoint needed - we get user data from token introspection - - # MCP settings - mcp_scope: str = "user" - - # RFC 8707 resource validation - oauth_strict: bool = False - - -def create_resource_server(settings: ResourceServerSettings) -> MCPServer: - """Create MCP Resource Server with token introspection. - - This server: - 1. Provides protected resource metadata (RFC 9728) - 2. Validates tokens via Authorization Server introspection - 3. Serves MCP tools and resources - """ - # Create token verifier for introspection with RFC 8707 resource validation - token_verifier = IntrospectionTokenVerifier( - introspection_endpoint=settings.auth_server_introspection_endpoint, - server_url=str(settings.server_url), - validate_resource=settings.oauth_strict, # Only validate when --oauth-strict is set - ) - - # Create MCPServer server as a Resource Server - app = MCPServer( - name="MCP Resource Server", - instructions="Resource Server that validates tokens via Authorization Server introspection", - debug=True, - # Auth configuration for RS mode - token_verifier=token_verifier, - auth=AuthSettings( - issuer_url=settings.auth_server_url, - required_scopes=[settings.mcp_scope], - resource_server_url=settings.server_url, - ), - ) - # Store settings for later use in run() - app._resource_server_settings = settings # type: ignore[attr-defined] - - @app.tool() - async def get_time() -> dict[str, Any]: - """Get the current server time. - - This tool demonstrates that system information can be protected - by OAuth authentication. User must be authenticated to access it. - """ - - now = datetime.datetime.now() - - return { - "current_time": now.isoformat(), - "timezone": "UTC", # Simplified for demo - "timestamp": now.timestamp(), - "formatted": now.strftime("%Y-%m-%d %H:%M:%S"), - } - - return app - - -@click.command() -@click.option("--port", default=8001, help="Port to listen on") -@click.option("--auth-server", default="http://localhost:9000", help="Authorization Server URL") -@click.option( - "--transport", - default="streamable-http", - type=click.Choice(["sse", "streamable-http"]), - help="Transport protocol to use ('sse' or 'streamable-http')", -) -@click.option( - "--oauth-strict", - is_flag=True, - help="Enable RFC 8707 resource validation", -) -def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http"], oauth_strict: bool) -> int: - """Run the MCP Resource Server. - - This server: - - Provides RFC 9728 Protected Resource Metadata - - Validates tokens via Authorization Server introspection - - Serves MCP tools requiring authentication - - Must be used with a running Authorization Server. - """ - logging.basicConfig(level=logging.INFO) - - try: - # Parse auth server URL - auth_server_url = AnyHttpUrl(auth_server) - - # Create settings - host = "localhost" - server_url = f"http://{host}:{port}/mcp" - settings = ResourceServerSettings( - host=host, - port=port, - server_url=AnyHttpUrl(server_url), - auth_server_url=auth_server_url, - auth_server_introspection_endpoint=f"{auth_server}/introspect", - oauth_strict=oauth_strict, - ) - except ValueError as e: - logger.error(f"Configuration error: {e}") - logger.error("Make sure to provide a valid Authorization Server URL") - return 1 - - try: - mcp_server = create_resource_server(settings) - - logger.info(f"🚀 MCP Resource Server running on {settings.server_url}") - logger.info(f"🔑 Using Authorization Server: {settings.auth_server_url}") - - # Run the server - this should block and keep running - mcp_server.run(transport=transport, host=host, port=port) - logger.info("Server stopped") - return 0 - except Exception: - logger.exception("Server error") - return 1 - - -if __name__ == "__main__": - main() # type: ignore[call-arg] +"""MCP Resource Server with Token Introspection. + +This server validates tokens via Authorization Server introspection and serves MCP resources. +Demonstrates RFC 9728 Protected Resource Metadata for AS/RS separation. + +NOTE: this is a simplified example for demonstration purposes. +This is not a production-ready implementation. +""" + +import datetime +import logging +from typing import Any, Literal + +import click +from pydantic import AnyHttpUrl +from pydantic_settings import BaseSettings, SettingsConfigDict + +from mcp.server.auth.settings import AuthSettings +from mcp.server.mcpserver.server import MCPServer + +from .token_verifier import IntrospectionTokenVerifier + +logger = logging.getLogger(__name__) + + +class ResourceServerSettings(BaseSettings): + """Settings for the MCP Resource Server.""" + + model_config = SettingsConfigDict(env_prefix="MCP_RESOURCE_") + + # Server settings + host: str = "localhost" + port: int = 8001 + server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:8001/mcp") + + # Authorization Server settings + auth_server_url: AnyHttpUrl = AnyHttpUrl("http://localhost:9000") + auth_server_introspection_endpoint: str = "http://localhost:9000/introspect" + # No user endpoint needed - we get user data from token introspection + + # MCP settings + mcp_scope: str = "user" + + # RFC 8707 resource validation + oauth_strict: bool = False + + +def create_resource_server(settings: ResourceServerSettings) -> MCPServer: + """Create MCP Resource Server with token introspection. + + This server: + 1. Provides protected resource metadata (RFC 9728) + 2. Validates tokens via Authorization Server introspection + 3. Serves MCP tools and resources + """ + # Create token verifier for introspection with RFC 8707 resource validation + token_verifier = IntrospectionTokenVerifier( + introspection_endpoint=settings.auth_server_introspection_endpoint, + server_url=str(settings.server_url), + validate_resource=settings.oauth_strict, # Only validate when --oauth-strict is set + ) + + # Create MCPServer server as a Resource Server + app = MCPServer( + name="MCP Resource Server", + instructions="Resource Server that validates tokens via Authorization Server introspection", + debug=True, + # Auth configuration for RS mode + token_verifier=token_verifier, + auth=AuthSettings( + issuer_url=settings.auth_server_url, + required_scopes=[settings.mcp_scope], + resource_server_url=settings.server_url, + ), + ) + # Store settings for later use in run() + app._resource_server_settings = settings # type: ignore[attr-defined] + + @app.tool() + async def get_time() -> dict[str, Any]: + """Get the current server time. + + This tool demonstrates that system information can be protected + by OAuth authentication. User must be authenticated to access it. + """ + + now = datetime.datetime.now() + + return { + "current_time": now.isoformat(), + "timezone": "UTC", # Simplified for demo + "timestamp": now.timestamp(), + "formatted": now.strftime("%Y-%m-%d %H:%M:%S"), + } + + return app + + +@click.command() +@click.option("--port", default=8001, help="Port to listen on") +@click.option("--auth-server", default="http://localhost:9000", help="Authorization Server URL") +@click.option( + "--transport", + default="streamable-http", + type=click.Choice(["sse", "streamable-http"]), + help="Transport protocol to use ('sse' or 'streamable-http')", +) +@click.option( + "--oauth-strict", + is_flag=True, + help="Enable RFC 8707 resource validation", +) +def main(port: int, auth_server: str, transport: Literal["sse", "streamable-http"], oauth_strict: bool) -> int: + """Run the MCP Resource Server. + + This server: + - Provides RFC 9728 Protected Resource Metadata + - Validates tokens via Authorization Server introspection + - Serves MCP tools requiring authentication + + Must be used with a running Authorization Server. + """ + logging.basicConfig(level=logging.INFO) + + try: + # Parse auth server URL + auth_server_url = AnyHttpUrl(auth_server) + + # Create settings + host = "localhost" + server_url = f"http://{host}:{port}/mcp" + settings = ResourceServerSettings( + host=host, + port=port, + server_url=AnyHttpUrl(server_url), + auth_server_url=auth_server_url, + auth_server_introspection_endpoint=f"{auth_server}/introspect", + oauth_strict=oauth_strict, + ) + except ValueError as e: + logger.error(f"Configuration error: {e}") + logger.error("Make sure to provide a valid Authorization Server URL") + return 1 + + try: + mcp_server = create_resource_server(settings) + + logger.info(f"🚀 MCP Resource Server running on {settings.server_url}") + logger.info(f"🔑 Using Authorization Server: {settings.auth_server_url}") + + # Run the server - this should block and keep running + mcp_server.run(transport=transport, host=host, port=port) + logger.info("Server stopped") + return 0 + except Exception: + logger.exception("Server error") + return 1 + + +if __name__ == "__main__": + main() # type: ignore[call-arg] diff --git a/examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py b/examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py index 3a3895cc5..25ac7fe75 100644 --- a/examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py +++ b/examples/servers/simple-auth/mcp_simple_auth/simple_auth_provider.py @@ -1,270 +1,270 @@ -"""Simple OAuth provider for MCP servers. - -This module contains a basic OAuth implementation using hardcoded user credentials -for demonstration purposes. No external authentication provider is required. - -NOTE: this is a simplified example for demonstration purposes. -This is not a production-ready implementation. - -""" - -import secrets -import time -from typing import Any - -from pydantic import AnyHttpUrl -from pydantic_settings import BaseSettings, SettingsConfigDict -from starlette.exceptions import HTTPException -from starlette.requests import Request -from starlette.responses import HTMLResponse, RedirectResponse, Response - -from mcp.server.auth.provider import ( - AccessToken, - AuthorizationCode, - AuthorizationParams, - OAuthAuthorizationServerProvider, - RefreshToken, - construct_redirect_uri, -) -from mcp.shared.auth import OAuthClientInformationFull, OAuthToken - - -class SimpleAuthSettings(BaseSettings): - """Simple OAuth settings for demo purposes.""" - - model_config = SettingsConfigDict(env_prefix="MCP_") - - # Demo user credentials - demo_username: str = "demo_user" - demo_password: str = "demo_password" - - # MCP OAuth scope - mcp_scope: str = "user" - - -class SimpleOAuthProvider(OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]): - """Simple OAuth provider for demo purposes. - - This provider handles the OAuth flow by: - 1. Providing a simple login form for demo credentials - 2. Issuing MCP tokens after successful authentication - 3. Maintaining token state for introspection - """ - - def __init__(self, settings: SimpleAuthSettings, auth_callback_url: str, server_url: str): - self.settings = settings - self.auth_callback_url = auth_callback_url - self.server_url = server_url - self.clients: dict[str, OAuthClientInformationFull] = {} - self.auth_codes: dict[str, AuthorizationCode] = {} - self.tokens: dict[str, AccessToken] = {} - self.state_mapping: dict[str, dict[str, str | None]] = {} - # Store authenticated user information - self.user_data: dict[str, dict[str, Any]] = {} - - async def get_client(self, client_id: str) -> OAuthClientInformationFull | None: - """Get OAuth client information.""" - return self.clients.get(client_id) - - async def register_client(self, client_info: OAuthClientInformationFull): - """Register a new OAuth client.""" - if not client_info.client_id: - raise ValueError("No client_id provided") - self.clients[client_info.client_id] = client_info - - async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str: - """Generate an authorization URL for simple login flow.""" - state = params.state or secrets.token_hex(16) - - # Store state mapping for callback - self.state_mapping[state] = { - "redirect_uri": str(params.redirect_uri), - "code_challenge": params.code_challenge, - "redirect_uri_provided_explicitly": str(params.redirect_uri_provided_explicitly), - "client_id": client.client_id, - "resource": params.resource, # RFC 8707 - } - - # Build simple login URL that points to login page - auth_url = f"{self.auth_callback_url}?state={state}&client_id={client.client_id}" - - return auth_url - - async def get_login_page(self, state: str) -> HTMLResponse: - """Generate login page HTML for the given state.""" - if not state: - raise HTTPException(400, "Missing state parameter") - - # Create simple login form HTML - html_content = f""" - <!DOCTYPE html> - <html> - <head> - <title>MCP Demo Authentication - - - -

MCP Demo Authentication

-

This is a simplified authentication demo. Use the demo credentials below:

-

Username: demo_user
- Password: demo_password

- -
- -
- - -
-
- - -
- -
- - - """ - - return HTMLResponse(content=html_content) - - async def handle_login_callback(self, request: Request) -> Response: - """Handle login form submission callback.""" - form = await request.form() - username = form.get("username") - password = form.get("password") - state = form.get("state") - - if not username or not password or not state: - raise HTTPException(400, "Missing username, password, or state parameter") - - # Ensure we have strings, not UploadFile objects - if not isinstance(username, str) or not isinstance(password, str) or not isinstance(state, str): - raise HTTPException(400, "Invalid parameter types") - - redirect_uri = await self.handle_simple_callback(username, password, state) - return RedirectResponse(url=redirect_uri, status_code=302) - - async def handle_simple_callback(self, username: str, password: str, state: str) -> str: - """Handle simple authentication callback and return redirect URI.""" - state_data = self.state_mapping.get(state) - if not state_data: - raise HTTPException(400, "Invalid state parameter") - - redirect_uri = state_data["redirect_uri"] - code_challenge = state_data["code_challenge"] - redirect_uri_provided_explicitly = state_data["redirect_uri_provided_explicitly"] == "True" - client_id = state_data["client_id"] - resource = state_data.get("resource") # RFC 8707 - - # These are required values from our own state mapping - assert redirect_uri is not None - assert code_challenge is not None - assert client_id is not None - - # Validate demo credentials - if username != self.settings.demo_username or password != self.settings.demo_password: - raise HTTPException(401, "Invalid credentials") - - # Create MCP authorization code - new_code = f"mcp_{secrets.token_hex(16)}" - auth_code = AuthorizationCode( - code=new_code, - client_id=client_id, - redirect_uri=AnyHttpUrl(redirect_uri), - redirect_uri_provided_explicitly=redirect_uri_provided_explicitly, - expires_at=time.time() + 300, - scopes=[self.settings.mcp_scope], - code_challenge=code_challenge, - resource=resource, # RFC 8707 - ) - self.auth_codes[new_code] = auth_code - - # Store user data - self.user_data[username] = { - "username": username, - "user_id": f"user_{secrets.token_hex(8)}", - "authenticated_at": time.time(), - } - - del self.state_mapping[state] - return construct_redirect_uri(redirect_uri, code=new_code, state=state) - - async def load_authorization_code( - self, client: OAuthClientInformationFull, authorization_code: str - ) -> AuthorizationCode | None: - """Load an authorization code.""" - return self.auth_codes.get(authorization_code) - - async def exchange_authorization_code( - self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode - ) -> OAuthToken: - """Exchange authorization code for tokens.""" - if authorization_code.code not in self.auth_codes: - raise ValueError("Invalid authorization code") - if not client.client_id: - raise ValueError("No client_id provided") - - # Generate MCP access token - mcp_token = f"mcp_{secrets.token_hex(32)}" - - # Store MCP token - self.tokens[mcp_token] = AccessToken( - token=mcp_token, - client_id=client.client_id, - scopes=authorization_code.scopes, - expires_at=int(time.time()) + 3600, - resource=authorization_code.resource, # RFC 8707 - ) - - # Store user data mapping for this token - self.user_data[mcp_token] = { - "username": self.settings.demo_username, - "user_id": f"user_{secrets.token_hex(8)}", - "authenticated_at": time.time(), - } - - del self.auth_codes[authorization_code.code] - - return OAuthToken( - access_token=mcp_token, - token_type="Bearer", - expires_in=3600, - scope=" ".join(authorization_code.scopes), - ) - - async def load_access_token(self, token: str) -> AccessToken | None: - """Load and validate an access token.""" - access_token = self.tokens.get(token) - if not access_token: - return None - - # Check if expired - if access_token.expires_at and access_token.expires_at < time.time(): - del self.tokens[token] - return None - - return access_token - - async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None: - """Load a refresh token - not supported in this example.""" - return None - - async def exchange_refresh_token( - self, - client: OAuthClientInformationFull, - refresh_token: RefreshToken, - scopes: list[str], - ) -> OAuthToken: - """Exchange refresh token - not supported in this example.""" - raise NotImplementedError("Refresh tokens not supported") - - # TODO(Marcelo): The type hint is wrong. We need to fix, and test to check if it works. - async def revoke_token(self, token: str, token_type_hint: str | None = None) -> None: # type: ignore - """Revoke a token.""" - if token in self.tokens: - del self.tokens[token] +"""Simple OAuth provider for MCP servers. + +This module contains a basic OAuth implementation using hardcoded user credentials +for demonstration purposes. No external authentication provider is required. + +NOTE: this is a simplified example for demonstration purposes. +This is not a production-ready implementation. + +""" + +import secrets +import time +from typing import Any + +from pydantic import AnyHttpUrl +from pydantic_settings import BaseSettings, SettingsConfigDict +from starlette.exceptions import HTTPException +from starlette.requests import Request +from starlette.responses import HTMLResponse, RedirectResponse, Response + +from mcp.server.auth.provider import ( + AccessToken, + AuthorizationCode, + AuthorizationParams, + OAuthAuthorizationServerProvider, + RefreshToken, + construct_redirect_uri, +) +from mcp.shared.auth import OAuthClientInformationFull, OAuthToken + + +class SimpleAuthSettings(BaseSettings): + """Simple OAuth settings for demo purposes.""" + + model_config = SettingsConfigDict(env_prefix="MCP_") + + # Demo user credentials + demo_username: str = "demo_user" + demo_password: str = "demo_password" + + # MCP OAuth scope + mcp_scope: str = "user" + + +class SimpleOAuthProvider(OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]): + """Simple OAuth provider for demo purposes. + + This provider handles the OAuth flow by: + 1. Providing a simple login form for demo credentials + 2. Issuing MCP tokens after successful authentication + 3. Maintaining token state for introspection + """ + + def __init__(self, settings: SimpleAuthSettings, auth_callback_url: str, server_url: str): + self.settings = settings + self.auth_callback_url = auth_callback_url + self.server_url = server_url + self.clients: dict[str, OAuthClientInformationFull] = {} + self.auth_codes: dict[str, AuthorizationCode] = {} + self.tokens: dict[str, AccessToken] = {} + self.state_mapping: dict[str, dict[str, str | None]] = {} + # Store authenticated user information + self.user_data: dict[str, dict[str, Any]] = {} + + async def get_client(self, client_id: str) -> OAuthClientInformationFull | None: + """Get OAuth client information.""" + return self.clients.get(client_id) + + async def register_client(self, client_info: OAuthClientInformationFull): + """Register a new OAuth client.""" + if not client_info.client_id: + raise ValueError("No client_id provided") + self.clients[client_info.client_id] = client_info + + async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str: + """Generate an authorization URL for simple login flow.""" + state = params.state or secrets.token_hex(16) + + # Store state mapping for callback + self.state_mapping[state] = { + "redirect_uri": str(params.redirect_uri), + "code_challenge": params.code_challenge, + "redirect_uri_provided_explicitly": str(params.redirect_uri_provided_explicitly), + "client_id": client.client_id, + "resource": params.resource, # RFC 8707 + } + + # Build simple login URL that points to login page + auth_url = f"{self.auth_callback_url}?state={state}&client_id={client.client_id}" + + return auth_url + + async def get_login_page(self, state: str) -> HTMLResponse: + """Generate login page HTML for the given state.""" + if not state: + raise HTTPException(400, "Missing state parameter") + + # Create simple login form HTML + html_content = f""" + + + + MCP Demo Authentication + + + +

MCP Demo Authentication

+

This is a simplified authentication demo. Use the demo credentials below:

+

Username: demo_user
+ Password: demo_password

+ +
+ +
+ + +
+
+ + +
+ +
+ + + """ + + return HTMLResponse(content=html_content) + + async def handle_login_callback(self, request: Request) -> Response: + """Handle login form submission callback.""" + form = await request.form() + username = form.get("username") + password = form.get("password") + state = form.get("state") + + if not username or not password or not state: + raise HTTPException(400, "Missing username, password, or state parameter") + + # Ensure we have strings, not UploadFile objects + if not isinstance(username, str) or not isinstance(password, str) or not isinstance(state, str): + raise HTTPException(400, "Invalid parameter types") + + redirect_uri = await self.handle_simple_callback(username, password, state) + return RedirectResponse(url=redirect_uri, status_code=302) + + async def handle_simple_callback(self, username: str, password: str, state: str) -> str: + """Handle simple authentication callback and return redirect URI.""" + state_data = self.state_mapping.get(state) + if not state_data: + raise HTTPException(400, "Invalid state parameter") + + redirect_uri = state_data["redirect_uri"] + code_challenge = state_data["code_challenge"] + redirect_uri_provided_explicitly = state_data["redirect_uri_provided_explicitly"] == "True" + client_id = state_data["client_id"] + resource = state_data.get("resource") # RFC 8707 + + # These are required values from our own state mapping + assert redirect_uri is not None + assert code_challenge is not None + assert client_id is not None + + # Validate demo credentials + if username != self.settings.demo_username or password != self.settings.demo_password: + raise HTTPException(401, "Invalid credentials") + + # Create MCP authorization code + new_code = f"mcp_{secrets.token_hex(16)}" + auth_code = AuthorizationCode( + code=new_code, + client_id=client_id, + redirect_uri=AnyHttpUrl(redirect_uri), + redirect_uri_provided_explicitly=redirect_uri_provided_explicitly, + expires_at=time.time() + 300, + scopes=[self.settings.mcp_scope], + code_challenge=code_challenge, + resource=resource, # RFC 8707 + ) + self.auth_codes[new_code] = auth_code + + # Store user data + self.user_data[username] = { + "username": username, + "user_id": f"user_{secrets.token_hex(8)}", + "authenticated_at": time.time(), + } + + del self.state_mapping[state] + return construct_redirect_uri(redirect_uri, code=new_code, state=state) + + async def load_authorization_code( + self, client: OAuthClientInformationFull, authorization_code: str + ) -> AuthorizationCode | None: + """Load an authorization code.""" + return self.auth_codes.get(authorization_code) + + async def exchange_authorization_code( + self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode + ) -> OAuthToken: + """Exchange authorization code for tokens.""" + if authorization_code.code not in self.auth_codes: + raise ValueError("Invalid authorization code") + if not client.client_id: + raise ValueError("No client_id provided") + + # Generate MCP access token + mcp_token = f"mcp_{secrets.token_hex(32)}" + + # Store MCP token + self.tokens[mcp_token] = AccessToken( + token=mcp_token, + client_id=client.client_id, + scopes=authorization_code.scopes, + expires_at=int(time.time()) + 3600, + resource=authorization_code.resource, # RFC 8707 + ) + + # Store user data mapping for this token + self.user_data[mcp_token] = { + "username": self.settings.demo_username, + "user_id": f"user_{secrets.token_hex(8)}", + "authenticated_at": time.time(), + } + + del self.auth_codes[authorization_code.code] + + return OAuthToken( + access_token=mcp_token, + token_type="Bearer", + expires_in=3600, + scope=" ".join(authorization_code.scopes), + ) + + async def load_access_token(self, token: str) -> AccessToken | None: + """Load and validate an access token.""" + access_token = self.tokens.get(token) + if not access_token: + return None + + # Check if expired + if access_token.expires_at and access_token.expires_at < time.time(): + del self.tokens[token] + return None + + return access_token + + async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None: + """Load a refresh token - not supported in this example.""" + return None + + async def exchange_refresh_token( + self, + client: OAuthClientInformationFull, + refresh_token: RefreshToken, + scopes: list[str], + ) -> OAuthToken: + """Exchange refresh token - not supported in this example.""" + raise NotImplementedError("Refresh tokens not supported") + + # TODO(Marcelo): The type hint is wrong. We need to fix, and test to check if it works. + async def revoke_token(self, token: str, token_type_hint: str | None = None) -> None: # type: ignore + """Revoke a token.""" + if token in self.tokens: + del self.tokens[token] diff --git a/examples/servers/simple-auth/mcp_simple_auth/token_verifier.py b/examples/servers/simple-auth/mcp_simple_auth/token_verifier.py index 5228d034e..e911125f3 100644 --- a/examples/servers/simple-auth/mcp_simple_auth/token_verifier.py +++ b/examples/servers/simple-auth/mcp_simple_auth/token_verifier.py @@ -1,106 +1,106 @@ -"""Example token verifier implementation using OAuth 2.0 Token Introspection (RFC 7662).""" - -import logging -from typing import Any - -from mcp.server.auth.provider import AccessToken, TokenVerifier -from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url - -logger = logging.getLogger(__name__) - - -class IntrospectionTokenVerifier(TokenVerifier): - """Example token verifier that uses OAuth 2.0 Token Introspection (RFC 7662). - - This is a simple example implementation for demonstration purposes. - Production implementations should consider: - - Connection pooling and reuse - - More sophisticated error handling - - Rate limiting and retry logic - - Comprehensive configuration options - """ - - def __init__( - self, - introspection_endpoint: str, - server_url: str, - validate_resource: bool = False, - ): - self.introspection_endpoint = introspection_endpoint - self.server_url = server_url - self.validate_resource = validate_resource - self.resource_url = resource_url_from_server_url(server_url) - - async def verify_token(self, token: str) -> AccessToken | None: - """Verify token via introspection endpoint.""" - import httpx - - # Validate URL to prevent SSRF attacks - if not self.introspection_endpoint.startswith(("https://", "http://localhost", "http://127.0.0.1")): - logger.warning(f"Rejecting introspection endpoint with unsafe scheme: {self.introspection_endpoint}") - return None - - # Configure secure HTTP client - timeout = httpx.Timeout(10.0, connect=5.0) - limits = httpx.Limits(max_connections=10, max_keepalive_connections=5) - - async with httpx.AsyncClient( - timeout=timeout, - limits=limits, - verify=True, # Enforce SSL verification - ) as client: - try: - response = await client.post( - self.introspection_endpoint, - data={"token": token}, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - - if response.status_code != 200: - logger.debug(f"Token introspection returned status {response.status_code}") - return None - - data = response.json() - if not data.get("active", False): - return None - - # RFC 8707 resource validation (only when --oauth-strict is set) - if self.validate_resource and not self._validate_resource(data): - logger.warning(f"Token resource validation failed. Expected: {self.resource_url}") - return None - - return AccessToken( - token=token, - client_id=data.get("client_id", "unknown"), - scopes=data.get("scope", "").split() if data.get("scope") else [], - expires_at=data.get("exp"), - resource=data.get("aud"), # Include resource in token - ) - except Exception as e: - logger.warning(f"Token introspection failed: {e}") - return None - - def _validate_resource(self, token_data: dict[str, Any]) -> bool: - """Validate token was issued for this resource server.""" - if not self.server_url or not self.resource_url: - return False # Fail if strict validation requested but URLs missing - - # Check 'aud' claim first (standard JWT audience) - aud: list[str] | str | None = token_data.get("aud") - if isinstance(aud, list): - for audience in aud: - if self._is_valid_resource(audience): - return True - return False - elif aud: - return self._is_valid_resource(aud) - - # No resource binding - invalid per RFC 8707 - return False - - def _is_valid_resource(self, resource: str) -> bool: - """Check if resource matches this server using hierarchical matching.""" - if not self.resource_url: - return False - - return check_resource_allowed(requested_resource=self.resource_url, configured_resource=resource) +"""Example token verifier implementation using OAuth 2.0 Token Introspection (RFC 7662).""" + +import logging +from typing import Any + +from mcp.server.auth.provider import AccessToken, TokenVerifier +from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url + +logger = logging.getLogger(__name__) + + +class IntrospectionTokenVerifier(TokenVerifier): + """Example token verifier that uses OAuth 2.0 Token Introspection (RFC 7662). + + This is a simple example implementation for demonstration purposes. + Production implementations should consider: + - Connection pooling and reuse + - More sophisticated error handling + - Rate limiting and retry logic + - Comprehensive configuration options + """ + + def __init__( + self, + introspection_endpoint: str, + server_url: str, + validate_resource: bool = False, + ): + self.introspection_endpoint = introspection_endpoint + self.server_url = server_url + self.validate_resource = validate_resource + self.resource_url = resource_url_from_server_url(server_url) + + async def verify_token(self, token: str) -> AccessToken | None: + """Verify token via introspection endpoint.""" + import httpx + + # Validate URL to prevent SSRF attacks + if not self.introspection_endpoint.startswith(("https://", "http://localhost", "http://127.0.0.1")): + logger.warning(f"Rejecting introspection endpoint with unsafe scheme: {self.introspection_endpoint}") + return None + + # Configure secure HTTP client + timeout = httpx.Timeout(10.0, connect=5.0) + limits = httpx.Limits(max_connections=10, max_keepalive_connections=5) + + async with httpx.AsyncClient( + timeout=timeout, + limits=limits, + verify=True, # Enforce SSL verification + ) as client: + try: + response = await client.post( + self.introspection_endpoint, + data={"token": token}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + + if response.status_code != 200: + logger.debug(f"Token introspection returned status {response.status_code}") + return None + + data = response.json() + if not data.get("active", False): + return None + + # RFC 8707 resource validation (only when --oauth-strict is set) + if self.validate_resource and not self._validate_resource(data): + logger.warning(f"Token resource validation failed. Expected: {self.resource_url}") + return None + + return AccessToken( + token=token, + client_id=data.get("client_id", "unknown"), + scopes=data.get("scope", "").split() if data.get("scope") else [], + expires_at=data.get("exp"), + resource=data.get("aud"), # Include resource in token + ) + except Exception as e: + logger.warning(f"Token introspection failed: {e}") + return None + + def _validate_resource(self, token_data: dict[str, Any]) -> bool: + """Validate token was issued for this resource server.""" + if not self.server_url or not self.resource_url: + return False # Fail if strict validation requested but URLs missing + + # Check 'aud' claim first (standard JWT audience) + aud: list[str] | str | None = token_data.get("aud") + if isinstance(aud, list): + for audience in aud: + if self._is_valid_resource(audience): + return True + return False + elif aud: + return self._is_valid_resource(aud) + + # No resource binding - invalid per RFC 8707 + return False + + def _is_valid_resource(self, resource: str) -> bool: + """Check if resource matches this server using hierarchical matching.""" + if not self.resource_url: + return False + + return check_resource_allowed(requested_resource=self.resource_url, configured_resource=resource) diff --git a/examples/servers/simple-auth/pyproject.toml b/examples/servers/simple-auth/pyproject.toml index 1ffe3e694..a6b2e8d7f 100644 --- a/examples/servers/simple-auth/pyproject.toml +++ b/examples/servers/simple-auth/pyproject.toml @@ -1,33 +1,33 @@ -[project] -name = "mcp-simple-auth" -version = "0.1.0" -description = "A simple MCP server demonstrating OAuth authentication" -readme = "README.md" -requires-python = ">=3.10" -authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] -license = { text = "MIT" } -dependencies = [ - "anyio>=4.5", - "click>=8.2.0", - "httpx>=0.27", - "mcp", - "pydantic>=2.0", - "pydantic-settings>=2.5.2", - "sse-starlette>=1.6.1", - "uvicorn>=0.23.1; sys_platform != 'emscripten'", -] - -[project.scripts] -mcp-simple-auth-rs = "mcp_simple_auth.server:main" -mcp-simple-auth-as = "mcp_simple_auth.auth_server:main" -mcp-simple-auth-legacy = "mcp_simple_auth.legacy_as_server:main" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["mcp_simple_auth"] - -[dependency-groups] -dev = ["pyright>=1.1.391", "pytest>=8.3.4", "ruff>=0.8.5"] +[project] +name = "mcp-simple-auth" +version = "0.1.0" +description = "A simple MCP server demonstrating OAuth authentication" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +license = { text = "MIT" } +dependencies = [ + "anyio>=4.5", + "click>=8.2.0", + "httpx>=0.27", + "mcp", + "pydantic>=2.0", + "pydantic-settings>=2.5.2", + "sse-starlette>=1.6.1", + "uvicorn>=0.23.1; sys_platform != 'emscripten'", +] + +[project.scripts] +mcp-simple-auth-rs = "mcp_simple_auth.server:main" +mcp-simple-auth-as = "mcp_simple_auth.auth_server:main" +mcp-simple-auth-legacy = "mcp_simple_auth.legacy_as_server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_auth"] + +[dependency-groups] +dev = ["pyright>=1.1.391", "pytest>=8.3.4", "ruff>=0.8.5"] diff --git a/examples/servers/simple-pagination/README.md b/examples/servers/simple-pagination/README.md index 4cab40fd3..358ba23e1 100644 --- a/examples/servers/simple-pagination/README.md +++ b/examples/servers/simple-pagination/README.md @@ -1,77 +1,77 @@ -# MCP Simple Pagination - -A simple MCP server demonstrating pagination for tools, resources, and prompts using cursor-based pagination. - -## Usage - -Start the server using either stdio (default) or Streamable HTTP transport: - -```bash -# Using stdio transport (default) -uv run mcp-simple-pagination - -# Using Streamable HTTP transport on custom port -uv run mcp-simple-pagination --transport streamable-http --port 8000 -``` - -The server exposes: - -- 25 tools (paginated, 5 per page) -- 30 resources (paginated, 10 per page) -- 20 prompts (paginated, 7 per page) - -Each paginated list returns a `nextCursor` when more pages are available. Use this cursor in subsequent requests to retrieve the next page. - -## Example - -Using the MCP client, you can retrieve paginated items like this using the STDIO transport: - -```python -import asyncio -from mcp.client.session import ClientSession -from mcp.client.stdio import StdioServerParameters, stdio_client - - -async def main(): - async with stdio_client( - StdioServerParameters(command="uv", args=["run", "mcp-simple-pagination"]) - ) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - - # Get first page of tools - tools_page1 = await session.list_tools() - print(f"First page: {len(tools_page1.tools)} tools") - print(f"Next cursor: {tools_page1.nextCursor}") - - # Get second page using cursor - if tools_page1.nextCursor: - tools_page2 = await session.list_tools(cursor=tools_page1.nextCursor) - print(f"Second page: {len(tools_page2.tools)} tools") - - # Similarly for resources - resources_page1 = await session.list_resources() - print(f"First page: {len(resources_page1.resources)} resources") - - # And for prompts - prompts_page1 = await session.list_prompts() - print(f"First page: {len(prompts_page1.prompts)} prompts") - - -asyncio.run(main()) -``` - -## Pagination Details - -The server uses simple numeric indices as cursors for demonstration purposes. In production scenarios, you might use: - -- Database offsets or row IDs -- Timestamps for time-based pagination -- Opaque tokens encoding pagination state - -The pagination implementation demonstrates: - -- Handling `None` cursor for the first page -- Returning `nextCursor` when more data exists -- Gracefully handling invalid cursors -- Different page sizes for different resource types +# MCP Simple Pagination + +A simple MCP server demonstrating pagination for tools, resources, and prompts using cursor-based pagination. + +## Usage + +Start the server using either stdio (default) or Streamable HTTP transport: + +```bash +# Using stdio transport (default) +uv run mcp-simple-pagination + +# Using Streamable HTTP transport on custom port +uv run mcp-simple-pagination --transport streamable-http --port 8000 +``` + +The server exposes: + +- 25 tools (paginated, 5 per page) +- 30 resources (paginated, 10 per page) +- 20 prompts (paginated, 7 per page) + +Each paginated list returns a `nextCursor` when more pages are available. Use this cursor in subsequent requests to retrieve the next page. + +## Example + +Using the MCP client, you can retrieve paginated items like this using the STDIO transport: + +```python +import asyncio +from mcp.client.session import ClientSession +from mcp.client.stdio import StdioServerParameters, stdio_client + + +async def main(): + async with stdio_client( + StdioServerParameters(command="uv", args=["run", "mcp-simple-pagination"]) + ) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + + # Get first page of tools + tools_page1 = await session.list_tools() + print(f"First page: {len(tools_page1.tools)} tools") + print(f"Next cursor: {tools_page1.nextCursor}") + + # Get second page using cursor + if tools_page1.nextCursor: + tools_page2 = await session.list_tools(cursor=tools_page1.nextCursor) + print(f"Second page: {len(tools_page2.tools)} tools") + + # Similarly for resources + resources_page1 = await session.list_resources() + print(f"First page: {len(resources_page1.resources)} resources") + + # And for prompts + prompts_page1 = await session.list_prompts() + print(f"First page: {len(prompts_page1.prompts)} prompts") + + +asyncio.run(main()) +``` + +## Pagination Details + +The server uses simple numeric indices as cursors for demonstration purposes. In production scenarios, you might use: + +- Database offsets or row IDs +- Timestamps for time-based pagination +- Opaque tokens encoding pagination state + +The pagination implementation demonstrates: + +- Handling `None` cursor for the first page +- Returning `nextCursor` when more data exists +- Gracefully handling invalid cursors +- Different page sizes for different resource types diff --git a/examples/servers/simple-pagination/mcp_simple_pagination/__main__.py b/examples/servers/simple-pagination/mcp_simple_pagination/__main__.py index e7ef16530..8d8f860d2 100644 --- a/examples/servers/simple-pagination/mcp_simple_pagination/__main__.py +++ b/examples/servers/simple-pagination/mcp_simple_pagination/__main__.py @@ -1,5 +1,5 @@ -import sys - -from .server import main - -sys.exit(main()) # type: ignore[call-arg] +import sys + +from .server import main + +sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/servers/simple-pagination/mcp_simple_pagination/server.py b/examples/servers/simple-pagination/mcp_simple_pagination/server.py index c94f2ac3d..a71dc3dc6 100644 --- a/examples/servers/simple-pagination/mcp_simple_pagination/server.py +++ b/examples/servers/simple-pagination/mcp_simple_pagination/server.py @@ -1,176 +1,176 @@ -"""Simple MCP server demonstrating pagination for tools, resources, and prompts. - -This example shows how to implement pagination with the low-level server API -to handle large lists of items that need to be split across multiple pages. -""" - -from typing import TypeVar - -import anyio -import click -from mcp import types -from mcp.server import Server, ServerRequestContext - -T = TypeVar("T") - -# Sample data - in real scenarios, this might come from a database -SAMPLE_TOOLS = [ - types.Tool( - name=f"tool_{i}", - title=f"Tool {i}", - description=f"This is sample tool number {i}", - input_schema={"type": "object", "properties": {"input": {"type": "string"}}}, - ) - for i in range(1, 26) # 25 tools total -] - -SAMPLE_RESOURCES = [ - types.Resource( - uri=f"file:///path/to/resource_{i}.txt", - name=f"resource_{i}", - description=f"This is sample resource number {i}", - ) - for i in range(1, 31) # 30 resources total -] - -SAMPLE_PROMPTS = [ - types.Prompt( - name=f"prompt_{i}", - description=f"This is sample prompt number {i}", - arguments=[ - types.PromptArgument(name="arg1", description="First argument", required=True), - ], - ) - for i in range(1, 21) # 20 prompts total -] - - -def _paginate(cursor: str | None, items: list[T], page_size: int) -> tuple[list[T], str | None]: - """Helper to paginate a list of items given a cursor.""" - if cursor is not None: - try: - start_idx = int(cursor) - except (ValueError, TypeError): - return [], None - else: - start_idx = 0 - - page = items[start_idx : start_idx + page_size] - next_cursor = str(start_idx + page_size) if start_idx + page_size < len(items) else None - return page, next_cursor - - -# Paginated list_tools - returns 5 tools per page -async def handle_list_tools( - ctx: ServerRequestContext, params: types.PaginatedRequestParams | None -) -> types.ListToolsResult: - cursor = params.cursor if params is not None else None - page, next_cursor = _paginate(cursor, SAMPLE_TOOLS, page_size=5) - return types.ListToolsResult(tools=page, next_cursor=next_cursor) - - -# Paginated list_resources - returns 10 resources per page -async def handle_list_resources( - ctx: ServerRequestContext, params: types.PaginatedRequestParams | None -) -> types.ListResourcesResult: - cursor = params.cursor if params is not None else None - page, next_cursor = _paginate(cursor, SAMPLE_RESOURCES, page_size=10) - return types.ListResourcesResult(resources=page, next_cursor=next_cursor) - - -# Paginated list_prompts - returns 7 prompts per page -async def handle_list_prompts( - ctx: ServerRequestContext, params: types.PaginatedRequestParams | None -) -> types.ListPromptsResult: - cursor = params.cursor if params is not None else None - page, next_cursor = _paginate(cursor, SAMPLE_PROMPTS, page_size=7) - return types.ListPromptsResult(prompts=page, next_cursor=next_cursor) - - -async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: - # Find the tool in our sample data - tool = next((t for t in SAMPLE_TOOLS if t.name == params.name), None) - if not tool: - raise ValueError(f"Unknown tool: {params.name}") - - return types.CallToolResult( - content=[ - types.TextContent( - type="text", - text=f"Called tool '{params.name}' with arguments: {params.arguments}", - ) - ] - ) - - -async def handle_read_resource( - ctx: ServerRequestContext, params: types.ReadResourceRequestParams -) -> types.ReadResourceResult: - resource = next((r for r in SAMPLE_RESOURCES if r.uri == str(params.uri)), None) - if not resource: - raise ValueError(f"Unknown resource: {params.uri}") - - return types.ReadResourceResult( - contents=[ - types.TextResourceContents( - uri=str(params.uri), - text=f"Content of {resource.name}: This is sample content for the resource.", - mime_type="text/plain", - ) - ] - ) - - -async def handle_get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> types.GetPromptResult: - prompt = next((p for p in SAMPLE_PROMPTS if p.name == params.name), None) - if not prompt: - raise ValueError(f"Unknown prompt: {params.name}") - - message_text = f"This is the prompt '{params.name}'" - if params.arguments: - message_text += f" with arguments: {params.arguments}" - - return types.GetPromptResult( - description=prompt.description, - messages=[ - types.PromptMessage( - role="user", - content=types.TextContent(type="text", text=message_text), - ) - ], - ) - - -@click.command() -@click.option("--port", default=8000, help="Port to listen on for HTTP") -@click.option( - "--transport", - type=click.Choice(["stdio", "streamable-http"]), - default="stdio", - help="Transport type", -) -def main(port: int, transport: str) -> int: - app = Server( - "mcp-simple-pagination", - on_list_tools=handle_list_tools, - on_list_resources=handle_list_resources, - on_list_prompts=handle_list_prompts, - on_call_tool=handle_call_tool, - on_read_resource=handle_read_resource, - on_get_prompt=handle_get_prompt, - ) - - if transport == "streamable-http": - import uvicorn - - uvicorn.run(app.streamable_http_app(), host="127.0.0.1", port=port) - else: - from mcp.server.stdio import stdio_server - - async def arun(): - async with stdio_server() as streams: - await app.run(streams[0], streams[1], app.create_initialization_options()) - - anyio.run(arun) - - return 0 +"""Simple MCP server demonstrating pagination for tools, resources, and prompts. + +This example shows how to implement pagination with the low-level server API +to handle large lists of items that need to be split across multiple pages. +""" + +from typing import TypeVar + +import anyio +import click +from mcp import types +from mcp.server import Server, ServerRequestContext + +T = TypeVar("T") + +# Sample data - in real scenarios, this might come from a database +SAMPLE_TOOLS = [ + types.Tool( + name=f"tool_{i}", + title=f"Tool {i}", + description=f"This is sample tool number {i}", + input_schema={"type": "object", "properties": {"input": {"type": "string"}}}, + ) + for i in range(1, 26) # 25 tools total +] + +SAMPLE_RESOURCES = [ + types.Resource( + uri=f"file:///path/to/resource_{i}.txt", + name=f"resource_{i}", + description=f"This is sample resource number {i}", + ) + for i in range(1, 31) # 30 resources total +] + +SAMPLE_PROMPTS = [ + types.Prompt( + name=f"prompt_{i}", + description=f"This is sample prompt number {i}", + arguments=[ + types.PromptArgument(name="arg1", description="First argument", required=True), + ], + ) + for i in range(1, 21) # 20 prompts total +] + + +def _paginate(cursor: str | None, items: list[T], page_size: int) -> tuple[list[T], str | None]: + """Helper to paginate a list of items given a cursor.""" + if cursor is not None: + try: + start_idx = int(cursor) + except (ValueError, TypeError): + return [], None + else: + start_idx = 0 + + page = items[start_idx : start_idx + page_size] + next_cursor = str(start_idx + page_size) if start_idx + page_size < len(items) else None + return page, next_cursor + + +# Paginated list_tools - returns 5 tools per page +async def handle_list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListToolsResult: + cursor = params.cursor if params is not None else None + page, next_cursor = _paginate(cursor, SAMPLE_TOOLS, page_size=5) + return types.ListToolsResult(tools=page, next_cursor=next_cursor) + + +# Paginated list_resources - returns 10 resources per page +async def handle_list_resources( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListResourcesResult: + cursor = params.cursor if params is not None else None + page, next_cursor = _paginate(cursor, SAMPLE_RESOURCES, page_size=10) + return types.ListResourcesResult(resources=page, next_cursor=next_cursor) + + +# Paginated list_prompts - returns 7 prompts per page +async def handle_list_prompts( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListPromptsResult: + cursor = params.cursor if params is not None else None + page, next_cursor = _paginate(cursor, SAMPLE_PROMPTS, page_size=7) + return types.ListPromptsResult(prompts=page, next_cursor=next_cursor) + + +async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: + # Find the tool in our sample data + tool = next((t for t in SAMPLE_TOOLS if t.name == params.name), None) + if not tool: + raise ValueError(f"Unknown tool: {params.name}") + + return types.CallToolResult( + content=[ + types.TextContent( + type="text", + text=f"Called tool '{params.name}' with arguments: {params.arguments}", + ) + ] + ) + + +async def handle_read_resource( + ctx: ServerRequestContext, params: types.ReadResourceRequestParams +) -> types.ReadResourceResult: + resource = next((r for r in SAMPLE_RESOURCES if r.uri == str(params.uri)), None) + if not resource: + raise ValueError(f"Unknown resource: {params.uri}") + + return types.ReadResourceResult( + contents=[ + types.TextResourceContents( + uri=str(params.uri), + text=f"Content of {resource.name}: This is sample content for the resource.", + mime_type="text/plain", + ) + ] + ) + + +async def handle_get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> types.GetPromptResult: + prompt = next((p for p in SAMPLE_PROMPTS if p.name == params.name), None) + if not prompt: + raise ValueError(f"Unknown prompt: {params.name}") + + message_text = f"This is the prompt '{params.name}'" + if params.arguments: + message_text += f" with arguments: {params.arguments}" + + return types.GetPromptResult( + description=prompt.description, + messages=[ + types.PromptMessage( + role="user", + content=types.TextContent(type="text", text=message_text), + ) + ], + ) + + +@click.command() +@click.option("--port", default=8000, help="Port to listen on for HTTP") +@click.option( + "--transport", + type=click.Choice(["stdio", "streamable-http"]), + default="stdio", + help="Transport type", +) +def main(port: int, transport: str) -> int: + app = Server( + "mcp-simple-pagination", + on_list_tools=handle_list_tools, + on_list_resources=handle_list_resources, + on_list_prompts=handle_list_prompts, + on_call_tool=handle_call_tool, + on_read_resource=handle_read_resource, + on_get_prompt=handle_get_prompt, + ) + + if transport == "streamable-http": + import uvicorn + + uvicorn.run(app.streamable_http_app(), host="127.0.0.1", port=port) + else: + from mcp.server.stdio import stdio_server + + async def arun(): + async with stdio_server() as streams: + await app.run(streams[0], streams[1], app.create_initialization_options()) + + anyio.run(arun) + + return 0 diff --git a/examples/servers/simple-pagination/pyproject.toml b/examples/servers/simple-pagination/pyproject.toml index 2d57d9ccc..f1f5d9c1a 100644 --- a/examples/servers/simple-pagination/pyproject.toml +++ b/examples/servers/simple-pagination/pyproject.toml @@ -1,43 +1,43 @@ -[project] -name = "mcp-simple-pagination" -version = "0.1.0" -description = "A simple MCP server demonstrating pagination for tools, resources, and prompts" -readme = "README.md" -requires-python = ">=3.10" -authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] -keywords = ["mcp", "llm", "automation", "pagination", "cursor"] -license = { text = "MIT" } -classifiers = [ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", -] -dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp"] - -[project.scripts] -mcp-simple-pagination = "mcp_simple_pagination.server:main" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["mcp_simple_pagination"] - -[tool.pyright] -include = ["mcp_simple_pagination"] -venvPath = "." -venv = ".venv" - -[tool.ruff.lint] -select = ["E", "F", "I"] -ignore = [] - -[tool.ruff] -line-length = 120 -target-version = "py310" - -[dependency-groups] -dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] +[project] +name = "mcp-simple-pagination" +version = "0.1.0" +description = "A simple MCP server demonstrating pagination for tools, resources, and prompts" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +keywords = ["mcp", "llm", "automation", "pagination", "cursor"] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", +] +dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp"] + +[project.scripts] +mcp-simple-pagination = "mcp_simple_pagination.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_pagination"] + +[tool.pyright] +include = ["mcp_simple_pagination"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/servers/simple-prompt/.python-version b/examples/servers/simple-prompt/.python-version index c8cfe3959..2951d9b02 100644 --- a/examples/servers/simple-prompt/.python-version +++ b/examples/servers/simple-prompt/.python-version @@ -1 +1 @@ -3.10 +3.10 diff --git a/examples/servers/simple-prompt/README.md b/examples/servers/simple-prompt/README.md index c837da876..99d694932 100644 --- a/examples/servers/simple-prompt/README.md +++ b/examples/servers/simple-prompt/README.md @@ -1,55 +1,55 @@ -# MCP Simple Prompt - -A simple MCP server that exposes a customizable prompt template with optional context and topic parameters. - -## Usage - -Start the server using either stdio (default) or Streamable HTTP transport: - -```bash -# Using stdio transport (default) -uv run mcp-simple-prompt - -# Using Streamable HTTP transport on custom port -uv run mcp-simple-prompt --transport streamable-http --port 8000 -``` - -The server exposes a prompt named "simple" that accepts two optional arguments: - -- `context`: Additional context to consider -- `topic`: Specific topic to focus on - -## Example - -Using the MCP client, you can retrieve the prompt like this using the STDIO transport: - -```python -import asyncio -from mcp.client.session import ClientSession -from mcp.client.stdio import StdioServerParameters, stdio_client - - -async def main(): - async with stdio_client( - StdioServerParameters(command="uv", args=["run", "mcp-simple-prompt"]) - ) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - - # List available prompts - prompts = await session.list_prompts() - print(prompts) - - # Get the prompt with arguments - prompt = await session.get_prompt( - "simple", - { - "context": "User is a software developer", - "topic": "Python async programming", - }, - ) - print(prompt) - - -asyncio.run(main()) -``` +# MCP Simple Prompt + +A simple MCP server that exposes a customizable prompt template with optional context and topic parameters. + +## Usage + +Start the server using either stdio (default) or Streamable HTTP transport: + +```bash +# Using stdio transport (default) +uv run mcp-simple-prompt + +# Using Streamable HTTP transport on custom port +uv run mcp-simple-prompt --transport streamable-http --port 8000 +``` + +The server exposes a prompt named "simple" that accepts two optional arguments: + +- `context`: Additional context to consider +- `topic`: Specific topic to focus on + +## Example + +Using the MCP client, you can retrieve the prompt like this using the STDIO transport: + +```python +import asyncio +from mcp.client.session import ClientSession +from mcp.client.stdio import StdioServerParameters, stdio_client + + +async def main(): + async with stdio_client( + StdioServerParameters(command="uv", args=["run", "mcp-simple-prompt"]) + ) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + + # List available prompts + prompts = await session.list_prompts() + print(prompts) + + # Get the prompt with arguments + prompt = await session.get_prompt( + "simple", + { + "context": "User is a software developer", + "topic": "Python async programming", + }, + ) + print(prompt) + + +asyncio.run(main()) +``` diff --git a/examples/servers/simple-prompt/mcp_simple_prompt/__main__.py b/examples/servers/simple-prompt/mcp_simple_prompt/__main__.py index e7ef16530..8d8f860d2 100644 --- a/examples/servers/simple-prompt/mcp_simple_prompt/__main__.py +++ b/examples/servers/simple-prompt/mcp_simple_prompt/__main__.py @@ -1,5 +1,5 @@ -import sys - -from .server import main - -sys.exit(main()) # type: ignore[call-arg] +import sys + +from .server import main + +sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/servers/simple-prompt/mcp_simple_prompt/server.py b/examples/servers/simple-prompt/mcp_simple_prompt/server.py index 74b71b3f3..4701af564 100644 --- a/examples/servers/simple-prompt/mcp_simple_prompt/server.py +++ b/examples/servers/simple-prompt/mcp_simple_prompt/server.py @@ -1,98 +1,98 @@ -import anyio -import click -from mcp import types -from mcp.server import Server, ServerRequestContext - - -def create_messages(context: str | None = None, topic: str | None = None) -> list[types.PromptMessage]: - """Create the messages for the prompt.""" - messages: list[types.PromptMessage] = [] - - # Add context if provided - if context: - messages.append( - types.PromptMessage( - role="user", - content=types.TextContent(type="text", text=f"Here is some relevant context: {context}"), - ) - ) - - # Add the main prompt - prompt = "Please help me with " - if topic: - prompt += f"the following topic: {topic}" - else: - prompt += "whatever questions I may have." - - messages.append(types.PromptMessage(role="user", content=types.TextContent(type="text", text=prompt))) - - return messages - - -async def handle_list_prompts( - ctx: ServerRequestContext, params: types.PaginatedRequestParams | None -) -> types.ListPromptsResult: - return types.ListPromptsResult( - prompts=[ - types.Prompt( - name="simple", - title="Simple Assistant Prompt", - description="A simple prompt that can take optional context and topic arguments", - arguments=[ - types.PromptArgument( - name="context", - description="Additional context to consider", - required=False, - ), - types.PromptArgument( - name="topic", - description="Specific topic to focus on", - required=False, - ), - ], - ) - ] - ) - - -async def handle_get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> types.GetPromptResult: - if params.name != "simple": - raise ValueError(f"Unknown prompt: {params.name}") - - arguments = params.arguments or {} - - return types.GetPromptResult( - messages=create_messages(context=arguments.get("context"), topic=arguments.get("topic")), - description="A simple prompt with optional context and topic arguments", - ) - - -@click.command() -@click.option("--port", default=8000, help="Port to listen on for HTTP") -@click.option( - "--transport", - type=click.Choice(["stdio", "streamable-http"]), - default="stdio", - help="Transport type", -) -def main(port: int, transport: str) -> int: - app = Server( - "mcp-simple-prompt", - on_list_prompts=handle_list_prompts, - on_get_prompt=handle_get_prompt, - ) - - if transport == "streamable-http": - import uvicorn - - uvicorn.run(app.streamable_http_app(), host="127.0.0.1", port=port) - else: - from mcp.server.stdio import stdio_server - - async def arun(): - async with stdio_server() as streams: - await app.run(streams[0], streams[1], app.create_initialization_options()) - - anyio.run(arun) - - return 0 +import anyio +import click +from mcp import types +from mcp.server import Server, ServerRequestContext + + +def create_messages(context: str | None = None, topic: str | None = None) -> list[types.PromptMessage]: + """Create the messages for the prompt.""" + messages: list[types.PromptMessage] = [] + + # Add context if provided + if context: + messages.append( + types.PromptMessage( + role="user", + content=types.TextContent(type="text", text=f"Here is some relevant context: {context}"), + ) + ) + + # Add the main prompt + prompt = "Please help me with " + if topic: + prompt += f"the following topic: {topic}" + else: + prompt += "whatever questions I may have." + + messages.append(types.PromptMessage(role="user", content=types.TextContent(type="text", text=prompt))) + + return messages + + +async def handle_list_prompts( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListPromptsResult: + return types.ListPromptsResult( + prompts=[ + types.Prompt( + name="simple", + title="Simple Assistant Prompt", + description="A simple prompt that can take optional context and topic arguments", + arguments=[ + types.PromptArgument( + name="context", + description="Additional context to consider", + required=False, + ), + types.PromptArgument( + name="topic", + description="Specific topic to focus on", + required=False, + ), + ], + ) + ] + ) + + +async def handle_get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> types.GetPromptResult: + if params.name != "simple": + raise ValueError(f"Unknown prompt: {params.name}") + + arguments = params.arguments or {} + + return types.GetPromptResult( + messages=create_messages(context=arguments.get("context"), topic=arguments.get("topic")), + description="A simple prompt with optional context and topic arguments", + ) + + +@click.command() +@click.option("--port", default=8000, help="Port to listen on for HTTP") +@click.option( + "--transport", + type=click.Choice(["stdio", "streamable-http"]), + default="stdio", + help="Transport type", +) +def main(port: int, transport: str) -> int: + app = Server( + "mcp-simple-prompt", + on_list_prompts=handle_list_prompts, + on_get_prompt=handle_get_prompt, + ) + + if transport == "streamable-http": + import uvicorn + + uvicorn.run(app.streamable_http_app(), host="127.0.0.1", port=port) + else: + from mcp.server.stdio import stdio_server + + async def arun(): + async with stdio_server() as streams: + await app.run(streams[0], streams[1], app.create_initialization_options()) + + anyio.run(arun) + + return 0 diff --git a/examples/servers/simple-prompt/pyproject.toml b/examples/servers/simple-prompt/pyproject.toml index 9d4d8e6a6..ba6817bf8 100644 --- a/examples/servers/simple-prompt/pyproject.toml +++ b/examples/servers/simple-prompt/pyproject.toml @@ -1,43 +1,43 @@ -[project] -name = "mcp-simple-prompt" -version = "0.1.0" -description = "A simple MCP server exposing a customizable prompt" -readme = "README.md" -requires-python = ">=3.10" -authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] -keywords = ["mcp", "llm", "automation", "web", "fetch"] -license = { text = "MIT" } -classifiers = [ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", -] -dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp"] - -[project.scripts] -mcp-simple-prompt = "mcp_simple_prompt.server:main" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["mcp_simple_prompt"] - -[tool.pyright] -include = ["mcp_simple_prompt"] -venvPath = "." -venv = ".venv" - -[tool.ruff.lint] -select = ["E", "F", "I"] -ignore = [] - -[tool.ruff] -line-length = 120 -target-version = "py310" - -[dependency-groups] -dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] +[project] +name = "mcp-simple-prompt" +version = "0.1.0" +description = "A simple MCP server exposing a customizable prompt" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +keywords = ["mcp", "llm", "automation", "web", "fetch"] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", +] +dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp"] + +[project.scripts] +mcp-simple-prompt = "mcp_simple_prompt.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_prompt"] + +[tool.pyright] +include = ["mcp_simple_prompt"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/servers/simple-resource/.python-version b/examples/servers/simple-resource/.python-version index c8cfe3959..2951d9b02 100644 --- a/examples/servers/simple-resource/.python-version +++ b/examples/servers/simple-resource/.python-version @@ -1 +1 @@ -3.10 +3.10 diff --git a/examples/servers/simple-resource/README.md b/examples/servers/simple-resource/README.md index 7fb2ab7cd..c75837d5c 100644 --- a/examples/servers/simple-resource/README.md +++ b/examples/servers/simple-resource/README.md @@ -1,48 +1,48 @@ -# MCP Simple Resource - -A simple MCP server that exposes sample text files as resources. - -## Usage - -Start the server using either stdio (default) or Streamable HTTP transport: - -```bash -# Using stdio transport (default) -uv run mcp-simple-resource - -# Using Streamable HTTP transport on custom port -uv run mcp-simple-resource --transport streamable-http --port 8000 -``` - -The server exposes some basic text file resources that can be read by clients. - -## Example - -Using the MCP client, you can retrieve resources like this using the STDIO transport: - -```python -import asyncio -from mcp.types import AnyUrl -from mcp.client.session import ClientSession -from mcp.client.stdio import StdioServerParameters, stdio_client - - -async def main(): - async with stdio_client( - StdioServerParameters(command="uv", args=["run", "mcp-simple-resource"]) - ) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - - # List available resources - resources = await session.list_resources() - print(resources) - - # Get a specific resource - resource = await session.read_resource(AnyUrl("file:///greeting.txt")) - print(resource) - - -asyncio.run(main()) - -``` +# MCP Simple Resource + +A simple MCP server that exposes sample text files as resources. + +## Usage + +Start the server using either stdio (default) or Streamable HTTP transport: + +```bash +# Using stdio transport (default) +uv run mcp-simple-resource + +# Using Streamable HTTP transport on custom port +uv run mcp-simple-resource --transport streamable-http --port 8000 +``` + +The server exposes some basic text file resources that can be read by clients. + +## Example + +Using the MCP client, you can retrieve resources like this using the STDIO transport: + +```python +import asyncio +from mcp.types import AnyUrl +from mcp.client.session import ClientSession +from mcp.client.stdio import StdioServerParameters, stdio_client + + +async def main(): + async with stdio_client( + StdioServerParameters(command="uv", args=["run", "mcp-simple-resource"]) + ) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + + # List available resources + resources = await session.list_resources() + print(resources) + + # Get a specific resource + resource = await session.read_resource(AnyUrl("file:///greeting.txt")) + print(resource) + + +asyncio.run(main()) + +``` diff --git a/examples/servers/simple-resource/mcp_simple_resource/__main__.py b/examples/servers/simple-resource/mcp_simple_resource/__main__.py index e7ef16530..8d8f860d2 100644 --- a/examples/servers/simple-resource/mcp_simple_resource/__main__.py +++ b/examples/servers/simple-resource/mcp_simple_resource/__main__.py @@ -1,5 +1,5 @@ -import sys - -from .server import main - -sys.exit(main()) # type: ignore[call-arg] +import sys + +from .server import main + +sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/servers/simple-resource/mcp_simple_resource/server.py b/examples/servers/simple-resource/mcp_simple_resource/server.py index 8d1105414..d59233a3a 100644 --- a/examples/servers/simple-resource/mcp_simple_resource/server.py +++ b/examples/servers/simple-resource/mcp_simple_resource/server.py @@ -1,91 +1,91 @@ -from urllib.parse import urlparse - -import anyio -import click -from mcp import types -from mcp.server import Server, ServerRequestContext - -SAMPLE_RESOURCES = { - "greeting": { - "content": "Hello! This is a sample text resource.", - "title": "Welcome Message", - }, - "help": { - "content": "This server provides a few sample text resources for testing.", - "title": "Help Documentation", - }, - "about": { - "content": "This is the simple-resource MCP server implementation.", - "title": "About This Server", - }, -} - - -async def handle_list_resources( - ctx: ServerRequestContext, params: types.PaginatedRequestParams | None -) -> types.ListResourcesResult: - return types.ListResourcesResult( - resources=[ - types.Resource( - uri=f"file:///{name}.txt", - name=name, - title=SAMPLE_RESOURCES[name]["title"], - description=f"A sample text resource named {name}", - mime_type="text/plain", - ) - for name in SAMPLE_RESOURCES.keys() - ] - ) - - -async def handle_read_resource( - ctx: ServerRequestContext, params: types.ReadResourceRequestParams -) -> types.ReadResourceResult: - parsed = urlparse(str(params.uri)) - if not parsed.path: - raise ValueError(f"Invalid resource path: {params.uri}") - name = parsed.path.replace(".txt", "").lstrip("/") - - if name not in SAMPLE_RESOURCES: - raise ValueError(f"Unknown resource: {params.uri}") - - return types.ReadResourceResult( - contents=[ - types.TextResourceContents( - uri=str(params.uri), - text=SAMPLE_RESOURCES[name]["content"], - mime_type="text/plain", - ) - ] - ) - - -@click.command() -@click.option("--port", default=8000, help="Port to listen on for HTTP") -@click.option( - "--transport", - type=click.Choice(["stdio", "streamable-http"]), - default="stdio", - help="Transport type", -) -def main(port: int, transport: str) -> int: - app = Server( - "mcp-simple-resource", - on_list_resources=handle_list_resources, - on_read_resource=handle_read_resource, - ) - - if transport == "streamable-http": - import uvicorn - - uvicorn.run(app.streamable_http_app(), host="127.0.0.1", port=port) - else: - from mcp.server.stdio import stdio_server - - async def arun(): - async with stdio_server() as streams: - await app.run(streams[0], streams[1], app.create_initialization_options()) - - anyio.run(arun) - - return 0 +from urllib.parse import urlparse + +import anyio +import click +from mcp import types +from mcp.server import Server, ServerRequestContext + +SAMPLE_RESOURCES = { + "greeting": { + "content": "Hello! This is a sample text resource.", + "title": "Welcome Message", + }, + "help": { + "content": "This server provides a few sample text resources for testing.", + "title": "Help Documentation", + }, + "about": { + "content": "This is the simple-resource MCP server implementation.", + "title": "About This Server", + }, +} + + +async def handle_list_resources( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListResourcesResult: + return types.ListResourcesResult( + resources=[ + types.Resource( + uri=f"file:///{name}.txt", + name=name, + title=SAMPLE_RESOURCES[name]["title"], + description=f"A sample text resource named {name}", + mime_type="text/plain", + ) + for name in SAMPLE_RESOURCES.keys() + ] + ) + + +async def handle_read_resource( + ctx: ServerRequestContext, params: types.ReadResourceRequestParams +) -> types.ReadResourceResult: + parsed = urlparse(str(params.uri)) + if not parsed.path: + raise ValueError(f"Invalid resource path: {params.uri}") + name = parsed.path.replace(".txt", "").lstrip("/") + + if name not in SAMPLE_RESOURCES: + raise ValueError(f"Unknown resource: {params.uri}") + + return types.ReadResourceResult( + contents=[ + types.TextResourceContents( + uri=str(params.uri), + text=SAMPLE_RESOURCES[name]["content"], + mime_type="text/plain", + ) + ] + ) + + +@click.command() +@click.option("--port", default=8000, help="Port to listen on for HTTP") +@click.option( + "--transport", + type=click.Choice(["stdio", "streamable-http"]), + default="stdio", + help="Transport type", +) +def main(port: int, transport: str) -> int: + app = Server( + "mcp-simple-resource", + on_list_resources=handle_list_resources, + on_read_resource=handle_read_resource, + ) + + if transport == "streamable-http": + import uvicorn + + uvicorn.run(app.streamable_http_app(), host="127.0.0.1", port=port) + else: + from mcp.server.stdio import stdio_server + + async def arun(): + async with stdio_server() as streams: + await app.run(streams[0], streams[1], app.create_initialization_options()) + + anyio.run(arun) + + return 0 diff --git a/examples/servers/simple-resource/pyproject.toml b/examples/servers/simple-resource/pyproject.toml index 34fbc8d9d..9c92e58e0 100644 --- a/examples/servers/simple-resource/pyproject.toml +++ b/examples/servers/simple-resource/pyproject.toml @@ -1,43 +1,43 @@ -[project] -name = "mcp-simple-resource" -version = "0.1.0" -description = "A simple MCP server exposing sample text resources" -readme = "README.md" -requires-python = ">=3.10" -authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] -keywords = ["mcp", "llm", "automation", "web", "fetch"] -license = { text = "MIT" } -classifiers = [ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", -] -dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp"] - -[project.scripts] -mcp-simple-resource = "mcp_simple_resource.server:main" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["mcp_simple_resource"] - -[tool.pyright] -include = ["mcp_simple_resource"] -venvPath = "." -venv = ".venv" - -[tool.ruff.lint] -select = ["E", "F", "I"] -ignore = [] - -[tool.ruff] -line-length = 120 -target-version = "py310" - -[dependency-groups] -dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] +[project] +name = "mcp-simple-resource" +version = "0.1.0" +description = "A simple MCP server exposing sample text resources" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +keywords = ["mcp", "llm", "automation", "web", "fetch"] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", +] +dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp"] + +[project.scripts] +mcp-simple-resource = "mcp_simple_resource.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_resource"] + +[tool.pyright] +include = ["mcp_simple_resource"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/servers/simple-streamablehttp-stateless/README.md b/examples/servers/simple-streamablehttp-stateless/README.md index a254f88d1..c18021a85 100644 --- a/examples/servers/simple-streamablehttp-stateless/README.md +++ b/examples/servers/simple-streamablehttp-stateless/README.md @@ -1,38 +1,38 @@ -# MCP Simple StreamableHttp Stateless Server Example - -A stateless MCP server example demonstrating the StreamableHttp transport without maintaining session state. This example is ideal for understanding how to deploy MCP servers in multi-node environments where requests can be routed to any instance. - -## Features - -- Uses the StreamableHTTP transport in stateless mode (mcp_session_id=None) -- Each request creates a new ephemeral connection -- No session state maintained between requests -- Suitable for deployment in multi-node environments - -## Usage - -Start the server: - -```bash -# Using default port 3000 -uv run mcp-simple-streamablehttp-stateless - -# Using custom port -uv run mcp-simple-streamablehttp-stateless --port 3000 - -# Custom logging level -uv run mcp-simple-streamablehttp-stateless --log-level DEBUG - -# Enable JSON responses instead of SSE streams -uv run mcp-simple-streamablehttp-stateless --json-response -``` - -The server exposes a tool named "start-notification-stream" that accepts three arguments: - -- `interval`: Time between notifications in seconds (e.g., 1.0) -- `count`: Number of notifications to send (e.g., 5) -- `caller`: Identifier string for the caller - -## Client - -You can connect to this server using an HTTP client. For now, only the TypeScript SDK has streamable HTTP client examples, or you can use [Inspector](https://github.com/modelcontextprotocol/inspector) for testing. +# MCP Simple StreamableHttp Stateless Server Example + +A stateless MCP server example demonstrating the StreamableHttp transport without maintaining session state. This example is ideal for understanding how to deploy MCP servers in multi-node environments where requests can be routed to any instance. + +## Features + +- Uses the StreamableHTTP transport in stateless mode (mcp_session_id=None) +- Each request creates a new ephemeral connection +- No session state maintained between requests +- Suitable for deployment in multi-node environments + +## Usage + +Start the server: + +```bash +# Using default port 3000 +uv run mcp-simple-streamablehttp-stateless + +# Using custom port +uv run mcp-simple-streamablehttp-stateless --port 3000 + +# Custom logging level +uv run mcp-simple-streamablehttp-stateless --log-level DEBUG + +# Enable JSON responses instead of SSE streams +uv run mcp-simple-streamablehttp-stateless --json-response +``` + +The server exposes a tool named "start-notification-stream" that accepts three arguments: + +- `interval`: Time between notifications in seconds (e.g., 1.0) +- `count`: Number of notifications to send (e.g., 5) +- `caller`: Identifier string for the caller + +## Client + +You can connect to this server using an HTTP client. For now, only the TypeScript SDK has streamable HTTP client examples, or you can use [Inspector](https://github.com/modelcontextprotocol/inspector) for testing. diff --git a/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/__main__.py b/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/__main__.py index 1664737e3..bf2dff3c5 100644 --- a/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/__main__.py +++ b/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/__main__.py @@ -1,7 +1,7 @@ -from .server import main - -if __name__ == "__main__": - # Click will handle CLI arguments - import sys - - sys.exit(main()) # type: ignore[call-arg] +from .server import main + +if __name__ == "__main__": + # Click will handle CLI arguments + import sys + + sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/server.py b/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/server.py index e2b8d2ef2..db33490f7 100644 --- a/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/server.py +++ b/examples/servers/simple-streamablehttp-stateless/mcp_simple_streamablehttp_stateless/server.py @@ -1,116 +1,116 @@ -import logging - -import anyio -import click -import uvicorn -from mcp import types -from mcp.server import Server, ServerRequestContext -from starlette.middleware.cors import CORSMiddleware - -logger = logging.getLogger(__name__) - - -async def handle_list_tools( - ctx: ServerRequestContext, params: types.PaginatedRequestParams | None -) -> types.ListToolsResult: - return types.ListToolsResult( - tools=[ - types.Tool( - name="start-notification-stream", - description=("Sends a stream of notifications with configurable count and interval"), - input_schema={ - "type": "object", - "required": ["interval", "count", "caller"], - "properties": { - "interval": { - "type": "number", - "description": "Interval between notifications in seconds", - }, - "count": { - "type": "number", - "description": "Number of notifications to send", - }, - "caller": { - "type": "string", - "description": ("Identifier of the caller to include in notifications"), - }, - }, - }, - ) - ] - ) - - -async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: - arguments = params.arguments or {} - interval = arguments.get("interval", 1.0) - count = arguments.get("count", 5) - caller = arguments.get("caller", "unknown") - - # Send the specified number of notifications with the given interval - for i in range(count): - await ctx.session.send_log_message( - level="info", - data=f"Notification {i + 1}/{count} from caller: {caller}", - logger="notification_stream", - related_request_id=ctx.request_id, - ) - if i < count - 1: # Don't wait after the last notification - await anyio.sleep(interval) - - return types.CallToolResult( - content=[ - types.TextContent( - type="text", - text=(f"Sent {count} notifications with {interval}s interval for caller: {caller}"), - ) - ] - ) - - -@click.command() -@click.option("--port", default=3000, help="Port to listen on for HTTP") -@click.option( - "--log-level", - default="INFO", - help="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)", -) -@click.option( - "--json-response", - is_flag=True, - default=False, - help="Enable JSON responses instead of SSE streams", -) -def main( - port: int, - log_level: str, - json_response: bool, -) -> None: - # Configure logging - logging.basicConfig( - level=getattr(logging, log_level.upper()), - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - ) - - app = Server( - "mcp-streamable-http-stateless-demo", - on_list_tools=handle_list_tools, - on_call_tool=handle_call_tool, - ) - - starlette_app = app.streamable_http_app( - stateless_http=True, - json_response=json_response, - debug=True, - ) - - # Wrap ASGI application with CORS middleware to expose Mcp-Session-Id header - # for browser-based clients (ensures 500 errors get proper CORS headers) - starlette_app = CORSMiddleware( - starlette_app, - allow_origins=["*"], # Note: streamable_http_app() enforces localhost-only Origin by default - allow_methods=["GET", "POST", "DELETE"], # MCP streamable HTTP methods - expose_headers=["Mcp-Session-Id"], - ) - - uvicorn.run(starlette_app, host="127.0.0.1", port=port) +import logging + +import anyio +import click +import uvicorn +from mcp import types +from mcp.server import Server, ServerRequestContext +from starlette.middleware.cors import CORSMiddleware + +logger = logging.getLogger(__name__) + + +async def handle_list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="start-notification-stream", + description=("Sends a stream of notifications with configurable count and interval"), + input_schema={ + "type": "object", + "required": ["interval", "count", "caller"], + "properties": { + "interval": { + "type": "number", + "description": "Interval between notifications in seconds", + }, + "count": { + "type": "number", + "description": "Number of notifications to send", + }, + "caller": { + "type": "string", + "description": ("Identifier of the caller to include in notifications"), + }, + }, + }, + ) + ] + ) + + +async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: + arguments = params.arguments or {} + interval = arguments.get("interval", 1.0) + count = arguments.get("count", 5) + caller = arguments.get("caller", "unknown") + + # Send the specified number of notifications with the given interval + for i in range(count): + await ctx.session.send_log_message( + level="info", + data=f"Notification {i + 1}/{count} from caller: {caller}", + logger="notification_stream", + related_request_id=ctx.request_id, + ) + if i < count - 1: # Don't wait after the last notification + await anyio.sleep(interval) + + return types.CallToolResult( + content=[ + types.TextContent( + type="text", + text=(f"Sent {count} notifications with {interval}s interval for caller: {caller}"), + ) + ] + ) + + +@click.command() +@click.option("--port", default=3000, help="Port to listen on for HTTP") +@click.option( + "--log-level", + default="INFO", + help="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)", +) +@click.option( + "--json-response", + is_flag=True, + default=False, + help="Enable JSON responses instead of SSE streams", +) +def main( + port: int, + log_level: str, + json_response: bool, +) -> None: + # Configure logging + logging.basicConfig( + level=getattr(logging, log_level.upper()), + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + + app = Server( + "mcp-streamable-http-stateless-demo", + on_list_tools=handle_list_tools, + on_call_tool=handle_call_tool, + ) + + starlette_app = app.streamable_http_app( + stateless_http=True, + json_response=json_response, + debug=True, + ) + + # Wrap ASGI application with CORS middleware to expose Mcp-Session-Id header + # for browser-based clients (ensures 500 errors get proper CORS headers) + starlette_app = CORSMiddleware( + starlette_app, + allow_origins=["*"], # Note: streamable_http_app() enforces localhost-only Origin by default + allow_methods=["GET", "POST", "DELETE"], # MCP streamable HTTP methods + expose_headers=["Mcp-Session-Id"], + ) + + uvicorn.run(starlette_app, host="127.0.0.1", port=port) diff --git a/examples/servers/simple-streamablehttp-stateless/pyproject.toml b/examples/servers/simple-streamablehttp-stateless/pyproject.toml index 38f7b1b39..9f5af5dfd 100644 --- a/examples/servers/simple-streamablehttp-stateless/pyproject.toml +++ b/examples/servers/simple-streamablehttp-stateless/pyproject.toml @@ -1,36 +1,36 @@ -[project] -name = "mcp-simple-streamablehttp-stateless" -version = "0.1.0" -description = "A simple MCP server exposing a StreamableHttp transport in stateless mode" -readme = "README.md" -requires-python = ">=3.10" -authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] -keywords = ["mcp", "llm", "automation", "web", "fetch", "http", "streamable", "stateless"] -license = { text = "MIT" } -dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp", "starlette", "uvicorn"] - -[project.scripts] -mcp-simple-streamablehttp-stateless = "mcp_simple_streamablehttp_stateless.server:main" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["mcp_simple_streamablehttp_stateless"] - -[tool.pyright] -include = ["mcp_simple_streamablehttp_stateless"] -venvPath = "." -venv = ".venv" - -[tool.ruff.lint] -select = ["E", "F", "I"] -ignore = [] - -[tool.ruff] -line-length = 120 -target-version = "py310" - -[dependency-groups] -dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] +[project] +name = "mcp-simple-streamablehttp-stateless" +version = "0.1.0" +description = "A simple MCP server exposing a StreamableHttp transport in stateless mode" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +keywords = ["mcp", "llm", "automation", "web", "fetch", "http", "streamable", "stateless"] +license = { text = "MIT" } +dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp", "starlette", "uvicorn"] + +[project.scripts] +mcp-simple-streamablehttp-stateless = "mcp_simple_streamablehttp_stateless.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_streamablehttp_stateless"] + +[tool.pyright] +include = ["mcp_simple_streamablehttp_stateless"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/servers/simple-streamablehttp/README.md b/examples/servers/simple-streamablehttp/README.md index 3eed3320e..8d7d9211c 100644 --- a/examples/servers/simple-streamablehttp/README.md +++ b/examples/servers/simple-streamablehttp/README.md @@ -1,51 +1,51 @@ -# MCP Simple StreamableHttp Server Example - -A simple MCP server example demonstrating the StreamableHttp transport, which enables HTTP-based communication with MCP servers using streaming. - -## Features - -- Uses the StreamableHTTP transport for server-client communication -- Supports REST API operations (POST, GET, DELETE) for `/mcp` endpoint -- Ability to send multiple notifications over time to the client -- Resumability support via InMemoryEventStore - -## Usage - -Start the server on the default or custom port: - -```bash - -# Using custom port -uv run mcp-simple-streamablehttp --port 3000 - -# Custom logging level -uv run mcp-simple-streamablehttp --log-level DEBUG - -# Enable JSON responses instead of SSE streams -uv run mcp-simple-streamablehttp --json-response -``` - -The server exposes a tool named "start-notification-stream" that accepts three arguments: - -- `interval`: Time between notifications in seconds (e.g., 1.0) -- `count`: Number of notifications to send (e.g., 5) -- `caller`: Identifier string for the caller - -## Resumability Support - -This server includes resumability support through the InMemoryEventStore. This enables clients to: - -- Reconnect to the server after a disconnection -- Resume event streaming from where they left off using the Last-Event-ID header - -The server will: - -- Generate unique event IDs for each SSE message -- Store events in memory for later replay -- Replay missed events when a client reconnects with a Last-Event-ID header - -Note: The InMemoryEventStore is designed for demonstration purposes only. For production use, consider implementing a persistent storage solution. - -## Client - -You can connect to this server using an HTTP client, for now only Typescript SDK has streamable HTTP client examples or you can use [Inspector](https://github.com/modelcontextprotocol/inspector) +# MCP Simple StreamableHttp Server Example + +A simple MCP server example demonstrating the StreamableHttp transport, which enables HTTP-based communication with MCP servers using streaming. + +## Features + +- Uses the StreamableHTTP transport for server-client communication +- Supports REST API operations (POST, GET, DELETE) for `/mcp` endpoint +- Ability to send multiple notifications over time to the client +- Resumability support via InMemoryEventStore + +## Usage + +Start the server on the default or custom port: + +```bash + +# Using custom port +uv run mcp-simple-streamablehttp --port 3000 + +# Custom logging level +uv run mcp-simple-streamablehttp --log-level DEBUG + +# Enable JSON responses instead of SSE streams +uv run mcp-simple-streamablehttp --json-response +``` + +The server exposes a tool named "start-notification-stream" that accepts three arguments: + +- `interval`: Time between notifications in seconds (e.g., 1.0) +- `count`: Number of notifications to send (e.g., 5) +- `caller`: Identifier string for the caller + +## Resumability Support + +This server includes resumability support through the InMemoryEventStore. This enables clients to: + +- Reconnect to the server after a disconnection +- Resume event streaming from where they left off using the Last-Event-ID header + +The server will: + +- Generate unique event IDs for each SSE message +- Store events in memory for later replay +- Replay missed events when a client reconnects with a Last-Event-ID header + +Note: The InMemoryEventStore is designed for demonstration purposes only. For production use, consider implementing a persistent storage solution. + +## Client + +You can connect to this server using an HTTP client, for now only Typescript SDK has streamable HTTP client examples or you can use [Inspector](https://github.com/modelcontextprotocol/inspector) diff --git a/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/__main__.py b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/__main__.py index 21862e45f..846570119 100644 --- a/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/__main__.py +++ b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/__main__.py @@ -1,4 +1,4 @@ -from .server import main - -if __name__ == "__main__": - main() # type: ignore[call-arg] +from .server import main + +if __name__ == "__main__": + main() # type: ignore[call-arg] diff --git a/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/event_store.py b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/event_store.py index 3501fa47c..123a36526 100644 --- a/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/event_store.py +++ b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/event_store.py @@ -1,93 +1,93 @@ -"""In-memory event store for demonstrating resumability functionality. - -This is a simple implementation intended for examples and testing, -not for production use where a persistent storage solution would be more appropriate. -""" - -import logging -from collections import deque -from dataclasses import dataclass -from uuid import uuid4 - -from mcp.server.streamable_http import EventCallback, EventId, EventMessage, EventStore, StreamId -from mcp.types import JSONRPCMessage - -logger = logging.getLogger(__name__) - - -@dataclass -class EventEntry: - """Represents an event entry in the event store.""" - - event_id: EventId - stream_id: StreamId - message: JSONRPCMessage | None - - -class InMemoryEventStore(EventStore): - """Simple in-memory implementation of the EventStore interface for resumability. - This is primarily intended for examples and testing, not for production use - where a persistent storage solution would be more appropriate. - - This implementation keeps only the last N events per stream for memory efficiency. - """ - - def __init__(self, max_events_per_stream: int = 100): - """Initialize the event store. - - Args: - max_events_per_stream: Maximum number of events to keep per stream - """ - self.max_events_per_stream = max_events_per_stream - # for maintaining last N events per stream - self.streams: dict[StreamId, deque[EventEntry]] = {} - # event_id -> EventEntry for quick lookup - self.event_index: dict[EventId, EventEntry] = {} - - async def store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId: - """Stores an event with a generated event ID.""" - event_id = str(uuid4()) - event_entry = EventEntry(event_id=event_id, stream_id=stream_id, message=message) - - # Get or create deque for this stream - if stream_id not in self.streams: - self.streams[stream_id] = deque(maxlen=self.max_events_per_stream) - - # If deque is full, the oldest event will be automatically removed - # We need to remove it from the event_index as well - if len(self.streams[stream_id]) == self.max_events_per_stream: - oldest_event = self.streams[stream_id][0] - self.event_index.pop(oldest_event.event_id, None) - - # Add new event - self.streams[stream_id].append(event_entry) - self.event_index[event_id] = event_entry - - return event_id - - async def replay_events_after( - self, - last_event_id: EventId, - send_callback: EventCallback, - ) -> StreamId | None: - """Replays events that occurred after the specified event ID.""" - if last_event_id not in self.event_index: - logger.warning(f"Event ID {last_event_id} not found in store") - return None - - # Get the stream and find events after the last one - last_event = self.event_index[last_event_id] - stream_id = last_event.stream_id - stream_events = self.streams.get(last_event.stream_id, deque()) - - # Events in deque are already in chronological order - found_last = False - for event in stream_events: - if found_last: - # Skip priming events (None message) - if event.message is not None: - await send_callback(EventMessage(event.message, event.event_id)) - elif event.event_id == last_event_id: - found_last = True - - return stream_id +"""In-memory event store for demonstrating resumability functionality. + +This is a simple implementation intended for examples and testing, +not for production use where a persistent storage solution would be more appropriate. +""" + +import logging +from collections import deque +from dataclasses import dataclass +from uuid import uuid4 + +from mcp.server.streamable_http import EventCallback, EventId, EventMessage, EventStore, StreamId +from mcp.types import JSONRPCMessage + +logger = logging.getLogger(__name__) + + +@dataclass +class EventEntry: + """Represents an event entry in the event store.""" + + event_id: EventId + stream_id: StreamId + message: JSONRPCMessage | None + + +class InMemoryEventStore(EventStore): + """Simple in-memory implementation of the EventStore interface for resumability. + This is primarily intended for examples and testing, not for production use + where a persistent storage solution would be more appropriate. + + This implementation keeps only the last N events per stream for memory efficiency. + """ + + def __init__(self, max_events_per_stream: int = 100): + """Initialize the event store. + + Args: + max_events_per_stream: Maximum number of events to keep per stream + """ + self.max_events_per_stream = max_events_per_stream + # for maintaining last N events per stream + self.streams: dict[StreamId, deque[EventEntry]] = {} + # event_id -> EventEntry for quick lookup + self.event_index: dict[EventId, EventEntry] = {} + + async def store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId: + """Stores an event with a generated event ID.""" + event_id = str(uuid4()) + event_entry = EventEntry(event_id=event_id, stream_id=stream_id, message=message) + + # Get or create deque for this stream + if stream_id not in self.streams: + self.streams[stream_id] = deque(maxlen=self.max_events_per_stream) + + # If deque is full, the oldest event will be automatically removed + # We need to remove it from the event_index as well + if len(self.streams[stream_id]) == self.max_events_per_stream: + oldest_event = self.streams[stream_id][0] + self.event_index.pop(oldest_event.event_id, None) + + # Add new event + self.streams[stream_id].append(event_entry) + self.event_index[event_id] = event_entry + + return event_id + + async def replay_events_after( + self, + last_event_id: EventId, + send_callback: EventCallback, + ) -> StreamId | None: + """Replays events that occurred after the specified event ID.""" + if last_event_id not in self.event_index: + logger.warning(f"Event ID {last_event_id} not found in store") + return None + + # Get the stream and find events after the last one + last_event = self.event_index[last_event_id] + stream_id = last_event.stream_id + stream_events = self.streams.get(last_event.stream_id, deque()) + + # Events in deque are already in chronological order + found_last = False + for event in stream_events: + if found_last: + # Skip priming events (None message) + if event.message is not None: + await send_callback(EventMessage(event.message, event.event_id)) + elif event.event_id == last_event_id: + found_last = True + + return stream_id diff --git a/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py index ec9761d1b..a154e1cc0 100644 --- a/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py +++ b/examples/servers/simple-streamablehttp/mcp_simple_streamablehttp/server.py @@ -1,142 +1,142 @@ -import logging - -import anyio -import click -import uvicorn -from mcp import types -from mcp.server import Server, ServerRequestContext -from starlette.middleware.cors import CORSMiddleware - -from .event_store import InMemoryEventStore - -# Configure logging -logger = logging.getLogger(__name__) - - -async def handle_list_tools( - ctx: ServerRequestContext, params: types.PaginatedRequestParams | None -) -> types.ListToolsResult: - return types.ListToolsResult( - tools=[ - types.Tool( - name="start-notification-stream", - description="Sends a stream of notifications with configurable count and interval", - input_schema={ - "type": "object", - "required": ["interval", "count", "caller"], - "properties": { - "interval": { - "type": "number", - "description": "Interval between notifications in seconds", - }, - "count": { - "type": "number", - "description": "Number of notifications to send", - }, - "caller": { - "type": "string", - "description": "Identifier of the caller to include in notifications", - }, - }, - }, - ) - ] - ) - - -async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: - arguments = params.arguments or {} - interval = arguments.get("interval", 1.0) - count = arguments.get("count", 5) - caller = arguments.get("caller", "unknown") - - # Send the specified number of notifications with the given interval - for i in range(count): - # Include more detailed message for resumability demonstration - notification_msg = f"[{i + 1}/{count}] Event from '{caller}' - Use Last-Event-ID to resume if disconnected" - await ctx.session.send_log_message( - level="info", - data=notification_msg, - logger="notification_stream", - # Associates this notification with the original request - # Ensures notifications are sent to the correct response stream - # Without this, notifications will either go to: - # - a standalone SSE stream (if GET request is supported) - # - nowhere (if GET request isn't supported) - related_request_id=ctx.request_id, - ) - logger.debug(f"Sent notification {i + 1}/{count} for caller: {caller}") - if i < count - 1: # Don't wait after the last notification - await anyio.sleep(interval) - - # This will send a resource notification through standalone SSE - # established by GET request - await ctx.session.send_resource_updated(uri="http:///test_resource") - return types.CallToolResult( - content=[ - types.TextContent( - type="text", - text=(f"Sent {count} notifications with {interval}s interval for caller: {caller}"), - ) - ] - ) - - -@click.command() -@click.option("--port", default=3000, help="Port to listen on for HTTP") -@click.option( - "--log-level", - default="INFO", - help="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)", -) -@click.option( - "--json-response", - is_flag=True, - default=False, - help="Enable JSON responses instead of SSE streams", -) -def main( - port: int, - log_level: str, - json_response: bool, -) -> int: - # Configure logging - logging.basicConfig( - level=getattr(logging, log_level.upper()), - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - ) - - app = Server( - "mcp-streamable-http-demo", - on_list_tools=handle_list_tools, - on_call_tool=handle_call_tool, - ) - - # Create event store for resumability - # The InMemoryEventStore enables resumability support for StreamableHTTP transport. - # It stores SSE events with unique IDs, allowing clients to: - # 1. Receive event IDs for each SSE message - # 2. Resume streams by sending Last-Event-ID in GET requests - # 3. Replay missed events after reconnection - # Note: This in-memory implementation is for demonstration ONLY. - # For production, use a persistent storage solution. - event_store = InMemoryEventStore() - - starlette_app = app.streamable_http_app( - event_store=event_store, - json_response=json_response, - debug=True, - ) - - # Wrap ASGI application with CORS middleware to expose Mcp-Session-Id header - # for browser-based clients (ensures 500 errors get proper CORS headers) - starlette_app = CORSMiddleware( - starlette_app, - allow_origins=["*"], # Note: streamable_http_app() enforces localhost-only Origin by default - allow_methods=["GET", "POST", "DELETE"], # MCP streamable HTTP methods - expose_headers=["Mcp-Session-Id"], - ) - - uvicorn.run(starlette_app, host="127.0.0.1", port=port) - - return 0 +import logging + +import anyio +import click +import uvicorn +from mcp import types +from mcp.server import Server, ServerRequestContext +from starlette.middleware.cors import CORSMiddleware + +from .event_store import InMemoryEventStore + +# Configure logging +logger = logging.getLogger(__name__) + + +async def handle_list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="start-notification-stream", + description="Sends a stream of notifications with configurable count and interval", + input_schema={ + "type": "object", + "required": ["interval", "count", "caller"], + "properties": { + "interval": { + "type": "number", + "description": "Interval between notifications in seconds", + }, + "count": { + "type": "number", + "description": "Number of notifications to send", + }, + "caller": { + "type": "string", + "description": "Identifier of the caller to include in notifications", + }, + }, + }, + ) + ] + ) + + +async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: + arguments = params.arguments or {} + interval = arguments.get("interval", 1.0) + count = arguments.get("count", 5) + caller = arguments.get("caller", "unknown") + + # Send the specified number of notifications with the given interval + for i in range(count): + # Include more detailed message for resumability demonstration + notification_msg = f"[{i + 1}/{count}] Event from '{caller}' - Use Last-Event-ID to resume if disconnected" + await ctx.session.send_log_message( + level="info", + data=notification_msg, + logger="notification_stream", + # Associates this notification with the original request + # Ensures notifications are sent to the correct response stream + # Without this, notifications will either go to: + # - a standalone SSE stream (if GET request is supported) + # - nowhere (if GET request isn't supported) + related_request_id=ctx.request_id, + ) + logger.debug(f"Sent notification {i + 1}/{count} for caller: {caller}") + if i < count - 1: # Don't wait after the last notification + await anyio.sleep(interval) + + # This will send a resource notification through standalone SSE + # established by GET request + await ctx.session.send_resource_updated(uri="http:///test_resource") + return types.CallToolResult( + content=[ + types.TextContent( + type="text", + text=(f"Sent {count} notifications with {interval}s interval for caller: {caller}"), + ) + ] + ) + + +@click.command() +@click.option("--port", default=3000, help="Port to listen on for HTTP") +@click.option( + "--log-level", + default="INFO", + help="Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)", +) +@click.option( + "--json-response", + is_flag=True, + default=False, + help="Enable JSON responses instead of SSE streams", +) +def main( + port: int, + log_level: str, + json_response: bool, +) -> int: + # Configure logging + logging.basicConfig( + level=getattr(logging, log_level.upper()), + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + + app = Server( + "mcp-streamable-http-demo", + on_list_tools=handle_list_tools, + on_call_tool=handle_call_tool, + ) + + # Create event store for resumability + # The InMemoryEventStore enables resumability support for StreamableHTTP transport. + # It stores SSE events with unique IDs, allowing clients to: + # 1. Receive event IDs for each SSE message + # 2. Resume streams by sending Last-Event-ID in GET requests + # 3. Replay missed events after reconnection + # Note: This in-memory implementation is for demonstration ONLY. + # For production, use a persistent storage solution. + event_store = InMemoryEventStore() + + starlette_app = app.streamable_http_app( + event_store=event_store, + json_response=json_response, + debug=True, + ) + + # Wrap ASGI application with CORS middleware to expose Mcp-Session-Id header + # for browser-based clients (ensures 500 errors get proper CORS headers) + starlette_app = CORSMiddleware( + starlette_app, + allow_origins=["*"], # Note: streamable_http_app() enforces localhost-only Origin by default + allow_methods=["GET", "POST", "DELETE"], # MCP streamable HTTP methods + expose_headers=["Mcp-Session-Id"], + ) + + uvicorn.run(starlette_app, host="127.0.0.1", port=port) + + return 0 diff --git a/examples/servers/simple-streamablehttp/pyproject.toml b/examples/servers/simple-streamablehttp/pyproject.toml index 93f7baf41..4fc6a84ca 100644 --- a/examples/servers/simple-streamablehttp/pyproject.toml +++ b/examples/servers/simple-streamablehttp/pyproject.toml @@ -1,36 +1,36 @@ -[project] -name = "mcp-simple-streamablehttp" -version = "0.1.0" -description = "A simple MCP server exposing a StreamableHttp transport for testing" -readme = "README.md" -requires-python = ">=3.10" -authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] -keywords = ["mcp", "llm", "automation", "web", "fetch", "http", "streamable"] -license = { text = "MIT" } -dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp", "starlette", "uvicorn"] - -[project.scripts] -mcp-simple-streamablehttp = "mcp_simple_streamablehttp.server:main" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["mcp_simple_streamablehttp"] - -[tool.pyright] -include = ["mcp_simple_streamablehttp"] -venvPath = "." -venv = ".venv" - -[tool.ruff.lint] -select = ["E", "F", "I"] -ignore = [] - -[tool.ruff] -line-length = 120 -target-version = "py310" - -[dependency-groups] -dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] +[project] +name = "mcp-simple-streamablehttp" +version = "0.1.0" +description = "A simple MCP server exposing a StreamableHttp transport for testing" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +keywords = ["mcp", "llm", "automation", "web", "fetch", "http", "streamable"] +license = { text = "MIT" } +dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp", "starlette", "uvicorn"] + +[project.scripts] +mcp-simple-streamablehttp = "mcp_simple_streamablehttp.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_streamablehttp"] + +[tool.pyright] +include = ["mcp_simple_streamablehttp"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/servers/simple-task-interactive/README.md b/examples/servers/simple-task-interactive/README.md index b8f384cb4..537cc4e21 100644 --- a/examples/servers/simple-task-interactive/README.md +++ b/examples/servers/simple-task-interactive/README.md @@ -1,74 +1,74 @@ -# Simple Interactive Task Server - -A minimal MCP server demonstrating interactive tasks with elicitation and sampling. - -## Running - -```bash -cd examples/servers/simple-task-interactive -uv run mcp-simple-task-interactive -``` - -The server starts on `http://localhost:8000/mcp` by default. Use `--port` to change. - -## What it does - -This server exposes two tools: - -### `confirm_delete` (demonstrates elicitation) - -Asks the user for confirmation before "deleting" a file. - -- Uses `task.elicit()` to request user input -- Shows the elicitation flow: task -> input_required -> response -> complete - -### `write_haiku` (demonstrates sampling) - -Asks the LLM to write a haiku about a topic. - -- Uses `task.create_message()` to request LLM completion -- Shows the sampling flow: task -> input_required -> response -> complete - -## Usage with the client - -In one terminal, start the server: - -```bash -cd examples/servers/simple-task-interactive -uv run mcp-simple-task-interactive -``` - -In another terminal, run the interactive client: - -```bash -cd examples/clients/simple-task-interactive-client -uv run mcp-simple-task-interactive-client -``` - -## Expected server output - -When a client connects and calls the tools, you'll see: - -```text -Starting server on http://localhost:8000/mcp - -[Server] confirm_delete called for 'important.txt' -[Server] Task created: -[Server] Sending elicitation request to client... -[Server] Received elicitation response: action=accept, content={'confirm': True} -[Server] Completing task with result: Deleted 'important.txt' - -[Server] write_haiku called for topic 'autumn leaves' -[Server] Task created: -[Server] Sending sampling request to client... -[Server] Received sampling response: Cherry blossoms fall -Softly on the quiet pon... -[Server] Completing task with haiku -``` - -## Key concepts - -1. **ServerTaskContext**: Provides `elicit()` and `create_message()` for user interaction -2. **run_task()**: Spawns background work, auto-completes/fails, returns immediately -3. **TaskResultHandler**: Delivers queued messages and routes responses -4. **Response routing**: Responses are routed back to waiting resolvers +# Simple Interactive Task Server + +A minimal MCP server demonstrating interactive tasks with elicitation and sampling. + +## Running + +```bash +cd examples/servers/simple-task-interactive +uv run mcp-simple-task-interactive +``` + +The server starts on `http://localhost:8000/mcp` by default. Use `--port` to change. + +## What it does + +This server exposes two tools: + +### `confirm_delete` (demonstrates elicitation) + +Asks the user for confirmation before "deleting" a file. + +- Uses `task.elicit()` to request user input +- Shows the elicitation flow: task -> input_required -> response -> complete + +### `write_haiku` (demonstrates sampling) + +Asks the LLM to write a haiku about a topic. + +- Uses `task.create_message()` to request LLM completion +- Shows the sampling flow: task -> input_required -> response -> complete + +## Usage with the client + +In one terminal, start the server: + +```bash +cd examples/servers/simple-task-interactive +uv run mcp-simple-task-interactive +``` + +In another terminal, run the interactive client: + +```bash +cd examples/clients/simple-task-interactive-client +uv run mcp-simple-task-interactive-client +``` + +## Expected server output + +When a client connects and calls the tools, you'll see: + +```text +Starting server on http://localhost:8000/mcp + +[Server] confirm_delete called for 'important.txt' +[Server] Task created: +[Server] Sending elicitation request to client... +[Server] Received elicitation response: action=accept, content={'confirm': True} +[Server] Completing task with result: Deleted 'important.txt' + +[Server] write_haiku called for topic 'autumn leaves' +[Server] Task created: +[Server] Sending sampling request to client... +[Server] Received sampling response: Cherry blossoms fall +Softly on the quiet pon... +[Server] Completing task with haiku +``` + +## Key concepts + +1. **ServerTaskContext**: Provides `elicit()` and `create_message()` for user interaction +2. **run_task()**: Spawns background work, auto-completes/fails, returns immediately +3. **TaskResultHandler**: Delivers queued messages and routes responses +4. **Response routing**: Responses are routed back to waiting resolvers diff --git a/examples/servers/simple-task-interactive/mcp_simple_task_interactive/__main__.py b/examples/servers/simple-task-interactive/mcp_simple_task_interactive/__main__.py index e7ef16530..8d8f860d2 100644 --- a/examples/servers/simple-task-interactive/mcp_simple_task_interactive/__main__.py +++ b/examples/servers/simple-task-interactive/mcp_simple_task_interactive/__main__.py @@ -1,5 +1,5 @@ -import sys - -from .server import main - -sys.exit(main()) # type: ignore[call-arg] +import sys + +from .server import main + +sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/servers/simple-task-interactive/mcp_simple_task_interactive/server.py b/examples/servers/simple-task-interactive/mcp_simple_task_interactive/server.py index bc06e1208..7df0047f6 100644 --- a/examples/servers/simple-task-interactive/mcp_simple_task_interactive/server.py +++ b/examples/servers/simple-task-interactive/mcp_simple_task_interactive/server.py @@ -1,138 +1,138 @@ -"""Simple interactive task server demonstrating elicitation and sampling. - -This example shows the simplified task API where: -- server.experimental.enable_tasks() sets up all infrastructure -- ctx.experimental.run_task() handles task lifecycle automatically -- ServerTaskContext.elicit() and ServerTaskContext.create_message() queue requests properly -""" - -from typing import Any - -import click -import uvicorn -from mcp import types -from mcp.server import Server, ServerRequestContext -from mcp.server.experimental.task_context import ServerTaskContext - - -async def handle_list_tools( - ctx: ServerRequestContext, params: types.PaginatedRequestParams | None -) -> types.ListToolsResult: - return types.ListToolsResult( - tools=[ - types.Tool( - name="confirm_delete", - description="Asks for confirmation before deleting (demonstrates elicitation)", - input_schema={ - "type": "object", - "properties": {"filename": {"type": "string"}}, - }, - execution=types.ToolExecution(task_support=types.TASK_REQUIRED), - ), - types.Tool( - name="write_haiku", - description="Asks LLM to write a haiku (demonstrates sampling)", - input_schema={"type": "object", "properties": {"topic": {"type": "string"}}}, - execution=types.ToolExecution(task_support=types.TASK_REQUIRED), - ), - ] - ) - - -async def handle_confirm_delete(ctx: ServerRequestContext, arguments: dict[str, Any]) -> types.CreateTaskResult: - """Handle the confirm_delete tool - demonstrates elicitation.""" - ctx.experimental.validate_task_mode(types.TASK_REQUIRED) - - filename = arguments.get("filename", "unknown.txt") - print(f"\n[Server] confirm_delete called for '{filename}'") - - async def work(task: ServerTaskContext) -> types.CallToolResult: - print(f"[Server] Task {task.task_id} starting elicitation...") - - result = await task.elicit( - message=f"Are you sure you want to delete '{filename}'?", - requested_schema={ - "type": "object", - "properties": {"confirm": {"type": "boolean"}}, - "required": ["confirm"], - }, - ) - - print(f"[Server] Received elicitation response: action={result.action}, content={result.content}") - - if result.action == "accept" and result.content: - confirmed = result.content.get("confirm", False) - text = f"Deleted '{filename}'" if confirmed else "Deletion cancelled" - else: - text = "Deletion cancelled" - - print(f"[Server] Completing task with result: {text}") - return types.CallToolResult(content=[types.TextContent(type="text", text=text)]) - - return await ctx.experimental.run_task(work) - - -async def handle_write_haiku(ctx: ServerRequestContext, arguments: dict[str, Any]) -> types.CreateTaskResult: - """Handle the write_haiku tool - demonstrates sampling.""" - ctx.experimental.validate_task_mode(types.TASK_REQUIRED) - - topic = arguments.get("topic", "nature") - print(f"\n[Server] write_haiku called for topic '{topic}'") - - async def work(task: ServerTaskContext) -> types.CallToolResult: - print(f"[Server] Task {task.task_id} starting sampling...") - - result = await task.create_message( - messages=[ - types.SamplingMessage( - role="user", - content=types.TextContent(type="text", text=f"Write a haiku about {topic}"), - ) - ], - max_tokens=50, - ) - - haiku = "No response" - if isinstance(result.content, types.TextContent): - haiku = result.content.text - - print(f"[Server] Received sampling response: {haiku[:50]}...") - return types.CallToolResult(content=[types.TextContent(type="text", text=f"Haiku:\n{haiku}")]) - - return await ctx.experimental.run_task(work) - - -async def handle_call_tool( - ctx: ServerRequestContext, params: types.CallToolRequestParams -) -> types.CallToolResult | types.CreateTaskResult: - """Dispatch tool calls to their handlers.""" - arguments = params.arguments or {} - - if params.name == "confirm_delete": - return await handle_confirm_delete(ctx, arguments) - elif params.name == "write_haiku": - return await handle_write_haiku(ctx, arguments) - - return types.CallToolResult( - content=[types.TextContent(type="text", text=f"Unknown tool: {params.name}")], - is_error=True, - ) - - -server = Server( - "simple-task-interactive", - on_list_tools=handle_list_tools, - on_call_tool=handle_call_tool, -) - -# Enable task support - this auto-registers all handlers -server.experimental.enable_tasks() - - -@click.command() -@click.option("--port", default=8000, help="Port to listen on") -def main(port: int) -> int: - starlette_app = server.streamable_http_app() - print(f"Starting server on http://localhost:{port}/mcp") - uvicorn.run(starlette_app, host="127.0.0.1", port=port) - return 0 +"""Simple interactive task server demonstrating elicitation and sampling. + +This example shows the simplified task API where: +- server.experimental.enable_tasks() sets up all infrastructure +- ctx.experimental.run_task() handles task lifecycle automatically +- ServerTaskContext.elicit() and ServerTaskContext.create_message() queue requests properly +""" + +from typing import Any + +import click +import uvicorn +from mcp import types +from mcp.server import Server, ServerRequestContext +from mcp.server.experimental.task_context import ServerTaskContext + + +async def handle_list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="confirm_delete", + description="Asks for confirmation before deleting (demonstrates elicitation)", + input_schema={ + "type": "object", + "properties": {"filename": {"type": "string"}}, + }, + execution=types.ToolExecution(task_support=types.TASK_REQUIRED), + ), + types.Tool( + name="write_haiku", + description="Asks LLM to write a haiku (demonstrates sampling)", + input_schema={"type": "object", "properties": {"topic": {"type": "string"}}}, + execution=types.ToolExecution(task_support=types.TASK_REQUIRED), + ), + ] + ) + + +async def handle_confirm_delete(ctx: ServerRequestContext, arguments: dict[str, Any]) -> types.CreateTaskResult: + """Handle the confirm_delete tool - demonstrates elicitation.""" + ctx.experimental.validate_task_mode(types.TASK_REQUIRED) + + filename = arguments.get("filename", "unknown.txt") + print(f"\n[Server] confirm_delete called for '{filename}'") + + async def work(task: ServerTaskContext) -> types.CallToolResult: + print(f"[Server] Task {task.task_id} starting elicitation...") + + result = await task.elicit( + message=f"Are you sure you want to delete '{filename}'?", + requested_schema={ + "type": "object", + "properties": {"confirm": {"type": "boolean"}}, + "required": ["confirm"], + }, + ) + + print(f"[Server] Received elicitation response: action={result.action}, content={result.content}") + + if result.action == "accept" and result.content: + confirmed = result.content.get("confirm", False) + text = f"Deleted '{filename}'" if confirmed else "Deletion cancelled" + else: + text = "Deletion cancelled" + + print(f"[Server] Completing task with result: {text}") + return types.CallToolResult(content=[types.TextContent(type="text", text=text)]) + + return await ctx.experimental.run_task(work) + + +async def handle_write_haiku(ctx: ServerRequestContext, arguments: dict[str, Any]) -> types.CreateTaskResult: + """Handle the write_haiku tool - demonstrates sampling.""" + ctx.experimental.validate_task_mode(types.TASK_REQUIRED) + + topic = arguments.get("topic", "nature") + print(f"\n[Server] write_haiku called for topic '{topic}'") + + async def work(task: ServerTaskContext) -> types.CallToolResult: + print(f"[Server] Task {task.task_id} starting sampling...") + + result = await task.create_message( + messages=[ + types.SamplingMessage( + role="user", + content=types.TextContent(type="text", text=f"Write a haiku about {topic}"), + ) + ], + max_tokens=50, + ) + + haiku = "No response" + if isinstance(result.content, types.TextContent): + haiku = result.content.text + + print(f"[Server] Received sampling response: {haiku[:50]}...") + return types.CallToolResult(content=[types.TextContent(type="text", text=f"Haiku:\n{haiku}")]) + + return await ctx.experimental.run_task(work) + + +async def handle_call_tool( + ctx: ServerRequestContext, params: types.CallToolRequestParams +) -> types.CallToolResult | types.CreateTaskResult: + """Dispatch tool calls to their handlers.""" + arguments = params.arguments or {} + + if params.name == "confirm_delete": + return await handle_confirm_delete(ctx, arguments) + elif params.name == "write_haiku": + return await handle_write_haiku(ctx, arguments) + + return types.CallToolResult( + content=[types.TextContent(type="text", text=f"Unknown tool: {params.name}")], + is_error=True, + ) + + +server = Server( + "simple-task-interactive", + on_list_tools=handle_list_tools, + on_call_tool=handle_call_tool, +) + +# Enable task support - this auto-registers all handlers +server.experimental.enable_tasks() + + +@click.command() +@click.option("--port", default=8000, help="Port to listen on") +def main(port: int) -> int: + starlette_app = server.streamable_http_app() + print(f"Starting server on http://localhost:{port}/mcp") + uvicorn.run(starlette_app, host="127.0.0.1", port=port) + return 0 diff --git a/examples/servers/simple-task-interactive/pyproject.toml b/examples/servers/simple-task-interactive/pyproject.toml index 4ec977076..83205b708 100644 --- a/examples/servers/simple-task-interactive/pyproject.toml +++ b/examples/servers/simple-task-interactive/pyproject.toml @@ -1,43 +1,43 @@ -[project] -name = "mcp-simple-task-interactive" -version = "0.1.0" -description = "A simple MCP server demonstrating interactive tasks (elicitation & sampling)" -readme = "README.md" -requires-python = ">=3.10" -authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] -keywords = ["mcp", "llm", "tasks", "elicitation", "sampling"] -license = { text = "MIT" } -classifiers = [ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", -] -dependencies = ["anyio>=4.5", "click>=8.0", "mcp", "starlette", "uvicorn"] - -[project.scripts] -mcp-simple-task-interactive = "mcp_simple_task_interactive.server:main" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["mcp_simple_task_interactive"] - -[tool.pyright] -include = ["mcp_simple_task_interactive"] -venvPath = "." -venv = ".venv" - -[tool.ruff.lint] -select = ["E", "F", "I"] -ignore = [] - -[tool.ruff] -line-length = 120 -target-version = "py310" - -[dependency-groups] -dev = ["pyright>=1.1.378", "ruff>=0.6.9"] +[project] +name = "mcp-simple-task-interactive" +version = "0.1.0" +description = "A simple MCP server demonstrating interactive tasks (elicitation & sampling)" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +keywords = ["mcp", "llm", "tasks", "elicitation", "sampling"] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", +] +dependencies = ["anyio>=4.5", "click>=8.0", "mcp", "starlette", "uvicorn"] + +[project.scripts] +mcp-simple-task-interactive = "mcp_simple_task_interactive.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_task_interactive"] + +[tool.pyright] +include = ["mcp_simple_task_interactive"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "ruff>=0.6.9"] diff --git a/examples/servers/simple-task/README.md b/examples/servers/simple-task/README.md index 6914e0414..86eaef01a 100644 --- a/examples/servers/simple-task/README.md +++ b/examples/servers/simple-task/README.md @@ -1,37 +1,37 @@ -# Simple Task Server - -A minimal MCP server demonstrating the experimental tasks feature over streamable HTTP. - -## Running - -```bash -cd examples/servers/simple-task -uv run mcp-simple-task -``` - -The server starts on `http://localhost:8000/mcp` by default. Use `--port` to change. - -## What it does - -This server exposes a single tool `long_running_task` that: - -1. Must be called as a task (with `task` metadata in the request) -2. Takes ~3 seconds to complete -3. Sends status updates during execution -4. Returns a result when complete - -## Usage with the client - -In one terminal, start the server: - -```bash -cd examples/servers/simple-task -uv run mcp-simple-task -``` - -In another terminal, run the client: - -```bash -cd examples/clients/simple-task-client -uv run mcp-simple-task-client -``` +# Simple Task Server + +A minimal MCP server demonstrating the experimental tasks feature over streamable HTTP. + +## Running + +```bash +cd examples/servers/simple-task +uv run mcp-simple-task +``` + +The server starts on `http://localhost:8000/mcp` by default. Use `--port` to change. + +## What it does + +This server exposes a single tool `long_running_task` that: + +1. Must be called as a task (with `task` metadata in the request) +2. Takes ~3 seconds to complete +3. Sends status updates during execution +4. Returns a result when complete + +## Usage with the client + +In one terminal, start the server: + +```bash +cd examples/servers/simple-task +uv run mcp-simple-task +``` + +In another terminal, run the client: + +```bash +cd examples/clients/simple-task-client +uv run mcp-simple-task-client +``` diff --git a/examples/servers/simple-task/mcp_simple_task/__main__.py b/examples/servers/simple-task/mcp_simple_task/__main__.py index e7ef16530..8d8f860d2 100644 --- a/examples/servers/simple-task/mcp_simple_task/__main__.py +++ b/examples/servers/simple-task/mcp_simple_task/__main__.py @@ -1,5 +1,5 @@ -import sys - -from .server import main - -sys.exit(main()) # type: ignore[call-arg] +import sys + +from .server import main + +sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/servers/simple-task/mcp_simple_task/server.py b/examples/servers/simple-task/mcp_simple_task/server.py index 7583cd8f0..a2a0eb98c 100644 --- a/examples/servers/simple-task/mcp_simple_task/server.py +++ b/examples/servers/simple-task/mcp_simple_task/server.py @@ -1,70 +1,70 @@ -"""Simple task server demonstrating MCP tasks over streamable HTTP.""" - -import anyio -import click -import uvicorn -from mcp import types -from mcp.server import Server, ServerRequestContext -from mcp.server.experimental.task_context import ServerTaskContext - - -async def handle_list_tools( - ctx: ServerRequestContext, params: types.PaginatedRequestParams | None -) -> types.ListToolsResult: - return types.ListToolsResult( - tools=[ - types.Tool( - name="long_running_task", - description="A task that takes a few seconds to complete with status updates", - input_schema={"type": "object", "properties": {}}, - execution=types.ToolExecution(task_support=types.TASK_REQUIRED), - ) - ] - ) - - -async def handle_call_tool( - ctx: ServerRequestContext, params: types.CallToolRequestParams -) -> types.CallToolResult | types.CreateTaskResult: - """Dispatch tool calls to their handlers.""" - if params.name == "long_running_task": - ctx.experimental.validate_task_mode(types.TASK_REQUIRED) - - async def work(task: ServerTaskContext) -> types.CallToolResult: - await task.update_status("Starting work...") - await anyio.sleep(1) - - await task.update_status("Processing step 1...") - await anyio.sleep(1) - - await task.update_status("Processing step 2...") - await anyio.sleep(1) - - return types.CallToolResult(content=[types.TextContent(type="text", text="Task completed!")]) - - return await ctx.experimental.run_task(work) - - return types.CallToolResult( - content=[types.TextContent(type="text", text=f"Unknown tool: {params.name}")], - is_error=True, - ) - - -server = Server( - "simple-task-server", - on_list_tools=handle_list_tools, - on_call_tool=handle_call_tool, -) - -# One-line setup: auto-registers get_task, get_task_result, list_tasks, cancel_task -server.experimental.enable_tasks() - - -@click.command() -@click.option("--port", default=8000, help="Port to listen on") -def main(port: int) -> int: - starlette_app = server.streamable_http_app() - - print(f"Starting server on http://localhost:{port}/mcp") - uvicorn.run(starlette_app, host="127.0.0.1", port=port) - return 0 +"""Simple task server demonstrating MCP tasks over streamable HTTP.""" + +import anyio +import click +import uvicorn +from mcp import types +from mcp.server import Server, ServerRequestContext +from mcp.server.experimental.task_context import ServerTaskContext + + +async def handle_list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="long_running_task", + description="A task that takes a few seconds to complete with status updates", + input_schema={"type": "object", "properties": {}}, + execution=types.ToolExecution(task_support=types.TASK_REQUIRED), + ) + ] + ) + + +async def handle_call_tool( + ctx: ServerRequestContext, params: types.CallToolRequestParams +) -> types.CallToolResult | types.CreateTaskResult: + """Dispatch tool calls to their handlers.""" + if params.name == "long_running_task": + ctx.experimental.validate_task_mode(types.TASK_REQUIRED) + + async def work(task: ServerTaskContext) -> types.CallToolResult: + await task.update_status("Starting work...") + await anyio.sleep(1) + + await task.update_status("Processing step 1...") + await anyio.sleep(1) + + await task.update_status("Processing step 2...") + await anyio.sleep(1) + + return types.CallToolResult(content=[types.TextContent(type="text", text="Task completed!")]) + + return await ctx.experimental.run_task(work) + + return types.CallToolResult( + content=[types.TextContent(type="text", text=f"Unknown tool: {params.name}")], + is_error=True, + ) + + +server = Server( + "simple-task-server", + on_list_tools=handle_list_tools, + on_call_tool=handle_call_tool, +) + +# One-line setup: auto-registers get_task, get_task_result, list_tasks, cancel_task +server.experimental.enable_tasks() + + +@click.command() +@click.option("--port", default=8000, help="Port to listen on") +def main(port: int) -> int: + starlette_app = server.streamable_http_app() + + print(f"Starting server on http://localhost:{port}/mcp") + uvicorn.run(starlette_app, host="127.0.0.1", port=port) + return 0 diff --git a/examples/servers/simple-task/pyproject.toml b/examples/servers/simple-task/pyproject.toml index 921a1c34f..9877b591c 100644 --- a/examples/servers/simple-task/pyproject.toml +++ b/examples/servers/simple-task/pyproject.toml @@ -1,43 +1,43 @@ -[project] -name = "mcp-simple-task" -version = "0.1.0" -description = "A simple MCP server demonstrating tasks" -readme = "README.md" -requires-python = ">=3.10" -authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] -keywords = ["mcp", "llm", "tasks"] -license = { text = "MIT" } -classifiers = [ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", -] -dependencies = ["anyio>=4.5", "click>=8.0", "mcp", "starlette", "uvicorn"] - -[project.scripts] -mcp-simple-task = "mcp_simple_task.server:main" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["mcp_simple_task"] - -[tool.pyright] -include = ["mcp_simple_task"] -venvPath = "." -venv = ".venv" - -[tool.ruff.lint] -select = ["E", "F", "I"] -ignore = [] - -[tool.ruff] -line-length = 120 -target-version = "py310" - -[dependency-groups] -dev = ["pyright>=1.1.378", "ruff>=0.6.9"] +[project] +name = "mcp-simple-task" +version = "0.1.0" +description = "A simple MCP server demonstrating tasks" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +keywords = ["mcp", "llm", "tasks"] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", +] +dependencies = ["anyio>=4.5", "click>=8.0", "mcp", "starlette", "uvicorn"] + +[project.scripts] +mcp-simple-task = "mcp_simple_task.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_task"] + +[tool.pyright] +include = ["mcp_simple_task"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "ruff>=0.6.9"] diff --git a/examples/servers/simple-tool/.python-version b/examples/servers/simple-tool/.python-version index c8cfe3959..2951d9b02 100644 --- a/examples/servers/simple-tool/.python-version +++ b/examples/servers/simple-tool/.python-version @@ -1 +1 @@ -3.10 +3.10 diff --git a/examples/servers/simple-tool/README.md b/examples/servers/simple-tool/README.md index 7d3759f9d..7b3c1d64f 100644 --- a/examples/servers/simple-tool/README.md +++ b/examples/servers/simple-tool/README.md @@ -1,48 +1,48 @@ - -A simple MCP server that exposes a website fetching tool. - -## Usage - -Start the server using either stdio (default) or Streamable HTTP transport: - -```bash -# Using stdio transport (default) -uv run mcp-simple-tool - -# Using Streamable HTTP transport on custom port -uv run mcp-simple-tool --transport streamable-http --port 8000 -``` - -The server exposes a tool named "fetch" that accepts one required argument: - -- `url`: The URL of the website to fetch - -## Example - -Using the MCP client, you can use the tool like this using the STDIO transport: - -```python -import asyncio -from mcp.client.session import ClientSession -from mcp.client.stdio import StdioServerParameters, stdio_client - - -async def main(): - async with stdio_client( - StdioServerParameters(command="uv", args=["run", "mcp-simple-tool"]) - ) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - - # List available tools - tools = await session.list_tools() - print(tools) - - # Call the fetch tool - result = await session.call_tool("fetch", {"url": "https://example.com"}) - print(result) - - -asyncio.run(main()) - -``` + +A simple MCP server that exposes a website fetching tool. + +## Usage + +Start the server using either stdio (default) or Streamable HTTP transport: + +```bash +# Using stdio transport (default) +uv run mcp-simple-tool + +# Using Streamable HTTP transport on custom port +uv run mcp-simple-tool --transport streamable-http --port 8000 +``` + +The server exposes a tool named "fetch" that accepts one required argument: + +- `url`: The URL of the website to fetch + +## Example + +Using the MCP client, you can use the tool like this using the STDIO transport: + +```python +import asyncio +from mcp.client.session import ClientSession +from mcp.client.stdio import StdioServerParameters, stdio_client + + +async def main(): + async with stdio_client( + StdioServerParameters(command="uv", args=["run", "mcp-simple-tool"]) + ) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + + # List available tools + tools = await session.list_tools() + print(tools) + + # Call the fetch tool + result = await session.call_tool("fetch", {"url": "https://example.com"}) + print(result) + + +asyncio.run(main()) + +``` diff --git a/examples/servers/simple-tool/mcp_simple_tool/__main__.py b/examples/servers/simple-tool/mcp_simple_tool/__main__.py index e7ef16530..8d8f860d2 100644 --- a/examples/servers/simple-tool/mcp_simple_tool/__main__.py +++ b/examples/servers/simple-tool/mcp_simple_tool/__main__.py @@ -1,5 +1,5 @@ -import sys - -from .server import main - -sys.exit(main()) # type: ignore[call-arg] +import sys + +from .server import main + +sys.exit(main()) # type: ignore[call-arg] diff --git a/examples/servers/simple-tool/mcp_simple_tool/server.py b/examples/servers/simple-tool/mcp_simple_tool/server.py index 226058b95..71234ce82 100644 --- a/examples/servers/simple-tool/mcp_simple_tool/server.py +++ b/examples/servers/simple-tool/mcp_simple_tool/server.py @@ -1,80 +1,80 @@ -import anyio -import click -from mcp import types -from mcp.server import Server, ServerRequestContext -from mcp.shared._httpx_utils import create_mcp_http_client - - -async def fetch_website( - url: str, -) -> list[types.ContentBlock]: - headers = {"User-Agent": "MCP Test Server (github.com/modelcontextprotocol/python-sdk)"} - async with create_mcp_http_client(headers=headers) as client: - response = await client.get(url) - response.raise_for_status() - return [types.TextContent(type="text", text=response.text)] - - -async def handle_list_tools( - ctx: ServerRequestContext, params: types.PaginatedRequestParams | None -) -> types.ListToolsResult: - return types.ListToolsResult( - tools=[ - types.Tool( - name="fetch", - title="Website Fetcher", - description="Fetches a website and returns its content", - input_schema={ - "type": "object", - "required": ["url"], - "properties": { - "url": { - "type": "string", - "description": "URL to fetch", - } - }, - }, - ) - ] - ) - - -async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: - if params.name != "fetch": - raise ValueError(f"Unknown tool: {params.name}") - arguments = params.arguments or {} - if "url" not in arguments: - raise ValueError("Missing required argument 'url'") - content = await fetch_website(arguments["url"]) - return types.CallToolResult(content=content) - - -@click.command() -@click.option("--port", default=8000, help="Port to listen on for HTTP") -@click.option( - "--transport", - type=click.Choice(["stdio", "streamable-http"]), - default="stdio", - help="Transport type", -) -def main(port: int, transport: str) -> int: - app = Server( - "mcp-website-fetcher", - on_list_tools=handle_list_tools, - on_call_tool=handle_call_tool, - ) - - if transport == "streamable-http": - import uvicorn - - uvicorn.run(app.streamable_http_app(), host="127.0.0.1", port=port) - else: - from mcp.server.stdio import stdio_server - - async def arun(): - async with stdio_server() as streams: - await app.run(streams[0], streams[1], app.create_initialization_options()) - - anyio.run(arun) - - return 0 +import anyio +import click +from mcp import types +from mcp.server import Server, ServerRequestContext +from mcp.shared._httpx_utils import create_mcp_http_client + + +async def fetch_website( + url: str, +) -> list[types.ContentBlock]: + headers = {"User-Agent": "MCP Test Server (github.com/modelcontextprotocol/python-sdk)"} + async with create_mcp_http_client(headers=headers) as client: + response = await client.get(url) + response.raise_for_status() + return [types.TextContent(type="text", text=response.text)] + + +async def handle_list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListToolsResult: + return types.ListToolsResult( + tools=[ + types.Tool( + name="fetch", + title="Website Fetcher", + description="Fetches a website and returns its content", + input_schema={ + "type": "object", + "required": ["url"], + "properties": { + "url": { + "type": "string", + "description": "URL to fetch", + } + }, + }, + ) + ] + ) + + +async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: + if params.name != "fetch": + raise ValueError(f"Unknown tool: {params.name}") + arguments = params.arguments or {} + if "url" not in arguments: + raise ValueError("Missing required argument 'url'") + content = await fetch_website(arguments["url"]) + return types.CallToolResult(content=content) + + +@click.command() +@click.option("--port", default=8000, help="Port to listen on for HTTP") +@click.option( + "--transport", + type=click.Choice(["stdio", "streamable-http"]), + default="stdio", + help="Transport type", +) +def main(port: int, transport: str) -> int: + app = Server( + "mcp-website-fetcher", + on_list_tools=handle_list_tools, + on_call_tool=handle_call_tool, + ) + + if transport == "streamable-http": + import uvicorn + + uvicorn.run(app.streamable_http_app(), host="127.0.0.1", port=port) + else: + from mcp.server.stdio import stdio_server + + async def arun(): + async with stdio_server() as streams: + await app.run(streams[0], streams[1], app.create_initialization_options()) + + anyio.run(arun) + + return 0 diff --git a/examples/servers/simple-tool/pyproject.toml b/examples/servers/simple-tool/pyproject.toml index 022e039e0..ed9f95e62 100644 --- a/examples/servers/simple-tool/pyproject.toml +++ b/examples/servers/simple-tool/pyproject.toml @@ -1,43 +1,43 @@ -[project] -name = "mcp-simple-tool" -version = "0.1.0" -description = "A simple MCP server exposing a website fetching tool" -readme = "README.md" -requires-python = ">=3.10" -authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] -keywords = ["mcp", "llm", "automation", "web", "fetch"] -license = { text = "MIT" } -classifiers = [ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", -] -dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp"] - -[project.scripts] -mcp-simple-tool = "mcp_simple_tool.server:main" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["mcp_simple_tool"] - -[tool.pyright] -include = ["mcp_simple_tool"] -venvPath = "." -venv = ".venv" - -[tool.ruff.lint] -select = ["E", "F", "I"] -ignore = [] - -[tool.ruff] -line-length = 120 -target-version = "py310" - -[dependency-groups] -dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] +[project] +name = "mcp-simple-tool" +version = "0.1.0" +description = "A simple MCP server exposing a website fetching tool" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +keywords = ["mcp", "llm", "automation", "web", "fetch"] +license = { text = "MIT" } +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", +] +dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp"] + +[project.scripts] +mcp-simple-tool = "mcp_simple_tool.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_simple_tool"] + +[tool.pyright] +include = ["mcp_simple_tool"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/servers/sse-polling-demo/README.md b/examples/servers/sse-polling-demo/README.md index e9d4446e1..d02837a7e 100644 --- a/examples/servers/sse-polling-demo/README.md +++ b/examples/servers/sse-polling-demo/README.md @@ -1,36 +1,36 @@ -# MCP SSE Polling Demo Server - -Demonstrates the SSE polling pattern with server-initiated stream close for long-running tasks (SEP-1699). - -## Features - -- Priming events (automatic with EventStore) -- Server-initiated stream close via `close_sse_stream()` callback -- Client auto-reconnect with Last-Event-ID -- Progress notifications during long-running tasks -- Configurable retry interval - -## Usage - -```bash -# Start server on default port -uv run mcp-sse-polling-demo --port 3000 - -# Custom retry interval (milliseconds) -uv run mcp-sse-polling-demo --port 3000 --retry-interval 100 -``` - -## Tool: process_batch - -Processes items with periodic checkpoints that trigger SSE stream closes: - -- `items`: Number of items to process (1-100, default: 10) -- `checkpoint_every`: Close stream after this many items (1-20, default: 3) - -## Client - -Use the companion `mcp-sse-polling-client` to test: - -```bash -uv run mcp-sse-polling-client --url http://localhost:3000/mcp -``` +# MCP SSE Polling Demo Server + +Demonstrates the SSE polling pattern with server-initiated stream close for long-running tasks (SEP-1699). + +## Features + +- Priming events (automatic with EventStore) +- Server-initiated stream close via `close_sse_stream()` callback +- Client auto-reconnect with Last-Event-ID +- Progress notifications during long-running tasks +- Configurable retry interval + +## Usage + +```bash +# Start server on default port +uv run mcp-sse-polling-demo --port 3000 + +# Custom retry interval (milliseconds) +uv run mcp-sse-polling-demo --port 3000 --retry-interval 100 +``` + +## Tool: process_batch + +Processes items with periodic checkpoints that trigger SSE stream closes: + +- `items`: Number of items to process (1-100, default: 10) +- `checkpoint_every`: Close stream after this many items (1-20, default: 3) + +## Client + +Use the companion `mcp-sse-polling-client` to test: + +```bash +uv run mcp-sse-polling-client --url http://localhost:3000/mcp +``` diff --git a/examples/servers/sse-polling-demo/mcp_sse_polling_demo/__init__.py b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/__init__.py index 46af2fdee..c45db674d 100644 --- a/examples/servers/sse-polling-demo/mcp_sse_polling_demo/__init__.py +++ b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/__init__.py @@ -1 +1 @@ -"""SSE Polling Demo Server - demonstrates close_sse_stream for long-running tasks.""" +"""SSE Polling Demo Server - demonstrates close_sse_stream for long-running tasks.""" diff --git a/examples/servers/sse-polling-demo/mcp_sse_polling_demo/__main__.py b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/__main__.py index 23cfc85e1..565c3c8cd 100644 --- a/examples/servers/sse-polling-demo/mcp_sse_polling_demo/__main__.py +++ b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/__main__.py @@ -1,6 +1,6 @@ -"""Entry point for the SSE Polling Demo server.""" - -from .server import main - -if __name__ == "__main__": - main() +"""Entry point for the SSE Polling Demo server.""" + +from .server import main + +if __name__ == "__main__": + main() diff --git a/examples/servers/sse-polling-demo/mcp_sse_polling_demo/event_store.py b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/event_store.py index c77bddef3..20acfe61b 100644 --- a/examples/servers/sse-polling-demo/mcp_sse_polling_demo/event_store.py +++ b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/event_store.py @@ -1,98 +1,98 @@ -"""In-memory event store for demonstrating resumability functionality. - -This is a simple implementation intended for examples and testing, -not for production use where a persistent storage solution would be more appropriate. -""" - -import logging -from collections import deque -from dataclasses import dataclass -from uuid import uuid4 - -from mcp.server.streamable_http import EventCallback, EventId, EventMessage, EventStore, StreamId -from mcp.types import JSONRPCMessage - -logger = logging.getLogger(__name__) - - -@dataclass -class EventEntry: - """Represents an event entry in the event store.""" - - event_id: EventId - stream_id: StreamId - message: JSONRPCMessage | None # None for priming events - - -class InMemoryEventStore(EventStore): - """Simple in-memory implementation of the EventStore interface for resumability. - This is primarily intended for examples and testing, not for production use - where a persistent storage solution would be more appropriate. - - This implementation keeps only the last N events per stream for memory efficiency. - """ - - def __init__(self, max_events_per_stream: int = 100): - """Initialize the event store. - - Args: - max_events_per_stream: Maximum number of events to keep per stream - """ - self.max_events_per_stream = max_events_per_stream - # for maintaining last N events per stream - self.streams: dict[StreamId, deque[EventEntry]] = {} - # event_id -> EventEntry for quick lookup - self.event_index: dict[EventId, EventEntry] = {} - - async def store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId: - """Stores an event with a generated event ID. - - Args: - stream_id: ID of the stream the event belongs to - message: The message to store, or None for priming events - """ - event_id = str(uuid4()) - event_entry = EventEntry(event_id=event_id, stream_id=stream_id, message=message) - - # Get or create deque for this stream - if stream_id not in self.streams: - self.streams[stream_id] = deque(maxlen=self.max_events_per_stream) - - # If deque is full, the oldest event will be automatically removed - # We need to remove it from the event_index as well - if len(self.streams[stream_id]) == self.max_events_per_stream: - oldest_event = self.streams[stream_id][0] - self.event_index.pop(oldest_event.event_id, None) - - # Add new event - self.streams[stream_id].append(event_entry) - self.event_index[event_id] = event_entry - - return event_id - - async def replay_events_after( - self, - last_event_id: EventId, - send_callback: EventCallback, - ) -> StreamId | None: - """Replays events that occurred after the specified event ID.""" - if last_event_id not in self.event_index: - logger.warning(f"Event ID {last_event_id} not found in store") - return None - - # Get the stream and find events after the last one - last_event = self.event_index[last_event_id] - stream_id = last_event.stream_id - stream_events = self.streams.get(last_event.stream_id, deque()) - - # Events in deque are already in chronological order - found_last = False - for event in stream_events: - if found_last: - # Skip priming events (None messages) during replay - if event.message is not None: - await send_callback(EventMessage(event.message, event.event_id)) - elif event.event_id == last_event_id: - found_last = True - - return stream_id +"""In-memory event store for demonstrating resumability functionality. + +This is a simple implementation intended for examples and testing, +not for production use where a persistent storage solution would be more appropriate. +""" + +import logging +from collections import deque +from dataclasses import dataclass +from uuid import uuid4 + +from mcp.server.streamable_http import EventCallback, EventId, EventMessage, EventStore, StreamId +from mcp.types import JSONRPCMessage + +logger = logging.getLogger(__name__) + + +@dataclass +class EventEntry: + """Represents an event entry in the event store.""" + + event_id: EventId + stream_id: StreamId + message: JSONRPCMessage | None # None for priming events + + +class InMemoryEventStore(EventStore): + """Simple in-memory implementation of the EventStore interface for resumability. + This is primarily intended for examples and testing, not for production use + where a persistent storage solution would be more appropriate. + + This implementation keeps only the last N events per stream for memory efficiency. + """ + + def __init__(self, max_events_per_stream: int = 100): + """Initialize the event store. + + Args: + max_events_per_stream: Maximum number of events to keep per stream + """ + self.max_events_per_stream = max_events_per_stream + # for maintaining last N events per stream + self.streams: dict[StreamId, deque[EventEntry]] = {} + # event_id -> EventEntry for quick lookup + self.event_index: dict[EventId, EventEntry] = {} + + async def store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId: + """Stores an event with a generated event ID. + + Args: + stream_id: ID of the stream the event belongs to + message: The message to store, or None for priming events + """ + event_id = str(uuid4()) + event_entry = EventEntry(event_id=event_id, stream_id=stream_id, message=message) + + # Get or create deque for this stream + if stream_id not in self.streams: + self.streams[stream_id] = deque(maxlen=self.max_events_per_stream) + + # If deque is full, the oldest event will be automatically removed + # We need to remove it from the event_index as well + if len(self.streams[stream_id]) == self.max_events_per_stream: + oldest_event = self.streams[stream_id][0] + self.event_index.pop(oldest_event.event_id, None) + + # Add new event + self.streams[stream_id].append(event_entry) + self.event_index[event_id] = event_entry + + return event_id + + async def replay_events_after( + self, + last_event_id: EventId, + send_callback: EventCallback, + ) -> StreamId | None: + """Replays events that occurred after the specified event ID.""" + if last_event_id not in self.event_index: + logger.warning(f"Event ID {last_event_id} not found in store") + return None + + # Get the stream and find events after the last one + last_event = self.event_index[last_event_id] + stream_id = last_event.stream_id + stream_events = self.streams.get(last_event.stream_id, deque()) + + # Events in deque are already in chronological order + found_last = False + for event in stream_events: + if found_last: + # Skip priming events (None messages) during replay + if event.message is not None: + await send_callback(EventMessage(event.message, event.event_id)) + elif event.event_id == last_event_id: + found_last = True + + return stream_id diff --git a/examples/servers/sse-polling-demo/mcp_sse_polling_demo/server.py b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/server.py index 14bc174c4..548ce6fb8 100644 --- a/examples/servers/sse-polling-demo/mcp_sse_polling_demo/server.py +++ b/examples/servers/sse-polling-demo/mcp_sse_polling_demo/server.py @@ -1,160 +1,160 @@ -"""SSE Polling Demo Server - -Demonstrates the SSE polling pattern with close_sse_stream() for long-running tasks. - -Features demonstrated: -- Priming events (automatic with EventStore) -- Server-initiated stream close via close_sse_stream callback -- Client auto-reconnect with Last-Event-ID -- Progress notifications during long-running tasks - -Run with: - uv run mcp-sse-polling-demo --port 3000 -""" - -import logging - -import anyio -import click -import uvicorn -from mcp import types -from mcp.server import Server, ServerRequestContext - -from .event_store import InMemoryEventStore - -logger = logging.getLogger(__name__) - - -async def handle_list_tools( - ctx: ServerRequestContext, params: types.PaginatedRequestParams | None -) -> types.ListToolsResult: - """List available tools.""" - return types.ListToolsResult( - tools=[ - types.Tool( - name="process_batch", - description=( - "Process a batch of items with periodic checkpoints. " - "Demonstrates SSE polling where server closes stream periodically." - ), - input_schema={ - "type": "object", - "properties": { - "items": { - "type": "integer", - "description": "Number of items to process (1-100)", - "default": 10, - }, - "checkpoint_every": { - "type": "integer", - "description": "Close stream after this many items (1-20)", - "default": 3, - }, - }, - }, - ) - ] - ) - - -async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: - """Handle tool calls.""" - arguments = params.arguments or {} - - if params.name == "process_batch": - items = arguments.get("items", 10) - checkpoint_every = arguments.get("checkpoint_every", 3) - - if items < 1 or items > 100: - return types.CallToolResult( - content=[types.TextContent(type="text", text="Error: items must be between 1 and 100")] - ) - if checkpoint_every < 1 or checkpoint_every > 20: - return types.CallToolResult( - content=[types.TextContent(type="text", text="Error: checkpoint_every must be between 1 and 20")] - ) - - await ctx.session.send_log_message( - level="info", - data=f"Starting batch processing of {items} items...", - logger="process_batch", - related_request_id=ctx.request_id, - ) - - for i in range(1, items + 1): - # Simulate work - await anyio.sleep(0.5) - - # Report progress - await ctx.session.send_log_message( - level="info", - data=f"[{i}/{items}] Processing item {i}", - logger="process_batch", - related_request_id=ctx.request_id, - ) - - # Checkpoint: close stream to trigger client reconnect - if i % checkpoint_every == 0 and i < items: - await ctx.session.send_log_message( - level="info", - data=f"Checkpoint at item {i} - closing SSE stream for polling", - logger="process_batch", - related_request_id=ctx.request_id, - ) - if ctx.close_sse_stream: - logger.info(f"Closing SSE stream at checkpoint {i}") - await ctx.close_sse_stream() - # Wait for client to reconnect (must be > retry_interval of 100ms) - await anyio.sleep(0.2) - - return types.CallToolResult( - content=[ - types.TextContent( - type="text", - text=f"Successfully processed {items} items with checkpoints every {checkpoint_every} items", - ) - ] - ) - - return types.CallToolResult(content=[types.TextContent(type="text", text=f"Unknown tool: {params.name}")]) - - -@click.command() -@click.option("--port", default=3000, help="Port to listen on") -@click.option( - "--log-level", - default="INFO", - help="Logging level (DEBUG, INFO, WARNING, ERROR)", -) -@click.option( - "--retry-interval", - default=100, - help="SSE retry interval in milliseconds (sent to client)", -) -def main(port: int, log_level: str, retry_interval: int) -> int: - """Run the SSE Polling Demo server.""" - logging.basicConfig( - level=getattr(logging, log_level.upper()), - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", - ) - - app = Server( - "sse-polling-demo", - on_list_tools=handle_list_tools, - on_call_tool=handle_call_tool, - ) - - starlette_app = app.streamable_http_app( - event_store=InMemoryEventStore(), - retry_interval=retry_interval, - debug=True, - ) - - logger.info(f"SSE Polling Demo server starting on port {port}") - logger.info("Try: POST /mcp with tools/call for 'process_batch'") - uvicorn.run(starlette_app, host="127.0.0.1", port=port) - return 0 - - -if __name__ == "__main__": - main() +"""SSE Polling Demo Server + +Demonstrates the SSE polling pattern with close_sse_stream() for long-running tasks. + +Features demonstrated: +- Priming events (automatic with EventStore) +- Server-initiated stream close via close_sse_stream callback +- Client auto-reconnect with Last-Event-ID +- Progress notifications during long-running tasks + +Run with: + uv run mcp-sse-polling-demo --port 3000 +""" + +import logging + +import anyio +import click +import uvicorn +from mcp import types +from mcp.server import Server, ServerRequestContext + +from .event_store import InMemoryEventStore + +logger = logging.getLogger(__name__) + + +async def handle_list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListToolsResult: + """List available tools.""" + return types.ListToolsResult( + tools=[ + types.Tool( + name="process_batch", + description=( + "Process a batch of items with periodic checkpoints. " + "Demonstrates SSE polling where server closes stream periodically." + ), + input_schema={ + "type": "object", + "properties": { + "items": { + "type": "integer", + "description": "Number of items to process (1-100)", + "default": 10, + }, + "checkpoint_every": { + "type": "integer", + "description": "Close stream after this many items (1-20)", + "default": 3, + }, + }, + }, + ) + ] + ) + + +async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: + """Handle tool calls.""" + arguments = params.arguments or {} + + if params.name == "process_batch": + items = arguments.get("items", 10) + checkpoint_every = arguments.get("checkpoint_every", 3) + + if items < 1 or items > 100: + return types.CallToolResult( + content=[types.TextContent(type="text", text="Error: items must be between 1 and 100")] + ) + if checkpoint_every < 1 or checkpoint_every > 20: + return types.CallToolResult( + content=[types.TextContent(type="text", text="Error: checkpoint_every must be between 1 and 20")] + ) + + await ctx.session.send_log_message( + level="info", + data=f"Starting batch processing of {items} items...", + logger="process_batch", + related_request_id=ctx.request_id, + ) + + for i in range(1, items + 1): + # Simulate work + await anyio.sleep(0.5) + + # Report progress + await ctx.session.send_log_message( + level="info", + data=f"[{i}/{items}] Processing item {i}", + logger="process_batch", + related_request_id=ctx.request_id, + ) + + # Checkpoint: close stream to trigger client reconnect + if i % checkpoint_every == 0 and i < items: + await ctx.session.send_log_message( + level="info", + data=f"Checkpoint at item {i} - closing SSE stream for polling", + logger="process_batch", + related_request_id=ctx.request_id, + ) + if ctx.close_sse_stream: + logger.info(f"Closing SSE stream at checkpoint {i}") + await ctx.close_sse_stream() + # Wait for client to reconnect (must be > retry_interval of 100ms) + await anyio.sleep(0.2) + + return types.CallToolResult( + content=[ + types.TextContent( + type="text", + text=f"Successfully processed {items} items with checkpoints every {checkpoint_every} items", + ) + ] + ) + + return types.CallToolResult(content=[types.TextContent(type="text", text=f"Unknown tool: {params.name}")]) + + +@click.command() +@click.option("--port", default=3000, help="Port to listen on") +@click.option( + "--log-level", + default="INFO", + help="Logging level (DEBUG, INFO, WARNING, ERROR)", +) +@click.option( + "--retry-interval", + default=100, + help="SSE retry interval in milliseconds (sent to client)", +) +def main(port: int, log_level: str, retry_interval: int) -> int: + """Run the SSE Polling Demo server.""" + logging.basicConfig( + level=getattr(logging, log_level.upper()), + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + + app = Server( + "sse-polling-demo", + on_list_tools=handle_list_tools, + on_call_tool=handle_call_tool, + ) + + starlette_app = app.streamable_http_app( + event_store=InMemoryEventStore(), + retry_interval=retry_interval, + debug=True, + ) + + logger.info(f"SSE Polling Demo server starting on port {port}") + logger.info("Try: POST /mcp with tools/call for 'process_batch'") + uvicorn.run(starlette_app, host="127.0.0.1", port=port) + return 0 + + +if __name__ == "__main__": + main() diff --git a/examples/servers/sse-polling-demo/pyproject.toml b/examples/servers/sse-polling-demo/pyproject.toml index 400f6580b..7c4ffa2f9 100644 --- a/examples/servers/sse-polling-demo/pyproject.toml +++ b/examples/servers/sse-polling-demo/pyproject.toml @@ -1,36 +1,36 @@ -[project] -name = "mcp-sse-polling-demo" -version = "0.1.0" -description = "Demo server showing SSE polling with close_sse_stream for long-running tasks" -readme = "README.md" -requires-python = ">=3.10" -authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] -keywords = ["mcp", "sse", "polling", "streamable", "http"] -license = { text = "MIT" } -dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp", "starlette", "uvicorn"] - -[project.scripts] -mcp-sse-polling-demo = "mcp_sse_polling_demo.server:main" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["mcp_sse_polling_demo"] - -[tool.pyright] -include = ["mcp_sse_polling_demo"] -venvPath = "." -venv = ".venv" - -[tool.ruff.lint] -select = ["E", "F", "I"] -ignore = [] - -[tool.ruff] -line-length = 120 -target-version = "py310" - -[dependency-groups] -dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] +[project] +name = "mcp-sse-polling-demo" +version = "0.1.0" +description = "Demo server showing SSE polling with close_sse_stream for long-running tasks" +readme = "README.md" +requires-python = ">=3.10" +authors = [{ name = "Model Context Protocol a Series of LF Projects, LLC." }] +keywords = ["mcp", "sse", "polling", "streamable", "http"] +license = { text = "MIT" } +dependencies = ["anyio>=4.5", "click>=8.2.0", "httpx>=0.27", "mcp", "starlette", "uvicorn"] + +[project.scripts] +mcp-sse-polling-demo = "mcp_sse_polling_demo.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mcp_sse_polling_demo"] + +[tool.pyright] +include = ["mcp_sse_polling_demo"] +venvPath = "." +venv = ".venv" + +[tool.ruff.lint] +select = ["E", "F", "I"] +ignore = [] + +[tool.ruff] +line-length = 120 +target-version = "py310" + +[dependency-groups] +dev = ["pyright>=1.1.378", "pytest>=8.3.3", "ruff>=0.6.9"] diff --git a/examples/servers/structured-output-lowlevel/mcp_structured_output_lowlevel/__init__.py b/examples/servers/structured-output-lowlevel/mcp_structured_output_lowlevel/__init__.py index c65905675..1fb66cbe8 100644 --- a/examples/servers/structured-output-lowlevel/mcp_structured_output_lowlevel/__init__.py +++ b/examples/servers/structured-output-lowlevel/mcp_structured_output_lowlevel/__init__.py @@ -1 +1 @@ -"""Example of structured output with low-level MCP server.""" +"""Example of structured output with low-level MCP server.""" diff --git a/examples/servers/structured-output-lowlevel/mcp_structured_output_lowlevel/__main__.py b/examples/servers/structured-output-lowlevel/mcp_structured_output_lowlevel/__main__.py index 95fb90854..30c02a310 100644 --- a/examples/servers/structured-output-lowlevel/mcp_structured_output_lowlevel/__main__.py +++ b/examples/servers/structured-output-lowlevel/mcp_structured_output_lowlevel/__main__.py @@ -1,89 +1,89 @@ -#!/usr/bin/env python3 -"""Example low-level MCP server demonstrating structured output support. - -This example shows how to use the low-level server API to return -structured data from tools. -""" - -import asyncio -import json -import random -from datetime import datetime - -import mcp.server.stdio -from mcp import types -from mcp.server import Server, ServerRequestContext - - -async def handle_list_tools( - ctx: ServerRequestContext, params: types.PaginatedRequestParams | None -) -> types.ListToolsResult: - """List available tools with their schemas.""" - return types.ListToolsResult( - tools=[ - types.Tool( - name="get_weather", - description="Get weather information (simulated)", - input_schema={ - "type": "object", - "properties": {"city": {"type": "string", "description": "City name"}}, - "required": ["city"], - }, - output_schema={ - "type": "object", - "properties": { - "temperature": {"type": "number"}, - "conditions": {"type": "string"}, - "humidity": {"type": "integer", "minimum": 0, "maximum": 100}, - "wind_speed": {"type": "number"}, - "timestamp": {"type": "string", "format": "date-time"}, - }, - "required": ["temperature", "conditions", "humidity", "wind_speed", "timestamp"], - }, - ), - ] - ) - - -async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: - """Handle tool call with structured output.""" - - if params.name == "get_weather": - # Simulate weather data (in production, call a real weather API) - weather_conditions = ["sunny", "cloudy", "rainy", "partly cloudy", "foggy"] - - weather_data = { - "temperature": round(random.uniform(0, 35), 1), - "conditions": random.choice(weather_conditions), - "humidity": random.randint(30, 90), - "wind_speed": round(random.uniform(0, 30), 1), - "timestamp": datetime.now().isoformat(), - } - - return types.CallToolResult( - content=[types.TextContent(type="text", text=json.dumps(weather_data, indent=2))], - structured_content=weather_data, - ) - - raise ValueError(f"Unknown tool: {params.name}") - - -server = Server( - "structured-output-lowlevel-example", - on_list_tools=handle_list_tools, - on_call_tool=handle_call_tool, -) - - -async def run(): - """Run the low-level server using stdio transport.""" - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - server.create_initialization_options(), - ) - - -if __name__ == "__main__": - asyncio.run(run()) +#!/usr/bin/env python3 +"""Example low-level MCP server demonstrating structured output support. + +This example shows how to use the low-level server API to return +structured data from tools. +""" + +import asyncio +import json +import random +from datetime import datetime + +import mcp.server.stdio +from mcp import types +from mcp.server import Server, ServerRequestContext + + +async def handle_list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListToolsResult: + """List available tools with their schemas.""" + return types.ListToolsResult( + tools=[ + types.Tool( + name="get_weather", + description="Get weather information (simulated)", + input_schema={ + "type": "object", + "properties": {"city": {"type": "string", "description": "City name"}}, + "required": ["city"], + }, + output_schema={ + "type": "object", + "properties": { + "temperature": {"type": "number"}, + "conditions": {"type": "string"}, + "humidity": {"type": "integer", "minimum": 0, "maximum": 100}, + "wind_speed": {"type": "number"}, + "timestamp": {"type": "string", "format": "date-time"}, + }, + "required": ["temperature", "conditions", "humidity", "wind_speed", "timestamp"], + }, + ), + ] + ) + + +async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: + """Handle tool call with structured output.""" + + if params.name == "get_weather": + # Simulate weather data (in production, call a real weather API) + weather_conditions = ["sunny", "cloudy", "rainy", "partly cloudy", "foggy"] + + weather_data = { + "temperature": round(random.uniform(0, 35), 1), + "conditions": random.choice(weather_conditions), + "humidity": random.randint(30, 90), + "wind_speed": round(random.uniform(0, 30), 1), + "timestamp": datetime.now().isoformat(), + } + + return types.CallToolResult( + content=[types.TextContent(type="text", text=json.dumps(weather_data, indent=2))], + structured_content=weather_data, + ) + + raise ValueError(f"Unknown tool: {params.name}") + + +server = Server( + "structured-output-lowlevel-example", + on_list_tools=handle_list_tools, + on_call_tool=handle_call_tool, +) + + +async def run(): + """Run the low-level server using stdio transport.""" + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + server.create_initialization_options(), + ) + + +if __name__ == "__main__": + asyncio.run(run()) diff --git a/examples/servers/structured-output-lowlevel/pyproject.toml b/examples/servers/structured-output-lowlevel/pyproject.toml index 554efc614..a7da2c300 100644 --- a/examples/servers/structured-output-lowlevel/pyproject.toml +++ b/examples/servers/structured-output-lowlevel/pyproject.toml @@ -1,6 +1,6 @@ -[project] -name = "mcp-structured-output-lowlevel" -version = "0.1.0" -description = "Example of structured output with low-level MCP server" -requires-python = ">=3.10" -dependencies = ["mcp"] +[project] +name = "mcp-structured-output-lowlevel" +version = "0.1.0" +description = "Example of structured output with low-level MCP server" +requires-python = ">=3.10" +dependencies = ["mcp"] diff --git a/examples/snippets/clients/completion_client.py b/examples/snippets/clients/completion_client.py index dc0c1b4f7..70704fb21 100644 --- a/examples/snippets/clients/completion_client.py +++ b/examples/snippets/clients/completion_client.py @@ -1,77 +1,77 @@ -"""cd to the `examples/snippets` directory and run: -uv run completion-client -""" - -import asyncio -import os - -from mcp import ClientSession, StdioServerParameters -from mcp.client.stdio import stdio_client -from mcp.types import PromptReference, ResourceTemplateReference - -# Create server parameters for stdio connection -server_params = StdioServerParameters( - command="uv", # Using uv to run the server - args=["run", "server", "completion", "stdio"], # Server with completion support - env={"UV_INDEX": os.environ.get("UV_INDEX", "")}, -) - - -async def run(): - """Run the completion client example.""" - async with stdio_client(server_params) as (read, write): - async with ClientSession(read, write) as session: - # Initialize the connection - await session.initialize() - - # List available resource templates - templates = await session.list_resource_templates() - print("Available resource templates:") - for template in templates.resource_templates: - print(f" - {template.uri_template}") - - # List available prompts - prompts = await session.list_prompts() - print("\nAvailable prompts:") - for prompt in prompts.prompts: - print(f" - {prompt.name}") - - # Complete resource template arguments - if templates.resource_templates: - template = templates.resource_templates[0] - print(f"\nCompleting arguments for resource template: {template.uri_template}") - - # Complete without context - result = await session.complete( - ref=ResourceTemplateReference(type="ref/resource", uri=template.uri_template), - argument={"name": "owner", "value": "model"}, - ) - print(f"Completions for 'owner' starting with 'model': {result.completion.values}") - - # Complete with context - repo suggestions based on owner - result = await session.complete( - ref=ResourceTemplateReference(type="ref/resource", uri=template.uri_template), - argument={"name": "repo", "value": ""}, - context_arguments={"owner": "modelcontextprotocol"}, - ) - print(f"Completions for 'repo' with owner='modelcontextprotocol': {result.completion.values}") - - # Complete prompt arguments - if prompts.prompts: - prompt_name = prompts.prompts[0].name - print(f"\nCompleting arguments for prompt: {prompt_name}") - - result = await session.complete( - ref=PromptReference(type="ref/prompt", name=prompt_name), - argument={"name": "style", "value": ""}, - ) - print(f"Completions for 'style' argument: {result.completion.values}") - - -def main(): - """Entry point for the completion client.""" - asyncio.run(run()) - - -if __name__ == "__main__": - main() +"""cd to the `examples/snippets` directory and run: +uv run completion-client +""" + +import asyncio +import os + +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client +from mcp.types import PromptReference, ResourceTemplateReference + +# Create server parameters for stdio connection +server_params = StdioServerParameters( + command="uv", # Using uv to run the server + args=["run", "server", "completion", "stdio"], # Server with completion support + env={"UV_INDEX": os.environ.get("UV_INDEX", "")}, +) + + +async def run(): + """Run the completion client example.""" + async with stdio_client(server_params) as (read, write): + async with ClientSession(read, write) as session: + # Initialize the connection + await session.initialize() + + # List available resource templates + templates = await session.list_resource_templates() + print("Available resource templates:") + for template in templates.resource_templates: + print(f" - {template.uri_template}") + + # List available prompts + prompts = await session.list_prompts() + print("\nAvailable prompts:") + for prompt in prompts.prompts: + print(f" - {prompt.name}") + + # Complete resource template arguments + if templates.resource_templates: + template = templates.resource_templates[0] + print(f"\nCompleting arguments for resource template: {template.uri_template}") + + # Complete without context + result = await session.complete( + ref=ResourceTemplateReference(type="ref/resource", uri=template.uri_template), + argument={"name": "owner", "value": "model"}, + ) + print(f"Completions for 'owner' starting with 'model': {result.completion.values}") + + # Complete with context - repo suggestions based on owner + result = await session.complete( + ref=ResourceTemplateReference(type="ref/resource", uri=template.uri_template), + argument={"name": "repo", "value": ""}, + context_arguments={"owner": "modelcontextprotocol"}, + ) + print(f"Completions for 'repo' with owner='modelcontextprotocol': {result.completion.values}") + + # Complete prompt arguments + if prompts.prompts: + prompt_name = prompts.prompts[0].name + print(f"\nCompleting arguments for prompt: {prompt_name}") + + result = await session.complete( + ref=PromptReference(type="ref/prompt", name=prompt_name), + argument={"name": "style", "value": ""}, + ) + print(f"Completions for 'style' argument: {result.completion.values}") + + +def main(): + """Entry point for the completion client.""" + asyncio.run(run()) + + +if __name__ == "__main__": + main() diff --git a/examples/snippets/clients/display_utilities.py b/examples/snippets/clients/display_utilities.py index baa2765a8..dd8a61f65 100644 --- a/examples/snippets/clients/display_utilities.py +++ b/examples/snippets/clients/display_utilities.py @@ -1,66 +1,66 @@ -"""cd to the `examples/snippets` directory and run: -uv run display-utilities-client -""" - -import asyncio -import os - -from mcp import ClientSession, StdioServerParameters -from mcp.client.stdio import stdio_client -from mcp.shared.metadata_utils import get_display_name - -# Create server parameters for stdio connection -server_params = StdioServerParameters( - command="uv", # Using uv to run the server - args=["run", "server", "mcpserver_quickstart", "stdio"], - env={"UV_INDEX": os.environ.get("UV_INDEX", "")}, -) - - -async def display_tools(session: ClientSession): - """Display available tools with human-readable names""" - tools_response = await session.list_tools() - - for tool in tools_response.tools: - # get_display_name() returns the title if available, otherwise the name - display_name = get_display_name(tool) - print(f"Tool: {display_name}") - if tool.description: - print(f" {tool.description}") - - -async def display_resources(session: ClientSession): - """Display available resources with human-readable names""" - resources_response = await session.list_resources() - - for resource in resources_response.resources: - display_name = get_display_name(resource) - print(f"Resource: {display_name} ({resource.uri})") - - templates_response = await session.list_resource_templates() - for template in templates_response.resource_templates: - display_name = get_display_name(template) - print(f"Resource Template: {display_name}") - - -async def run(): - """Run the display utilities example.""" - async with stdio_client(server_params) as (read, write): - async with ClientSession(read, write) as session: - # Initialize the connection - await session.initialize() - - print("=== Available Tools ===") - await display_tools(session) - - print("\n=== Available Resources ===") - await display_resources(session) - - -def main(): - """Entry point for the display utilities client.""" - asyncio.run(run()) - - -if __name__ == "__main__": - main() +"""cd to the `examples/snippets` directory and run: +uv run display-utilities-client +""" + +import asyncio +import os + +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client +from mcp.shared.metadata_utils import get_display_name + +# Create server parameters for stdio connection +server_params = StdioServerParameters( + command="uv", # Using uv to run the server + args=["run", "server", "mcpserver_quickstart", "stdio"], + env={"UV_INDEX": os.environ.get("UV_INDEX", "")}, +) + + +async def display_tools(session: ClientSession): + """Display available tools with human-readable names""" + tools_response = await session.list_tools() + + for tool in tools_response.tools: + # get_display_name() returns the title if available, otherwise the name + display_name = get_display_name(tool) + print(f"Tool: {display_name}") + if tool.description: + print(f" {tool.description}") + + +async def display_resources(session: ClientSession): + """Display available resources with human-readable names""" + resources_response = await session.list_resources() + + for resource in resources_response.resources: + display_name = get_display_name(resource) + print(f"Resource: {display_name} ({resource.uri})") + + templates_response = await session.list_resource_templates() + for template in templates_response.resource_templates: + display_name = get_display_name(template) + print(f"Resource Template: {display_name}") + + +async def run(): + """Run the display utilities example.""" + async with stdio_client(server_params) as (read, write): + async with ClientSession(read, write) as session: + # Initialize the connection + await session.initialize() + + print("=== Available Tools ===") + await display_tools(session) + + print("\n=== Available Resources ===") + await display_resources(session) + + +def main(): + """Entry point for the display utilities client.""" + asyncio.run(run()) + + +if __name__ == "__main__": + main() diff --git a/examples/snippets/clients/oauth_client.py b/examples/snippets/clients/oauth_client.py index 3887c5c8c..a8a1be039 100644 --- a/examples/snippets/clients/oauth_client.py +++ b/examples/snippets/clients/oauth_client.py @@ -1,88 +1,88 @@ -"""Before running, specify running MCP RS server URL. -To spin up RS server locally, see - examples/servers/simple-auth/README.md - -cd to the `examples/snippets` directory and run: - uv run oauth-client -""" - -import asyncio -from urllib.parse import parse_qs, urlparse - -import httpx -from pydantic import AnyUrl - -from mcp import ClientSession -from mcp.client.auth import OAuthClientProvider, TokenStorage -from mcp.client.streamable_http import streamable_http_client -from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken - - -class InMemoryTokenStorage(TokenStorage): - """Demo In-memory token storage implementation.""" - - def __init__(self): - self.tokens: OAuthToken | None = None - self.client_info: OAuthClientInformationFull | None = None - - async def get_tokens(self) -> OAuthToken | None: - """Get stored tokens.""" - return self.tokens - - async def set_tokens(self, tokens: OAuthToken) -> None: - """Store tokens.""" - self.tokens = tokens - - async def get_client_info(self) -> OAuthClientInformationFull | None: - """Get stored client information.""" - return self.client_info - - async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: - """Store client information.""" - self.client_info = client_info - - -async def handle_redirect(auth_url: str) -> None: - print(f"Visit: {auth_url}") - - -async def handle_callback() -> tuple[str, str | None]: - callback_url = input("Paste callback URL: ") - params = parse_qs(urlparse(callback_url).query) - return params["code"][0], params.get("state", [None])[0] - - -async def main(): - """Run the OAuth client example.""" - oauth_auth = OAuthClientProvider( - server_url="http://localhost:8001", - client_metadata=OAuthClientMetadata( - client_name="Example MCP Client", - redirect_uris=[AnyUrl("http://localhost:3000/callback")], - grant_types=["authorization_code", "refresh_token"], - response_types=["code"], - scope="user", - ), - storage=InMemoryTokenStorage(), - redirect_handler=handle_redirect, - callback_handler=handle_callback, - ) - - async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client: - async with streamable_http_client("http://localhost:8001/mcp", http_client=custom_client) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - - tools = await session.list_tools() - print(f"Available tools: {[tool.name for tool in tools.tools]}") - - resources = await session.list_resources() - print(f"Available resources: {[r.uri for r in resources.resources]}") - - -def run(): - asyncio.run(main()) - - -if __name__ == "__main__": - run() +"""Before running, specify running MCP RS server URL. +To spin up RS server locally, see + examples/servers/simple-auth/README.md + +cd to the `examples/snippets` directory and run: + uv run oauth-client +""" + +import asyncio +from urllib.parse import parse_qs, urlparse + +import httpx +from pydantic import AnyUrl + +from mcp import ClientSession +from mcp.client.auth import OAuthClientProvider, TokenStorage +from mcp.client.streamable_http import streamable_http_client +from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthToken + + +class InMemoryTokenStorage(TokenStorage): + """Demo In-memory token storage implementation.""" + + def __init__(self): + self.tokens: OAuthToken | None = None + self.client_info: OAuthClientInformationFull | None = None + + async def get_tokens(self) -> OAuthToken | None: + """Get stored tokens.""" + return self.tokens + + async def set_tokens(self, tokens: OAuthToken) -> None: + """Store tokens.""" + self.tokens = tokens + + async def get_client_info(self) -> OAuthClientInformationFull | None: + """Get stored client information.""" + return self.client_info + + async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: + """Store client information.""" + self.client_info = client_info + + +async def handle_redirect(auth_url: str) -> None: + print(f"Visit: {auth_url}") + + +async def handle_callback() -> tuple[str, str | None]: + callback_url = input("Paste callback URL: ") + params = parse_qs(urlparse(callback_url).query) + return params["code"][0], params.get("state", [None])[0] + + +async def main(): + """Run the OAuth client example.""" + oauth_auth = OAuthClientProvider( + server_url="http://localhost:8001", + client_metadata=OAuthClientMetadata( + client_name="Example MCP Client", + redirect_uris=[AnyUrl("http://localhost:3000/callback")], + grant_types=["authorization_code", "refresh_token"], + response_types=["code"], + scope="user", + ), + storage=InMemoryTokenStorage(), + redirect_handler=handle_redirect, + callback_handler=handle_callback, + ) + + async with httpx.AsyncClient(auth=oauth_auth, follow_redirects=True) as custom_client: + async with streamable_http_client("http://localhost:8001/mcp", http_client=custom_client) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + + tools = await session.list_tools() + print(f"Available tools: {[tool.name for tool in tools.tools]}") + + resources = await session.list_resources() + print(f"Available resources: {[r.uri for r in resources.resources]}") + + +def run(): + asyncio.run(main()) + + +if __name__ == "__main__": + run() diff --git a/examples/snippets/clients/pagination_client.py b/examples/snippets/clients/pagination_client.py index b9b8c23ae..f4e190b14 100644 --- a/examples/snippets/clients/pagination_client.py +++ b/examples/snippets/clients/pagination_client.py @@ -1,39 +1,39 @@ -"""Example of consuming paginated MCP endpoints from a client.""" - -import asyncio - -from mcp.client.session import ClientSession -from mcp.client.stdio import StdioServerParameters, stdio_client -from mcp.types import PaginatedRequestParams, Resource - - -async def list_all_resources() -> None: - """Fetch all resources using pagination.""" - async with stdio_client(StdioServerParameters(command="uv", args=["run", "mcp-simple-pagination"])) as ( - read, - write, - ): - async with ClientSession(read, write) as session: - await session.initialize() - - all_resources: list[Resource] = [] - cursor = None - - while True: - # Fetch a page of resources - result = await session.list_resources(params=PaginatedRequestParams(cursor=cursor)) - all_resources.extend(result.resources) - - print(f"Fetched {len(result.resources)} resources") - - # Check if there are more pages - if result.next_cursor: - cursor = result.next_cursor - else: - break - - print(f"Total resources: {len(all_resources)}") - - -if __name__ == "__main__": - asyncio.run(list_all_resources()) +"""Example of consuming paginated MCP endpoints from a client.""" + +import asyncio + +from mcp.client.session import ClientSession +from mcp.client.stdio import StdioServerParameters, stdio_client +from mcp.types import PaginatedRequestParams, Resource + + +async def list_all_resources() -> None: + """Fetch all resources using pagination.""" + async with stdio_client(StdioServerParameters(command="uv", args=["run", "mcp-simple-pagination"])) as ( + read, + write, + ): + async with ClientSession(read, write) as session: + await session.initialize() + + all_resources: list[Resource] = [] + cursor = None + + while True: + # Fetch a page of resources + result = await session.list_resources(params=PaginatedRequestParams(cursor=cursor)) + all_resources.extend(result.resources) + + print(f"Fetched {len(result.resources)} resources") + + # Check if there are more pages + if result.next_cursor: + cursor = result.next_cursor + else: + break + + print(f"Total resources: {len(all_resources)}") + + +if __name__ == "__main__": + asyncio.run(list_all_resources()) diff --git a/examples/snippets/clients/parsing_tool_results.py b/examples/snippets/clients/parsing_tool_results.py index b16640677..516e2ab0e 100644 --- a/examples/snippets/clients/parsing_tool_results.py +++ b/examples/snippets/clients/parsing_tool_results.py @@ -1,60 +1,60 @@ -"""examples/snippets/clients/parsing_tool_results.py""" - -import asyncio - -from mcp import ClientSession, StdioServerParameters, types -from mcp.client.stdio import stdio_client - - -async def parse_tool_results(): - """Demonstrates how to parse different types of content in CallToolResult.""" - server_params = StdioServerParameters(command="python", args=["path/to/mcp_server.py"]) - - async with stdio_client(server_params) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - - # Example 1: Parsing text content - result = await session.call_tool("get_data", {"format": "text"}) - for content in result.content: - if isinstance(content, types.TextContent): - print(f"Text: {content.text}") - - # Example 2: Parsing structured content from JSON tools - result = await session.call_tool("get_user", {"id": "123"}) - if hasattr(result, "structured_content") and result.structured_content: - # Access structured data directly - user_data = result.structured_content - print(f"User: {user_data.get('name')}, Age: {user_data.get('age')}") - - # Example 3: Parsing embedded resources - result = await session.call_tool("read_config", {}) - for content in result.content: - if isinstance(content, types.EmbeddedResource): - resource = content.resource - if isinstance(resource, types.TextResourceContents): - print(f"Config from {resource.uri}: {resource.text}") - else: - print(f"Binary data from {resource.uri}") - - # Example 4: Parsing image content - result = await session.call_tool("generate_chart", {"data": [1, 2, 3]}) - for content in result.content: - if isinstance(content, types.ImageContent): - print(f"Image ({content.mime_type}): {len(content.data)} bytes") - - # Example 5: Handling errors - result = await session.call_tool("failing_tool", {}) - if result.is_error: - print("Tool execution failed!") - for content in result.content: - if isinstance(content, types.TextContent): - print(f"Error: {content.text}") - - -async def main(): - await parse_tool_results() - - -if __name__ == "__main__": - asyncio.run(main()) +"""examples/snippets/clients/parsing_tool_results.py""" + +import asyncio + +from mcp import ClientSession, StdioServerParameters, types +from mcp.client.stdio import stdio_client + + +async def parse_tool_results(): + """Demonstrates how to parse different types of content in CallToolResult.""" + server_params = StdioServerParameters(command="python", args=["path/to/mcp_server.py"]) + + async with stdio_client(server_params) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + + # Example 1: Parsing text content + result = await session.call_tool("get_data", {"format": "text"}) + for content in result.content: + if isinstance(content, types.TextContent): + print(f"Text: {content.text}") + + # Example 2: Parsing structured content from JSON tools + result = await session.call_tool("get_user", {"id": "123"}) + if hasattr(result, "structured_content") and result.structured_content: + # Access structured data directly + user_data = result.structured_content + print(f"User: {user_data.get('name')}, Age: {user_data.get('age')}") + + # Example 3: Parsing embedded resources + result = await session.call_tool("read_config", {}) + for content in result.content: + if isinstance(content, types.EmbeddedResource): + resource = content.resource + if isinstance(resource, types.TextResourceContents): + print(f"Config from {resource.uri}: {resource.text}") + else: + print(f"Binary data from {resource.uri}") + + # Example 4: Parsing image content + result = await session.call_tool("generate_chart", {"data": [1, 2, 3]}) + for content in result.content: + if isinstance(content, types.ImageContent): + print(f"Image ({content.mime_type}): {len(content.data)} bytes") + + # Example 5: Handling errors + result = await session.call_tool("failing_tool", {}) + if result.is_error: + print("Tool execution failed!") + for content in result.content: + if isinstance(content, types.TextContent): + print(f"Error: {content.text}") + + +async def main(): + await parse_tool_results() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/snippets/clients/stdio_client.py b/examples/snippets/clients/stdio_client.py index c1f85f42a..e708bf19e 100644 --- a/examples/snippets/clients/stdio_client.py +++ b/examples/snippets/clients/stdio_client.py @@ -1,80 +1,80 @@ -"""cd to the `examples/snippets/clients` directory and run: -uv run client -""" - -import asyncio -import os - -from mcp import ClientSession, StdioServerParameters, types -from mcp.client.context import ClientRequestContext -from mcp.client.stdio import stdio_client - -# Create server parameters for stdio connection -server_params = StdioServerParameters( - command="uv", # Using uv to run the server - args=["run", "server", "mcpserver_quickstart", "stdio"], # We're already in snippets dir - env={"UV_INDEX": os.environ.get("UV_INDEX", "")}, -) - - -# Optional: create a sampling callback -async def handle_sampling_message( - context: ClientRequestContext, params: types.CreateMessageRequestParams -) -> types.CreateMessageResult: - print(f"Sampling request: {params.messages}") - return types.CreateMessageResult( - role="assistant", - content=types.TextContent( - type="text", - text="Hello, world! from model", - ), - model="gpt-3.5-turbo", - stop_reason="endTurn", - ) - - -async def run(): - async with stdio_client(server_params) as (read, write): - async with ClientSession(read, write, sampling_callback=handle_sampling_message) as session: - # Initialize the connection - await session.initialize() - - # List available prompts - prompts = await session.list_prompts() - print(f"Available prompts: {[p.name for p in prompts.prompts]}") - - # Get a prompt (greet_user prompt from mcpserver_quickstart) - if prompts.prompts: - prompt = await session.get_prompt("greet_user", arguments={"name": "Alice", "style": "friendly"}) - print(f"Prompt result: {prompt.messages[0].content}") - - # List available resources - resources = await session.list_resources() - print(f"Available resources: {[r.uri for r in resources.resources]}") - - # List available tools - tools = await session.list_tools() - print(f"Available tools: {[t.name for t in tools.tools]}") - - # Read a resource (greeting resource from mcpserver_quickstart) - resource_content = await session.read_resource("greeting://World") - content_block = resource_content.contents[0] - if isinstance(content_block, types.TextContent): - print(f"Resource content: {content_block.text}") - - # Call a tool (add tool from mcpserver_quickstart) - result = await session.call_tool("add", arguments={"a": 5, "b": 3}) - result_unstructured = result.content[0] - if isinstance(result_unstructured, types.TextContent): - print(f"Tool result: {result_unstructured.text}") - result_structured = result.structured_content - print(f"Structured tool result: {result_structured}") - - -def main(): - """Entry point for the client script.""" - asyncio.run(run()) - - -if __name__ == "__main__": - main() +"""cd to the `examples/snippets/clients` directory and run: +uv run client +""" + +import asyncio +import os + +from mcp import ClientSession, StdioServerParameters, types +from mcp.client.context import ClientRequestContext +from mcp.client.stdio import stdio_client + +# Create server parameters for stdio connection +server_params = StdioServerParameters( + command="uv", # Using uv to run the server + args=["run", "server", "mcpserver_quickstart", "stdio"], # We're already in snippets dir + env={"UV_INDEX": os.environ.get("UV_INDEX", "")}, +) + + +# Optional: create a sampling callback +async def handle_sampling_message( + context: ClientRequestContext, params: types.CreateMessageRequestParams +) -> types.CreateMessageResult: + print(f"Sampling request: {params.messages}") + return types.CreateMessageResult( + role="assistant", + content=types.TextContent( + type="text", + text="Hello, world! from model", + ), + model="gpt-3.5-turbo", + stop_reason="endTurn", + ) + + +async def run(): + async with stdio_client(server_params) as (read, write): + async with ClientSession(read, write, sampling_callback=handle_sampling_message) as session: + # Initialize the connection + await session.initialize() + + # List available prompts + prompts = await session.list_prompts() + print(f"Available prompts: {[p.name for p in prompts.prompts]}") + + # Get a prompt (greet_user prompt from mcpserver_quickstart) + if prompts.prompts: + prompt = await session.get_prompt("greet_user", arguments={"name": "Alice", "style": "friendly"}) + print(f"Prompt result: {prompt.messages[0].content}") + + # List available resources + resources = await session.list_resources() + print(f"Available resources: {[r.uri for r in resources.resources]}") + + # List available tools + tools = await session.list_tools() + print(f"Available tools: {[t.name for t in tools.tools]}") + + # Read a resource (greeting resource from mcpserver_quickstart) + resource_content = await session.read_resource("greeting://World") + content_block = resource_content.contents[0] + if isinstance(content_block, types.TextContent): + print(f"Resource content: {content_block.text}") + + # Call a tool (add tool from mcpserver_quickstart) + result = await session.call_tool("add", arguments={"a": 5, "b": 3}) + result_unstructured = result.content[0] + if isinstance(result_unstructured, types.TextContent): + print(f"Tool result: {result_unstructured.text}") + result_structured = result.structured_content + print(f"Structured tool result: {result_structured}") + + +def main(): + """Entry point for the client script.""" + asyncio.run(run()) + + +if __name__ == "__main__": + main() diff --git a/examples/snippets/clients/streamable_basic.py b/examples/snippets/clients/streamable_basic.py index 43bb6396c..749d19d03 100644 --- a/examples/snippets/clients/streamable_basic.py +++ b/examples/snippets/clients/streamable_basic.py @@ -1,24 +1,24 @@ -"""Run from the repository root: -uv run examples/snippets/clients/streamable_basic.py -""" - -import asyncio - -from mcp import ClientSession -from mcp.client.streamable_http import streamable_http_client - - -async def main(): - # Connect to a streamable HTTP server - async with streamable_http_client("http://localhost:8000/mcp") as (read_stream, write_stream): - # Create a session using the client streams - async with ClientSession(read_stream, write_stream) as session: - # Initialize the connection - await session.initialize() - # List available tools - tools = await session.list_tools() - print(f"Available tools: {[tool.name for tool in tools.tools]}") - - -if __name__ == "__main__": - asyncio.run(main()) +"""Run from the repository root: +uv run examples/snippets/clients/streamable_basic.py +""" + +import asyncio + +from mcp import ClientSession +from mcp.client.streamable_http import streamable_http_client + + +async def main(): + # Connect to a streamable HTTP server + async with streamable_http_client("http://localhost:8000/mcp") as (read_stream, write_stream): + # Create a session using the client streams + async with ClientSession(read_stream, write_stream) as session: + # Initialize the connection + await session.initialize() + # List available tools + tools = await session.list_tools() + print(f"Available tools: {[tool.name for tool in tools.tools]}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/snippets/clients/url_elicitation_client.py b/examples/snippets/clients/url_elicitation_client.py index 2aecbeeee..562f1b6ea 100644 --- a/examples/snippets/clients/url_elicitation_client.py +++ b/examples/snippets/clients/url_elicitation_client.py @@ -1,316 +1,316 @@ -"""URL Elicitation Client Example. - -Demonstrates how clients handle URL elicitation requests from servers. -This is the Python equivalent of TypeScript SDK's elicitationUrlExample.ts, -focused on URL elicitation patterns without OAuth complexity. - -Features demonstrated: -1. Client elicitation capability declaration -2. Handling elicitation requests from servers via callback -3. Catching UrlElicitationRequiredError from tool calls -4. Browser interaction with security warnings -5. Interactive CLI for testing - -Run with: - cd examples/snippets - uv run elicitation-client - -Requires a server with URL elicitation tools running. Start the elicitation -server first: - uv run server elicitation sse -""" - -from __future__ import annotations - -import asyncio -import json -import webbrowser -from typing import Any -from urllib.parse import urlparse - -from mcp import ClientSession, types -from mcp.client.context import ClientRequestContext -from mcp.client.sse import sse_client -from mcp.shared.exceptions import MCPError, UrlElicitationRequiredError -from mcp.types import URL_ELICITATION_REQUIRED - - -async def handle_elicitation( - context: ClientRequestContext, - params: types.ElicitRequestParams, -) -> types.ElicitResult | types.ErrorData: - """Handle elicitation requests from the server. - - This callback is invoked when the server sends an elicitation/request. - For URL mode, we prompt the user and optionally open their browser. - """ - if params.mode == "url": - return await handle_url_elicitation(params) - else: - # We only support URL mode in this example - return types.ErrorData( - code=types.INVALID_REQUEST, - message=f"Unsupported elicitation mode: {params.mode}", - ) - - -ALLOWED_SCHEMES = {"http", "https"} - - -async def handle_url_elicitation( - params: types.ElicitRequestParams, -) -> types.ElicitResult: - """Handle URL mode elicitation - show security warning and optionally open browser. - - This function demonstrates the security-conscious approach to URL elicitation: - 1. Validate the URL scheme before prompting the user - 2. Display the full URL and domain for user inspection - 3. Show the server's reason for requesting this interaction - 4. Require explicit user consent before opening any URL - """ - # Extract URL parameters - these are available on URL mode requests - url = getattr(params, "url", None) - elicitation_id = getattr(params, "elicitationId", None) - message = params.message - - if not url: - print("Error: No URL provided in elicitation request") - return types.ElicitResult(action="cancel") - - # Reject dangerous URL schemes before prompting the user - parsed = urlparse(str(url)) - if parsed.scheme.lower() not in ALLOWED_SCHEMES: - print(f"\nRejecting URL with disallowed scheme '{parsed.scheme}': {url}") - return types.ElicitResult(action="decline") - - # Extract domain for security display - domain = extract_domain(url) - - # Security warning - always show the user what they're being asked to do - print("\n" + "=" * 60) - print("SECURITY WARNING: External URL Request") - print("=" * 60) - print("\nThe server is requesting you to open an external URL.") - print(f"\n Domain: {domain}") - print(f" Full URL: {url}") - print("\n Server's reason:") - print(f" {message}") - print(f"\n Elicitation ID: {elicitation_id}") - print("\n" + "-" * 60) - - # Get explicit user consent - try: - response = input("\nOpen this URL in your browser? (y/n): ").strip().lower() - except EOFError: - return types.ElicitResult(action="cancel") - - if response in ("n", "no"): - print("URL navigation declined.") - return types.ElicitResult(action="decline") - elif response not in ("y", "yes"): - print("Invalid response. Cancelling.") - return types.ElicitResult(action="cancel") - - # Open the browser - print(f"\nOpening browser to: {url}") - try: - webbrowser.open(url) - except Exception as e: - print(f"Failed to open browser: {e}") - print(f"Please manually open: {url}") - - print("Waiting for you to complete the interaction in your browser...") - print("(The server will continue once you've finished)") - - return types.ElicitResult(action="accept") - - -def extract_domain(url: str) -> str: - """Extract domain from URL for security display.""" - try: - return urlparse(url).netloc - except Exception: - return "unknown" - - -async def call_tool_with_error_handling( - session: ClientSession, - tool_name: str, - arguments: dict[str, Any], -) -> types.CallToolResult | None: - """Call a tool, handling UrlElicitationRequiredError if raised. - - When a server tool needs URL elicitation before it can proceed, - it can either: - 1. Send an elicitation request directly (handled by elicitation_callback) - 2. Return an error with code -32042 (URL_ELICITATION_REQUIRED) - - This function demonstrates handling case 2 - catching the error - and processing the required URL elicitations. - """ - try: - result = await session.call_tool(tool_name, arguments) - - # Check if the tool returned an error in the result - if result.is_error: - print(f"Tool returned error: {result.content}") - return None - - return result - - except MCPError as e: - # Check if this is a URL elicitation required error - if e.code == URL_ELICITATION_REQUIRED: - print("\n[Tool requires URL elicitation to proceed]") - - # Convert to typed error to access elicitations - url_error = UrlElicitationRequiredError.from_error(e.error) - - # Process each required elicitation - for elicitation in url_error.elicitations: - await handle_url_elicitation(elicitation) - - return None - else: - # Re-raise other MCP errors - print(f"MCP Error: {e.error.message} (code: {e.error.code})") - return None - - -def print_help() -> None: - """Print available commands.""" - print("\nAvailable commands:") - print(" list-tools - List available tools") - print(" call [json-args] - Call a tool with optional JSON arguments") - print(" secure-payment - Test URL elicitation via ctx.elicit_url()") - print(" connect-service - Test URL elicitation via UrlElicitationRequiredError") - print(" help - Show this help") - print(" quit - Exit the program") - - -def print_tool_result(result: types.CallToolResult | None) -> None: - """Print a tool call result.""" - if not result: - return - print("\nTool result:") - for content in result.content: - if isinstance(content, types.TextContent): - print(f" {content.text}") - else: - print(f" [{content.type}]") - - -async def handle_list_tools(session: ClientSession) -> None: - """Handle the list-tools command.""" - tools = await session.list_tools() - if tools.tools: - print("\nAvailable tools:") - for tool in tools.tools: - print(f" - {tool.name}: {tool.description or 'No description'}") - else: - print("No tools available") - - -async def handle_call_command(session: ClientSession, command: str) -> None: - """Handle the call command.""" - parts = command.split(maxsplit=2) - if len(parts) < 2: - print("Usage: call [json-args]") - return - - tool_name = parts[1] - args: dict[str, Any] = {} - if len(parts) > 2: - try: - args = json.loads(parts[2]) - except json.JSONDecodeError as e: - print(f"Invalid JSON arguments: {e}") - return - - print(f"\nCalling tool '{tool_name}' with args: {args}") - result = await call_tool_with_error_handling(session, tool_name, args) - print_tool_result(result) - - -async def process_command(session: ClientSession, command: str) -> bool: - """Process a single command. Returns False if should exit.""" - if command in {"quit", "exit"}: - print("Goodbye!") - return False - - if command == "help": - print_help() - elif command == "list-tools": - await handle_list_tools(session) - elif command.startswith("call "): - await handle_call_command(session, command) - elif command == "secure-payment": - print("\nTesting secure_payment tool (uses ctx.elicit_url())...") - result = await call_tool_with_error_handling(session, "secure_payment", {"amount": 99.99}) - print_tool_result(result) - elif command == "connect-service": - print("\nTesting connect_service tool (raises UrlElicitationRequiredError)...") - result = await call_tool_with_error_handling(session, "connect_service", {"service_name": "github"}) - print_tool_result(result) - else: - print(f"Unknown command: {command}") - print("Type 'help' for available commands.") - - return True - - -async def run_command_loop(session: ClientSession) -> None: - """Run the interactive command loop.""" - while True: - try: - command = input("> ").strip() - except EOFError: - break - except KeyboardInterrupt: - print("\n") - break - - if not command: - continue - - if not await process_command(session, command): - break - - -async def main() -> None: - """Run the interactive URL elicitation client.""" - server_url = "http://localhost:8000/sse" - - print("=" * 60) - print("URL Elicitation Client Example") - print("=" * 60) - print(f"\nConnecting to: {server_url}") - print("(Start server with: cd examples/snippets && uv run server elicitation sse)") - - try: - async with sse_client(server_url) as (read, write): - async with ClientSession( - read, - write, - elicitation_callback=handle_elicitation, - ) as session: - await session.initialize() - print("\nConnected! Type 'help' for available commands.\n") - await run_command_loop(session) - - except ConnectionRefusedError: - print(f"\nError: Could not connect to {server_url}") - print("Make sure the elicitation server is running:") - print(" cd examples/snippets && uv run server elicitation sse") - except Exception as e: - print(f"\nError: {e}") - raise - - -def run() -> None: - """Entry point for the client script.""" - asyncio.run(main()) - - -if __name__ == "__main__": - run() +"""URL Elicitation Client Example. + +Demonstrates how clients handle URL elicitation requests from servers. +This is the Python equivalent of TypeScript SDK's elicitationUrlExample.ts, +focused on URL elicitation patterns without OAuth complexity. + +Features demonstrated: +1. Client elicitation capability declaration +2. Handling elicitation requests from servers via callback +3. Catching UrlElicitationRequiredError from tool calls +4. Browser interaction with security warnings +5. Interactive CLI for testing + +Run with: + cd examples/snippets + uv run elicitation-client + +Requires a server with URL elicitation tools running. Start the elicitation +server first: + uv run server elicitation sse +""" + +from __future__ import annotations + +import asyncio +import json +import webbrowser +from typing import Any +from urllib.parse import urlparse + +from mcp import ClientSession, types +from mcp.client.context import ClientRequestContext +from mcp.client.sse import sse_client +from mcp.shared.exceptions import MCPError, UrlElicitationRequiredError +from mcp.types import URL_ELICITATION_REQUIRED + + +async def handle_elicitation( + context: ClientRequestContext, + params: types.ElicitRequestParams, +) -> types.ElicitResult | types.ErrorData: + """Handle elicitation requests from the server. + + This callback is invoked when the server sends an elicitation/request. + For URL mode, we prompt the user and optionally open their browser. + """ + if params.mode == "url": + return await handle_url_elicitation(params) + else: + # We only support URL mode in this example + return types.ErrorData( + code=types.INVALID_REQUEST, + message=f"Unsupported elicitation mode: {params.mode}", + ) + + +ALLOWED_SCHEMES = {"http", "https"} + + +async def handle_url_elicitation( + params: types.ElicitRequestParams, +) -> types.ElicitResult: + """Handle URL mode elicitation - show security warning and optionally open browser. + + This function demonstrates the security-conscious approach to URL elicitation: + 1. Validate the URL scheme before prompting the user + 2. Display the full URL and domain for user inspection + 3. Show the server's reason for requesting this interaction + 4. Require explicit user consent before opening any URL + """ + # Extract URL parameters - these are available on URL mode requests + url = getattr(params, "url", None) + elicitation_id = getattr(params, "elicitationId", None) + message = params.message + + if not url: + print("Error: No URL provided in elicitation request") + return types.ElicitResult(action="cancel") + + # Reject dangerous URL schemes before prompting the user + parsed = urlparse(str(url)) + if parsed.scheme.lower() not in ALLOWED_SCHEMES: + print(f"\nRejecting URL with disallowed scheme '{parsed.scheme}': {url}") + return types.ElicitResult(action="decline") + + # Extract domain for security display + domain = extract_domain(url) + + # Security warning - always show the user what they're being asked to do + print("\n" + "=" * 60) + print("SECURITY WARNING: External URL Request") + print("=" * 60) + print("\nThe server is requesting you to open an external URL.") + print(f"\n Domain: {domain}") + print(f" Full URL: {url}") + print("\n Server's reason:") + print(f" {message}") + print(f"\n Elicitation ID: {elicitation_id}") + print("\n" + "-" * 60) + + # Get explicit user consent + try: + response = input("\nOpen this URL in your browser? (y/n): ").strip().lower() + except EOFError: + return types.ElicitResult(action="cancel") + + if response in ("n", "no"): + print("URL navigation declined.") + return types.ElicitResult(action="decline") + elif response not in ("y", "yes"): + print("Invalid response. Cancelling.") + return types.ElicitResult(action="cancel") + + # Open the browser + print(f"\nOpening browser to: {url}") + try: + webbrowser.open(url) + except Exception as e: + print(f"Failed to open browser: {e}") + print(f"Please manually open: {url}") + + print("Waiting for you to complete the interaction in your browser...") + print("(The server will continue once you've finished)") + + return types.ElicitResult(action="accept") + + +def extract_domain(url: str) -> str: + """Extract domain from URL for security display.""" + try: + return urlparse(url).netloc + except Exception: + return "unknown" + + +async def call_tool_with_error_handling( + session: ClientSession, + tool_name: str, + arguments: dict[str, Any], +) -> types.CallToolResult | None: + """Call a tool, handling UrlElicitationRequiredError if raised. + + When a server tool needs URL elicitation before it can proceed, + it can either: + 1. Send an elicitation request directly (handled by elicitation_callback) + 2. Return an error with code -32042 (URL_ELICITATION_REQUIRED) + + This function demonstrates handling case 2 - catching the error + and processing the required URL elicitations. + """ + try: + result = await session.call_tool(tool_name, arguments) + + # Check if the tool returned an error in the result + if result.is_error: + print(f"Tool returned error: {result.content}") + return None + + return result + + except MCPError as e: + # Check if this is a URL elicitation required error + if e.code == URL_ELICITATION_REQUIRED: + print("\n[Tool requires URL elicitation to proceed]") + + # Convert to typed error to access elicitations + url_error = UrlElicitationRequiredError.from_error(e.error) + + # Process each required elicitation + for elicitation in url_error.elicitations: + await handle_url_elicitation(elicitation) + + return None + else: + # Re-raise other MCP errors + print(f"MCP Error: {e.error.message} (code: {e.error.code})") + return None + + +def print_help() -> None: + """Print available commands.""" + print("\nAvailable commands:") + print(" list-tools - List available tools") + print(" call [json-args] - Call a tool with optional JSON arguments") + print(" secure-payment - Test URL elicitation via ctx.elicit_url()") + print(" connect-service - Test URL elicitation via UrlElicitationRequiredError") + print(" help - Show this help") + print(" quit - Exit the program") + + +def print_tool_result(result: types.CallToolResult | None) -> None: + """Print a tool call result.""" + if not result: + return + print("\nTool result:") + for content in result.content: + if isinstance(content, types.TextContent): + print(f" {content.text}") + else: + print(f" [{content.type}]") + + +async def handle_list_tools(session: ClientSession) -> None: + """Handle the list-tools command.""" + tools = await session.list_tools() + if tools.tools: + print("\nAvailable tools:") + for tool in tools.tools: + print(f" - {tool.name}: {tool.description or 'No description'}") + else: + print("No tools available") + + +async def handle_call_command(session: ClientSession, command: str) -> None: + """Handle the call command.""" + parts = command.split(maxsplit=2) + if len(parts) < 2: + print("Usage: call [json-args]") + return + + tool_name = parts[1] + args: dict[str, Any] = {} + if len(parts) > 2: + try: + args = json.loads(parts[2]) + except json.JSONDecodeError as e: + print(f"Invalid JSON arguments: {e}") + return + + print(f"\nCalling tool '{tool_name}' with args: {args}") + result = await call_tool_with_error_handling(session, tool_name, args) + print_tool_result(result) + + +async def process_command(session: ClientSession, command: str) -> bool: + """Process a single command. Returns False if should exit.""" + if command in {"quit", "exit"}: + print("Goodbye!") + return False + + if command == "help": + print_help() + elif command == "list-tools": + await handle_list_tools(session) + elif command.startswith("call "): + await handle_call_command(session, command) + elif command == "secure-payment": + print("\nTesting secure_payment tool (uses ctx.elicit_url())...") + result = await call_tool_with_error_handling(session, "secure_payment", {"amount": 99.99}) + print_tool_result(result) + elif command == "connect-service": + print("\nTesting connect_service tool (raises UrlElicitationRequiredError)...") + result = await call_tool_with_error_handling(session, "connect_service", {"service_name": "github"}) + print_tool_result(result) + else: + print(f"Unknown command: {command}") + print("Type 'help' for available commands.") + + return True + + +async def run_command_loop(session: ClientSession) -> None: + """Run the interactive command loop.""" + while True: + try: + command = input("> ").strip() + except EOFError: + break + except KeyboardInterrupt: + print("\n") + break + + if not command: + continue + + if not await process_command(session, command): + break + + +async def main() -> None: + """Run the interactive URL elicitation client.""" + server_url = "http://localhost:8000/sse" + + print("=" * 60) + print("URL Elicitation Client Example") + print("=" * 60) + print(f"\nConnecting to: {server_url}") + print("(Start server with: cd examples/snippets && uv run server elicitation sse)") + + try: + async with sse_client(server_url) as (read, write): + async with ClientSession( + read, + write, + elicitation_callback=handle_elicitation, + ) as session: + await session.initialize() + print("\nConnected! Type 'help' for available commands.\n") + await run_command_loop(session) + + except ConnectionRefusedError: + print(f"\nError: Could not connect to {server_url}") + print("Make sure the elicitation server is running:") + print(" cd examples/snippets && uv run server elicitation sse") + except Exception as e: + print(f"\nError: {e}") + raise + + +def run() -> None: + """Entry point for the client script.""" + asyncio.run(main()) + + +if __name__ == "__main__": + run() diff --git a/examples/snippets/pyproject.toml b/examples/snippets/pyproject.toml index 4e68846a0..2a4bf0998 100644 --- a/examples/snippets/pyproject.toml +++ b/examples/snippets/pyproject.toml @@ -1,24 +1,24 @@ -[project] -name = "mcp-snippets" -version = "0.1.0" -description = "MCP Example Snippets" -requires-python = ">=3.10" -dependencies = [ - "mcp", -] - -[build-system] -requires = ["setuptools", "wheel"] -build-backend = "setuptools.build_meta" - -[tool.setuptools] -packages = ["servers", "clients"] - -[project.scripts] -server = "servers:run_server" -client = "clients.stdio_client:main" -completion-client = "clients.completion_client:main" -direct-execution-server = "servers.direct_execution:main" -display-utilities-client = "clients.display_utilities:main" -oauth-client = "clients.oauth_client:run" -elicitation-client = "clients.url_elicitation_client:run" +[project] +name = "mcp-snippets" +version = "0.1.0" +description = "MCP Example Snippets" +requires-python = ">=3.10" +dependencies = [ + "mcp", +] + +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["servers", "clients"] + +[project.scripts] +server = "servers:run_server" +client = "clients.stdio_client:main" +completion-client = "clients.completion_client:main" +direct-execution-server = "servers.direct_execution:main" +display-utilities-client = "clients.display_utilities:main" +oauth-client = "clients.oauth_client:run" +elicitation-client = "clients.url_elicitation_client:run" diff --git a/examples/snippets/servers/__init__.py b/examples/snippets/servers/__init__.py index f132f875f..2a40f1b5b 100644 --- a/examples/snippets/servers/__init__.py +++ b/examples/snippets/servers/__init__.py @@ -1,37 +1,37 @@ -"""MCP Snippets. - -This package contains simple examples of MCP server features. -Each server demonstrates a single feature and can be run as a standalone server. - -To run a server, use the command: - uv run server basic_tool sse -""" - -import importlib -import sys -from typing import Literal, cast - - -def run_server(): - """Run a server by name with optional transport. - - Usage: server [transport] - Example: server basic_tool sse - """ - if len(sys.argv) < 2: - print("Usage: server [transport]") - print("Available servers: basic_tool, basic_resource, basic_prompt, tool_progress,") - print(" sampling, elicitation, completion, notifications,") - print(" mcpserver_quickstart, structured_output, images") - print("Available transports: stdio (default), sse, streamable-http") - sys.exit(1) - - server_name = sys.argv[1] - transport = sys.argv[2] if len(sys.argv) > 2 else "stdio" - - try: - module = importlib.import_module(f".{server_name}", package=__name__) - module.mcp.run(cast(Literal["stdio", "sse", "streamable-http"], transport)) - except ImportError: - print(f"Error: Server '{server_name}' not found") - sys.exit(1) +"""MCP Snippets. + +This package contains simple examples of MCP server features. +Each server demonstrates a single feature and can be run as a standalone server. + +To run a server, use the command: + uv run server basic_tool sse +""" + +import importlib +import sys +from typing import Literal, cast + + +def run_server(): + """Run a server by name with optional transport. + + Usage: server [transport] + Example: server basic_tool sse + """ + if len(sys.argv) < 2: + print("Usage: server [transport]") + print("Available servers: basic_tool, basic_resource, basic_prompt, tool_progress,") + print(" sampling, elicitation, completion, notifications,") + print(" mcpserver_quickstart, structured_output, images") + print("Available transports: stdio (default), sse, streamable-http") + sys.exit(1) + + server_name = sys.argv[1] + transport = sys.argv[2] if len(sys.argv) > 2 else "stdio" + + try: + module = importlib.import_module(f".{server_name}", package=__name__) + module.mcp.run(cast(Literal["stdio", "sse", "streamable-http"], transport)) + except ImportError: + print(f"Error: Server '{server_name}' not found") + sys.exit(1) diff --git a/examples/snippets/servers/basic_prompt.py b/examples/snippets/servers/basic_prompt.py index d2eee9729..634f64a3b 100644 --- a/examples/snippets/servers/basic_prompt.py +++ b/examples/snippets/servers/basic_prompt.py @@ -1,18 +1,18 @@ -from mcp.server.mcpserver import MCPServer -from mcp.server.mcpserver.prompts import base - -mcp = MCPServer(name="Prompt Example") - - -@mcp.prompt(title="Code Review") -def review_code(code: str) -> str: - return f"Please review this code:\n\n{code}" - - -@mcp.prompt(title="Debug Assistant") -def debug_error(error: str) -> list[base.Message]: - return [ - base.UserMessage("I'm seeing this error:"), - base.UserMessage(error), - base.AssistantMessage("I'll help debug that. What have you tried so far?"), - ] +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.prompts import base + +mcp = MCPServer(name="Prompt Example") + + +@mcp.prompt(title="Code Review") +def review_code(code: str) -> str: + return f"Please review this code:\n\n{code}" + + +@mcp.prompt(title="Debug Assistant") +def debug_error(error: str) -> list[base.Message]: + return [ + base.UserMessage("I'm seeing this error:"), + base.UserMessage(error), + base.AssistantMessage("I'll help debug that. What have you tried so far?"), + ] diff --git a/examples/snippets/servers/basic_resource.py b/examples/snippets/servers/basic_resource.py index 38d96b491..f78b77b37 100644 --- a/examples/snippets/servers/basic_resource.py +++ b/examples/snippets/servers/basic_resource.py @@ -1,20 +1,20 @@ -from mcp.server.mcpserver import MCPServer - -mcp = MCPServer(name="Resource Example") - - -@mcp.resource("file://documents/{name}") -def read_document(name: str) -> str: - """Read a document by name.""" - # This would normally read from disk - return f"Content of {name}" - - -@mcp.resource("config://settings") -def get_settings() -> str: - """Get application settings.""" - return """{ - "theme": "dark", - "language": "en", - "debug": false -}""" +from mcp.server.mcpserver import MCPServer + +mcp = MCPServer(name="Resource Example") + + +@mcp.resource("file://documents/{name}") +def read_document(name: str) -> str: + """Read a document by name.""" + # This would normally read from disk + return f"Content of {name}" + + +@mcp.resource("config://settings") +def get_settings() -> str: + """Get application settings.""" + return """{ + "theme": "dark", + "language": "en", + "debug": false +}""" diff --git a/examples/snippets/servers/basic_tool.py b/examples/snippets/servers/basic_tool.py index 7648024bc..4593dae45 100644 --- a/examples/snippets/servers/basic_tool.py +++ b/examples/snippets/servers/basic_tool.py @@ -1,16 +1,16 @@ -from mcp.server.mcpserver import MCPServer - -mcp = MCPServer(name="Tool Example") - - -@mcp.tool() -def sum(a: int, b: int) -> int: - """Add two numbers together.""" - return a + b - - -@mcp.tool() -def get_weather(city: str, unit: str = "celsius") -> str: - """Get weather for a city.""" - # This would normally call a weather API - return f"Weather in {city}: 22degrees{unit[0].upper()}" +from mcp.server.mcpserver import MCPServer + +mcp = MCPServer(name="Tool Example") + + +@mcp.tool() +def sum(a: int, b: int) -> int: + """Add two numbers together.""" + return a + b + + +@mcp.tool() +def get_weather(city: str, unit: str = "celsius") -> str: + """Get weather for a city.""" + # This would normally call a weather API + return f"Weather in {city}: 22degrees{unit[0].upper()}" diff --git a/examples/snippets/servers/completion.py b/examples/snippets/servers/completion.py index 47accffa3..10d518c7f 100644 --- a/examples/snippets/servers/completion.py +++ b/examples/snippets/servers/completion.py @@ -1,49 +1,49 @@ -from mcp.server.mcpserver import MCPServer -from mcp.types import ( - Completion, - CompletionArgument, - CompletionContext, - PromptReference, - ResourceTemplateReference, -) - -mcp = MCPServer(name="Example") - - -@mcp.resource("github://repos/{owner}/{repo}") -def github_repo(owner: str, repo: str) -> str: - """GitHub repository resource.""" - return f"Repository: {owner}/{repo}" - - -@mcp.prompt(description="Code review prompt") -def review_code(language: str, code: str) -> str: - """Generate a code review.""" - return f"Review this {language} code:\n{code}" - - -@mcp.completion() -async def handle_completion( - ref: PromptReference | ResourceTemplateReference, - argument: CompletionArgument, - context: CompletionContext | None, -) -> Completion | None: - """Provide completions for prompts and resources.""" - - # Complete programming languages for the prompt - if isinstance(ref, PromptReference): - if ref.name == "review_code" and argument.name == "language": - languages = ["python", "javascript", "typescript", "go", "rust"] - return Completion( - values=[lang for lang in languages if lang.startswith(argument.value)], - has_more=False, - ) - - # Complete repository names for GitHub resources - if isinstance(ref, ResourceTemplateReference): - if ref.uri == "github://repos/{owner}/{repo}" and argument.name == "repo": - if context and context.arguments and context.arguments.get("owner") == "modelcontextprotocol": - repos = ["python-sdk", "typescript-sdk", "specification"] - return Completion(values=repos, has_more=False) - - return None +from mcp.server.mcpserver import MCPServer +from mcp.types import ( + Completion, + CompletionArgument, + CompletionContext, + PromptReference, + ResourceTemplateReference, +) + +mcp = MCPServer(name="Example") + + +@mcp.resource("github://repos/{owner}/{repo}") +def github_repo(owner: str, repo: str) -> str: + """GitHub repository resource.""" + return f"Repository: {owner}/{repo}" + + +@mcp.prompt(description="Code review prompt") +def review_code(language: str, code: str) -> str: + """Generate a code review.""" + return f"Review this {language} code:\n{code}" + + +@mcp.completion() +async def handle_completion( + ref: PromptReference | ResourceTemplateReference, + argument: CompletionArgument, + context: CompletionContext | None, +) -> Completion | None: + """Provide completions for prompts and resources.""" + + # Complete programming languages for the prompt + if isinstance(ref, PromptReference): + if ref.name == "review_code" and argument.name == "language": + languages = ["python", "javascript", "typescript", "go", "rust"] + return Completion( + values=[lang for lang in languages if lang.startswith(argument.value)], + has_more=False, + ) + + # Complete repository names for GitHub resources + if isinstance(ref, ResourceTemplateReference): + if ref.uri == "github://repos/{owner}/{repo}" and argument.name == "repo": + if context and context.arguments and context.arguments.get("owner") == "modelcontextprotocol": + repos = ["python-sdk", "typescript-sdk", "specification"] + return Completion(values=repos, has_more=False) + + return None diff --git a/examples/snippets/servers/direct_call_tool_result.py b/examples/snippets/servers/direct_call_tool_result.py index 4c98c358e..ce66b984f 100644 --- a/examples/snippets/servers/direct_call_tool_result.py +++ b/examples/snippets/servers/direct_call_tool_result.py @@ -1,42 +1,42 @@ -"""Example showing direct CallToolResult return for advanced control.""" - -from typing import Annotated - -from pydantic import BaseModel - -from mcp.server.mcpserver import MCPServer -from mcp.types import CallToolResult, TextContent - -mcp = MCPServer("CallToolResult Example") - - -class ValidationModel(BaseModel): - """Model for validating structured output.""" - - status: str - data: dict[str, int] - - -@mcp.tool() -def advanced_tool() -> CallToolResult: - """Return CallToolResult directly for full control including _meta field.""" - return CallToolResult( - content=[TextContent(type="text", text="Response visible to the model")], - _meta={"hidden": "data for client applications only"}, - ) - - -@mcp.tool() -def validated_tool() -> Annotated[CallToolResult, ValidationModel]: - """Return CallToolResult with structured output validation.""" - return CallToolResult( - content=[TextContent(type="text", text="Validated response")], - structured_content={"status": "success", "data": {"result": 42}}, - _meta={"internal": "metadata"}, - ) - - -@mcp.tool() -def empty_result_tool() -> CallToolResult: - """For empty results, return CallToolResult with empty content.""" - return CallToolResult(content=[]) +"""Example showing direct CallToolResult return for advanced control.""" + +from typing import Annotated + +from pydantic import BaseModel + +from mcp.server.mcpserver import MCPServer +from mcp.types import CallToolResult, TextContent + +mcp = MCPServer("CallToolResult Example") + + +class ValidationModel(BaseModel): + """Model for validating structured output.""" + + status: str + data: dict[str, int] + + +@mcp.tool() +def advanced_tool() -> CallToolResult: + """Return CallToolResult directly for full control including _meta field.""" + return CallToolResult( + content=[TextContent(type="text", text="Response visible to the model")], + _meta={"hidden": "data for client applications only"}, + ) + + +@mcp.tool() +def validated_tool() -> Annotated[CallToolResult, ValidationModel]: + """Return CallToolResult with structured output validation.""" + return CallToolResult( + content=[TextContent(type="text", text="Validated response")], + structured_content={"status": "success", "data": {"result": 42}}, + _meta={"internal": "metadata"}, + ) + + +@mcp.tool() +def empty_result_tool() -> CallToolResult: + """For empty results, return CallToolResult with empty content.""" + return CallToolResult(content=[]) diff --git a/examples/snippets/servers/direct_execution.py b/examples/snippets/servers/direct_execution.py index acf7151d3..8df359f1a 100644 --- a/examples/snippets/servers/direct_execution.py +++ b/examples/snippets/servers/direct_execution.py @@ -1,27 +1,27 @@ -"""Example showing direct execution of an MCP server. - -This is the simplest way to run an MCP server directly. -cd to the `examples/snippets` directory and run: - uv run direct-execution-server - or - python servers/direct_execution.py -""" - -from mcp.server.mcpserver import MCPServer - -mcp = MCPServer("My App") - - -@mcp.tool() -def hello(name: str = "World") -> str: - """Say hello to someone.""" - return f"Hello, {name}!" - - -def main(): - """Entry point for the direct execution server.""" - mcp.run() - - -if __name__ == "__main__": - main() +"""Example showing direct execution of an MCP server. + +This is the simplest way to run an MCP server directly. +cd to the `examples/snippets` directory and run: + uv run direct-execution-server + or + python servers/direct_execution.py +""" + +from mcp.server.mcpserver import MCPServer + +mcp = MCPServer("My App") + + +@mcp.tool() +def hello(name: str = "World") -> str: + """Say hello to someone.""" + return f"Hello, {name}!" + + +def main(): + """Entry point for the direct execution server.""" + mcp.run() + + +if __name__ == "__main__": + main() diff --git a/examples/snippets/servers/elicitation.py b/examples/snippets/servers/elicitation.py index 79453f543..fb75de0fd 100644 --- a/examples/snippets/servers/elicitation.py +++ b/examples/snippets/servers/elicitation.py @@ -1,98 +1,98 @@ -"""Elicitation examples demonstrating form and URL mode elicitation. - -Form mode elicitation collects structured, non-sensitive data through a schema. -URL mode elicitation directs users to external URLs for sensitive operations -like OAuth flows, credential collection, or payment processing. -""" - -import uuid - -from pydantic import BaseModel, Field - -from mcp.server.mcpserver import Context, MCPServer -from mcp.shared.exceptions import UrlElicitationRequiredError -from mcp.types import ElicitRequestURLParams - -mcp = MCPServer(name="Elicitation Example") - - -class BookingPreferences(BaseModel): - """Schema for collecting user preferences.""" - - checkAlternative: bool = Field(description="Would you like to check another date?") - alternativeDate: str = Field( - default="2024-12-26", - description="Alternative date (YYYY-MM-DD)", - ) - - -@mcp.tool() -async def book_table(date: str, time: str, party_size: int, ctx: Context) -> str: - """Book a table with date availability check. - - This demonstrates form mode elicitation for collecting non-sensitive user input. - """ - # Check if date is available - if date == "2024-12-25": - # Date unavailable - ask user for alternative - result = await ctx.elicit( - message=(f"No tables available for {party_size} on {date}. Would you like to try another date?"), - schema=BookingPreferences, - ) - - if result.action == "accept" and result.data: - if result.data.checkAlternative: - return f"[SUCCESS] Booked for {result.data.alternativeDate}" - return "[CANCELLED] No booking made" - return "[CANCELLED] Booking cancelled" - - # Date available - return f"[SUCCESS] Booked for {date} at {time}" - - -@mcp.tool() -async def secure_payment(amount: float, ctx: Context) -> str: - """Process a secure payment requiring URL confirmation. - - This demonstrates URL mode elicitation using ctx.elicit_url() for - operations that require out-of-band user interaction. - """ - elicitation_id = str(uuid.uuid4()) - - result = await ctx.elicit_url( - message=f"Please confirm payment of ${amount:.2f}", - url=f"https://payments.example.com/confirm?amount={amount}&id={elicitation_id}", - elicitation_id=elicitation_id, - ) - - if result.action == "accept": - # In a real app, the payment confirmation would happen out-of-band - # and you'd verify the payment status from your backend - return f"Payment of ${amount:.2f} initiated - check your browser to complete" - elif result.action == "decline": - return "Payment declined by user" - return "Payment cancelled" - - -@mcp.tool() -async def connect_service(service_name: str, ctx: Context) -> str: - """Connect to a third-party service requiring OAuth authorization. - - This demonstrates the "throw error" pattern using UrlElicitationRequiredError. - Use this pattern when the tool cannot proceed without user authorization. - """ - elicitation_id = str(uuid.uuid4()) - - # Raise UrlElicitationRequiredError to signal that the client must complete - # a URL elicitation before this request can be processed. - # The MCP framework will convert this to a -32042 error response. - raise UrlElicitationRequiredError( - [ - ElicitRequestURLParams( - mode="url", - message=f"Authorization required to connect to {service_name}", - url=f"https://{service_name}.example.com/oauth/authorize?elicit={elicitation_id}", - elicitation_id=elicitation_id, - ) - ] - ) +"""Elicitation examples demonstrating form and URL mode elicitation. + +Form mode elicitation collects structured, non-sensitive data through a schema. +URL mode elicitation directs users to external URLs for sensitive operations +like OAuth flows, credential collection, or payment processing. +""" + +import uuid + +from pydantic import BaseModel, Field + +from mcp.server.mcpserver import Context, MCPServer +from mcp.shared.exceptions import UrlElicitationRequiredError +from mcp.types import ElicitRequestURLParams + +mcp = MCPServer(name="Elicitation Example") + + +class BookingPreferences(BaseModel): + """Schema for collecting user preferences.""" + + checkAlternative: bool = Field(description="Would you like to check another date?") + alternativeDate: str = Field( + default="2024-12-26", + description="Alternative date (YYYY-MM-DD)", + ) + + +@mcp.tool() +async def book_table(date: str, time: str, party_size: int, ctx: Context) -> str: + """Book a table with date availability check. + + This demonstrates form mode elicitation for collecting non-sensitive user input. + """ + # Check if date is available + if date == "2024-12-25": + # Date unavailable - ask user for alternative + result = await ctx.elicit( + message=(f"No tables available for {party_size} on {date}. Would you like to try another date?"), + schema=BookingPreferences, + ) + + if result.action == "accept" and result.data: + if result.data.checkAlternative: + return f"[SUCCESS] Booked for {result.data.alternativeDate}" + return "[CANCELLED] No booking made" + return "[CANCELLED] Booking cancelled" + + # Date available + return f"[SUCCESS] Booked for {date} at {time}" + + +@mcp.tool() +async def secure_payment(amount: float, ctx: Context) -> str: + """Process a secure payment requiring URL confirmation. + + This demonstrates URL mode elicitation using ctx.elicit_url() for + operations that require out-of-band user interaction. + """ + elicitation_id = str(uuid.uuid4()) + + result = await ctx.elicit_url( + message=f"Please confirm payment of ${amount:.2f}", + url=f"https://payments.example.com/confirm?amount={amount}&id={elicitation_id}", + elicitation_id=elicitation_id, + ) + + if result.action == "accept": + # In a real app, the payment confirmation would happen out-of-band + # and you'd verify the payment status from your backend + return f"Payment of ${amount:.2f} initiated - check your browser to complete" + elif result.action == "decline": + return "Payment declined by user" + return "Payment cancelled" + + +@mcp.tool() +async def connect_service(service_name: str, ctx: Context) -> str: + """Connect to a third-party service requiring OAuth authorization. + + This demonstrates the "throw error" pattern using UrlElicitationRequiredError. + Use this pattern when the tool cannot proceed without user authorization. + """ + elicitation_id = str(uuid.uuid4()) + + # Raise UrlElicitationRequiredError to signal that the client must complete + # a URL elicitation before this request can be processed. + # The MCP framework will convert this to a -32042 error response. + raise UrlElicitationRequiredError( + [ + ElicitRequestURLParams( + mode="url", + message=f"Authorization required to connect to {service_name}", + url=f"https://{service_name}.example.com/oauth/authorize?elicit={elicitation_id}", + elicitation_id=elicitation_id, + ) + ] + ) diff --git a/examples/snippets/servers/images.py b/examples/snippets/servers/images.py index b30c9a8f7..b05ccafd8 100644 --- a/examples/snippets/servers/images.py +++ b/examples/snippets/servers/images.py @@ -1,15 +1,15 @@ -"""Example showing image handling with MCPServer.""" - -from PIL import Image as PILImage - -from mcp.server.mcpserver import Image, MCPServer - -mcp = MCPServer("Image Example") - - -@mcp.tool() -def create_thumbnail(image_path: str) -> Image: - """Create a thumbnail from an image""" - img = PILImage.open(image_path) - img.thumbnail((100, 100)) - return Image(data=img.tobytes(), format="png") +"""Example showing image handling with MCPServer.""" + +from PIL import Image as PILImage + +from mcp.server.mcpserver import Image, MCPServer + +mcp = MCPServer("Image Example") + + +@mcp.tool() +def create_thumbnail(image_path: str) -> Image: + """Create a thumbnail from an image""" + img = PILImage.open(image_path) + img.thumbnail((100, 100)) + return Image(data=img.tobytes(), format="png") diff --git a/examples/snippets/servers/lifespan_example.py b/examples/snippets/servers/lifespan_example.py index f290d31dd..cc07f4d53 100644 --- a/examples/snippets/servers/lifespan_example.py +++ b/examples/snippets/servers/lifespan_example.py @@ -1,56 +1,56 @@ -"""Example showing lifespan support for startup/shutdown with strong typing.""" - -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from dataclasses import dataclass - -from mcp.server.mcpserver import Context, MCPServer - - -# Mock database class for example -class Database: - """Mock database class for example.""" - - @classmethod - async def connect(cls) -> "Database": - """Connect to database.""" - return cls() - - async def disconnect(self) -> None: - """Disconnect from database.""" - pass - - def query(self) -> str: - """Execute a query.""" - return "Query result" - - -@dataclass -class AppContext: - """Application context with typed dependencies.""" - - db: Database - - -@asynccontextmanager -async def app_lifespan(server: MCPServer) -> AsyncIterator[AppContext]: - """Manage application lifecycle with type-safe context.""" - # Initialize on startup - db = await Database.connect() - try: - yield AppContext(db=db) - finally: - # Cleanup on shutdown - await db.disconnect() - - -# Pass lifespan to server -mcp = MCPServer("My App", lifespan=app_lifespan) - - -# Access type-safe lifespan context in tools -@mcp.tool() -def query_db(ctx: Context[AppContext]) -> str: - """Tool that uses initialized resources.""" - db = ctx.request_context.lifespan_context.db - return db.query() +"""Example showing lifespan support for startup/shutdown with strong typing.""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from dataclasses import dataclass + +from mcp.server.mcpserver import Context, MCPServer + + +# Mock database class for example +class Database: + """Mock database class for example.""" + + @classmethod + async def connect(cls) -> "Database": + """Connect to database.""" + return cls() + + async def disconnect(self) -> None: + """Disconnect from database.""" + pass + + def query(self) -> str: + """Execute a query.""" + return "Query result" + + +@dataclass +class AppContext: + """Application context with typed dependencies.""" + + db: Database + + +@asynccontextmanager +async def app_lifespan(server: MCPServer) -> AsyncIterator[AppContext]: + """Manage application lifecycle with type-safe context.""" + # Initialize on startup + db = await Database.connect() + try: + yield AppContext(db=db) + finally: + # Cleanup on shutdown + await db.disconnect() + + +# Pass lifespan to server +mcp = MCPServer("My App", lifespan=app_lifespan) + + +# Access type-safe lifespan context in tools +@mcp.tool() +def query_db(ctx: Context[AppContext]) -> str: + """Tool that uses initialized resources.""" + db = ctx.request_context.lifespan_context.db + return db.query() diff --git a/examples/snippets/servers/lowlevel/__init__.py b/examples/snippets/servers/lowlevel/__init__.py index c6ae62db6..08d58c451 100644 --- a/examples/snippets/servers/lowlevel/__init__.py +++ b/examples/snippets/servers/lowlevel/__init__.py @@ -1 +1 @@ -"""Low-level server examples for MCP Python SDK.""" +"""Low-level server examples for MCP Python SDK.""" diff --git a/examples/snippets/servers/lowlevel/basic.py b/examples/snippets/servers/lowlevel/basic.py index 81f40e994..f829d0cac 100644 --- a/examples/snippets/servers/lowlevel/basic.py +++ b/examples/snippets/servers/lowlevel/basic.py @@ -1,63 +1,63 @@ -"""Run from the repository root: -uv run examples/snippets/servers/lowlevel/basic.py -""" - -import asyncio - -import mcp.server.stdio -from mcp import types -from mcp.server import Server, ServerRequestContext - - -async def handle_list_prompts( - ctx: ServerRequestContext, params: types.PaginatedRequestParams | None -) -> types.ListPromptsResult: - """List available prompts.""" - return types.ListPromptsResult( - prompts=[ - types.Prompt( - name="example-prompt", - description="An example prompt template", - arguments=[types.PromptArgument(name="arg1", description="Example argument", required=True)], - ) - ] - ) - - -async def handle_get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> types.GetPromptResult: - """Get a specific prompt by name.""" - if params.name != "example-prompt": - raise ValueError(f"Unknown prompt: {params.name}") - - arg1_value = (params.arguments or {}).get("arg1", "default") - - return types.GetPromptResult( - description="Example prompt", - messages=[ - types.PromptMessage( - role="user", - content=types.TextContent(type="text", text=f"Example prompt text with argument: {arg1_value}"), - ) - ], - ) - - -server = Server( - "example-server", - on_list_prompts=handle_list_prompts, - on_get_prompt=handle_get_prompt, -) - - -async def run(): - """Run the basic low-level server.""" - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - server.create_initialization_options(), - ) - - -if __name__ == "__main__": - asyncio.run(run()) +"""Run from the repository root: +uv run examples/snippets/servers/lowlevel/basic.py +""" + +import asyncio + +import mcp.server.stdio +from mcp import types +from mcp.server import Server, ServerRequestContext + + +async def handle_list_prompts( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListPromptsResult: + """List available prompts.""" + return types.ListPromptsResult( + prompts=[ + types.Prompt( + name="example-prompt", + description="An example prompt template", + arguments=[types.PromptArgument(name="arg1", description="Example argument", required=True)], + ) + ] + ) + + +async def handle_get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestParams) -> types.GetPromptResult: + """Get a specific prompt by name.""" + if params.name != "example-prompt": + raise ValueError(f"Unknown prompt: {params.name}") + + arg1_value = (params.arguments or {}).get("arg1", "default") + + return types.GetPromptResult( + description="Example prompt", + messages=[ + types.PromptMessage( + role="user", + content=types.TextContent(type="text", text=f"Example prompt text with argument: {arg1_value}"), + ) + ], + ) + + +server = Server( + "example-server", + on_list_prompts=handle_list_prompts, + on_get_prompt=handle_get_prompt, +) + + +async def run(): + """Run the basic low-level server.""" + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + server.create_initialization_options(), + ) + + +if __name__ == "__main__": + asyncio.run(run()) diff --git a/examples/snippets/servers/lowlevel/direct_call_tool_result.py b/examples/snippets/servers/lowlevel/direct_call_tool_result.py index 7e8fc4dcb..930748f1b 100644 --- a/examples/snippets/servers/lowlevel/direct_call_tool_result.py +++ b/examples/snippets/servers/lowlevel/direct_call_tool_result.py @@ -1,62 +1,62 @@ -"""Run from the repository root: -uv run examples/snippets/servers/lowlevel/direct_call_tool_result.py -""" - -import asyncio - -import mcp.server.stdio -from mcp import types -from mcp.server import Server, ServerRequestContext - - -async def handle_list_tools( - ctx: ServerRequestContext, params: types.PaginatedRequestParams | None -) -> types.ListToolsResult: - """List available tools.""" - return types.ListToolsResult( - tools=[ - types.Tool( - name="advanced_tool", - description="Tool with full control including _meta field", - input_schema={ - "type": "object", - "properties": {"message": {"type": "string"}}, - "required": ["message"], - }, - ) - ] - ) - - -async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: - """Handle tool calls by returning CallToolResult directly.""" - if params.name == "advanced_tool": - message = (params.arguments or {}).get("message", "") - return types.CallToolResult( - content=[types.TextContent(type="text", text=f"Processed: {message}")], - structured_content={"result": "success", "message": message}, - _meta={"hidden": "data for client applications only"}, - ) - - raise ValueError(f"Unknown tool: {params.name}") - - -server = Server( - "example-server", - on_list_tools=handle_list_tools, - on_call_tool=handle_call_tool, -) - - -async def run(): - """Run the server.""" - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - server.create_initialization_options(), - ) - - -if __name__ == "__main__": - asyncio.run(run()) +"""Run from the repository root: +uv run examples/snippets/servers/lowlevel/direct_call_tool_result.py +""" + +import asyncio + +import mcp.server.stdio +from mcp import types +from mcp.server import Server, ServerRequestContext + + +async def handle_list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListToolsResult: + """List available tools.""" + return types.ListToolsResult( + tools=[ + types.Tool( + name="advanced_tool", + description="Tool with full control including _meta field", + input_schema={ + "type": "object", + "properties": {"message": {"type": "string"}}, + "required": ["message"], + }, + ) + ] + ) + + +async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: + """Handle tool calls by returning CallToolResult directly.""" + if params.name == "advanced_tool": + message = (params.arguments or {}).get("message", "") + return types.CallToolResult( + content=[types.TextContent(type="text", text=f"Processed: {message}")], + structured_content={"result": "success", "message": message}, + _meta={"hidden": "data for client applications only"}, + ) + + raise ValueError(f"Unknown tool: {params.name}") + + +server = Server( + "example-server", + on_list_tools=handle_list_tools, + on_call_tool=handle_call_tool, +) + + +async def run(): + """Run the server.""" + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + server.create_initialization_options(), + ) + + +if __name__ == "__main__": + asyncio.run(run()) diff --git a/examples/snippets/servers/lowlevel/lifespan.py b/examples/snippets/servers/lowlevel/lifespan.py index bcd96c893..da3e19573 100644 --- a/examples/snippets/servers/lowlevel/lifespan.py +++ b/examples/snippets/servers/lowlevel/lifespan.py @@ -1,101 +1,101 @@ -"""Run from the repository root: -uv run examples/snippets/servers/lowlevel/lifespan.py -""" - -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager -from typing import TypedDict - -import mcp.server.stdio -from mcp import types -from mcp.server import Server, ServerRequestContext - - -# Mock database class for example -class Database: - """Mock database class for example.""" - - @classmethod - async def connect(cls) -> "Database": - """Connect to database.""" - print("Database connected") - return cls() - - async def disconnect(self) -> None: - """Disconnect from database.""" - print("Database disconnected") - - async def query(self, query_str: str) -> list[dict[str, str]]: - """Execute a query.""" - # Simulate database query - return [{"id": "1", "name": "Example", "query": query_str}] - - -class AppContext(TypedDict): - db: Database - - -@asynccontextmanager -async def server_lifespan(_server: Server[AppContext]) -> AsyncIterator[AppContext]: - """Manage server startup and shutdown lifecycle.""" - db = await Database.connect() - try: - yield {"db": db} - finally: - await db.disconnect() - - -async def handle_list_tools( - ctx: ServerRequestContext[AppContext], params: types.PaginatedRequestParams | None -) -> types.ListToolsResult: - """List available tools.""" - return types.ListToolsResult( - tools=[ - types.Tool( - name="query_db", - description="Query the database", - input_schema={ - "type": "object", - "properties": {"query": {"type": "string", "description": "SQL query to execute"}}, - "required": ["query"], - }, - ) - ] - ) - - -async def handle_call_tool( - ctx: ServerRequestContext[AppContext], params: types.CallToolRequestParams -) -> types.CallToolResult: - """Handle database query tool call.""" - if params.name != "query_db": - raise ValueError(f"Unknown tool: {params.name}") - - db = ctx.lifespan_context["db"] - results = await db.query((params.arguments or {})["query"]) - - return types.CallToolResult(content=[types.TextContent(type="text", text=f"Query results: {results}")]) - - -server = Server( - "example-server", - lifespan=server_lifespan, - on_list_tools=handle_list_tools, - on_call_tool=handle_call_tool, -) - - -async def run(): - """Run the server with lifespan management.""" - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - server.create_initialization_options(), - ) - - -if __name__ == "__main__": - import asyncio - - asyncio.run(run()) +"""Run from the repository root: +uv run examples/snippets/servers/lowlevel/lifespan.py +""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import TypedDict + +import mcp.server.stdio +from mcp import types +from mcp.server import Server, ServerRequestContext + + +# Mock database class for example +class Database: + """Mock database class for example.""" + + @classmethod + async def connect(cls) -> "Database": + """Connect to database.""" + print("Database connected") + return cls() + + async def disconnect(self) -> None: + """Disconnect from database.""" + print("Database disconnected") + + async def query(self, query_str: str) -> list[dict[str, str]]: + """Execute a query.""" + # Simulate database query + return [{"id": "1", "name": "Example", "query": query_str}] + + +class AppContext(TypedDict): + db: Database + + +@asynccontextmanager +async def server_lifespan(_server: Server[AppContext]) -> AsyncIterator[AppContext]: + """Manage server startup and shutdown lifecycle.""" + db = await Database.connect() + try: + yield {"db": db} + finally: + await db.disconnect() + + +async def handle_list_tools( + ctx: ServerRequestContext[AppContext], params: types.PaginatedRequestParams | None +) -> types.ListToolsResult: + """List available tools.""" + return types.ListToolsResult( + tools=[ + types.Tool( + name="query_db", + description="Query the database", + input_schema={ + "type": "object", + "properties": {"query": {"type": "string", "description": "SQL query to execute"}}, + "required": ["query"], + }, + ) + ] + ) + + +async def handle_call_tool( + ctx: ServerRequestContext[AppContext], params: types.CallToolRequestParams +) -> types.CallToolResult: + """Handle database query tool call.""" + if params.name != "query_db": + raise ValueError(f"Unknown tool: {params.name}") + + db = ctx.lifespan_context["db"] + results = await db.query((params.arguments or {})["query"]) + + return types.CallToolResult(content=[types.TextContent(type="text", text=f"Query results: {results}")]) + + +server = Server( + "example-server", + lifespan=server_lifespan, + on_list_tools=handle_list_tools, + on_call_tool=handle_call_tool, +) + + +async def run(): + """Run the server with lifespan management.""" + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + server.create_initialization_options(), + ) + + +if __name__ == "__main__": + import asyncio + + asyncio.run(run()) diff --git a/examples/snippets/servers/lowlevel/structured_output.py b/examples/snippets/servers/lowlevel/structured_output.py index f93c8875f..ed16cd031 100644 --- a/examples/snippets/servers/lowlevel/structured_output.py +++ b/examples/snippets/servers/lowlevel/structured_output.py @@ -1,80 +1,80 @@ -"""Run from the repository root: -uv run examples/snippets/servers/lowlevel/structured_output.py -""" - -import asyncio -import json - -import mcp.server.stdio -from mcp import types -from mcp.server import Server, ServerRequestContext - - -async def handle_list_tools( - ctx: ServerRequestContext, params: types.PaginatedRequestParams | None -) -> types.ListToolsResult: - """List available tools with structured output schemas.""" - return types.ListToolsResult( - tools=[ - types.Tool( - name="get_weather", - description="Get current weather for a city", - input_schema={ - "type": "object", - "properties": {"city": {"type": "string", "description": "City name"}}, - "required": ["city"], - }, - output_schema={ - "type": "object", - "properties": { - "temperature": {"type": "number", "description": "Temperature in Celsius"}, - "condition": {"type": "string", "description": "Weather condition"}, - "humidity": {"type": "number", "description": "Humidity percentage"}, - "city": {"type": "string", "description": "City name"}, - }, - "required": ["temperature", "condition", "humidity", "city"], - }, - ) - ] - ) - - -async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: - """Handle tool calls with structured output.""" - if params.name == "get_weather": - city = (params.arguments or {})["city"] - - weather_data = { - "temperature": 22.5, - "condition": "partly cloudy", - "humidity": 65, - "city": city, - } - - return types.CallToolResult( - content=[types.TextContent(type="text", text=json.dumps(weather_data, indent=2))], - structured_content=weather_data, - ) - - raise ValueError(f"Unknown tool: {params.name}") - - -server = Server( - "example-server", - on_list_tools=handle_list_tools, - on_call_tool=handle_call_tool, -) - - -async def run(): - """Run the structured output server.""" - async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): - await server.run( - read_stream, - write_stream, - server.create_initialization_options(), - ) - - -if __name__ == "__main__": - asyncio.run(run()) +"""Run from the repository root: +uv run examples/snippets/servers/lowlevel/structured_output.py +""" + +import asyncio +import json + +import mcp.server.stdio +from mcp import types +from mcp.server import Server, ServerRequestContext + + +async def handle_list_tools( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListToolsResult: + """List available tools with structured output schemas.""" + return types.ListToolsResult( + tools=[ + types.Tool( + name="get_weather", + description="Get current weather for a city", + input_schema={ + "type": "object", + "properties": {"city": {"type": "string", "description": "City name"}}, + "required": ["city"], + }, + output_schema={ + "type": "object", + "properties": { + "temperature": {"type": "number", "description": "Temperature in Celsius"}, + "condition": {"type": "string", "description": "Weather condition"}, + "humidity": {"type": "number", "description": "Humidity percentage"}, + "city": {"type": "string", "description": "City name"}, + }, + "required": ["temperature", "condition", "humidity", "city"], + }, + ) + ] + ) + + +async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> types.CallToolResult: + """Handle tool calls with structured output.""" + if params.name == "get_weather": + city = (params.arguments or {})["city"] + + weather_data = { + "temperature": 22.5, + "condition": "partly cloudy", + "humidity": 65, + "city": city, + } + + return types.CallToolResult( + content=[types.TextContent(type="text", text=json.dumps(weather_data, indent=2))], + structured_content=weather_data, + ) + + raise ValueError(f"Unknown tool: {params.name}") + + +server = Server( + "example-server", + on_list_tools=handle_list_tools, + on_call_tool=handle_call_tool, +) + + +async def run(): + """Run the structured output server.""" + async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): + await server.run( + read_stream, + write_stream, + server.create_initialization_options(), + ) + + +if __name__ == "__main__": + asyncio.run(run()) diff --git a/examples/snippets/servers/mcpserver_quickstart.py b/examples/snippets/servers/mcpserver_quickstart.py index 70a83a56e..aa3f7d9a8 100644 --- a/examples/snippets/servers/mcpserver_quickstart.py +++ b/examples/snippets/servers/mcpserver_quickstart.py @@ -1,42 +1,42 @@ -"""MCPServer quickstart example. - -Run from the repository root: - uv run examples/snippets/servers/mcpserver_quickstart.py -""" - -from mcp.server.mcpserver import MCPServer - -# Create an MCP server -mcp = MCPServer("Demo") - - -# Add an addition tool -@mcp.tool() -def add(a: int, b: int) -> int: - """Add two numbers""" - return a + b - - -# Add a dynamic greeting resource -@mcp.resource("greeting://{name}") -def get_greeting(name: str) -> str: - """Get a personalized greeting""" - return f"Hello, {name}!" - - -# Add a prompt -@mcp.prompt() -def greet_user(name: str, style: str = "friendly") -> str: - """Generate a greeting prompt""" - styles = { - "friendly": "Please write a warm, friendly greeting", - "formal": "Please write a formal, professional greeting", - "casual": "Please write a casual, relaxed greeting", - } - - return f"{styles.get(style, styles['friendly'])} for someone named {name}." - - -# Run with streamable HTTP transport -if __name__ == "__main__": - mcp.run(transport="streamable-http", json_response=True) +"""MCPServer quickstart example. + +Run from the repository root: + uv run examples/snippets/servers/mcpserver_quickstart.py +""" + +from mcp.server.mcpserver import MCPServer + +# Create an MCP server +mcp = MCPServer("Demo") + + +# Add an addition tool +@mcp.tool() +def add(a: int, b: int) -> int: + """Add two numbers""" + return a + b + + +# Add a dynamic greeting resource +@mcp.resource("greeting://{name}") +def get_greeting(name: str) -> str: + """Get a personalized greeting""" + return f"Hello, {name}!" + + +# Add a prompt +@mcp.prompt() +def greet_user(name: str, style: str = "friendly") -> str: + """Generate a greeting prompt""" + styles = { + "friendly": "Please write a warm, friendly greeting", + "formal": "Please write a formal, professional greeting", + "casual": "Please write a casual, relaxed greeting", + } + + return f"{styles.get(style, styles['friendly'])} for someone named {name}." + + +# Run with streamable HTTP transport +if __name__ == "__main__": + mcp.run(transport="streamable-http", json_response=True) diff --git a/examples/snippets/servers/notifications.py b/examples/snippets/servers/notifications.py index d6d903cc7..047480a8f 100644 --- a/examples/snippets/servers/notifications.py +++ b/examples/snippets/servers/notifications.py @@ -1,18 +1,18 @@ -from mcp.server.mcpserver import Context, MCPServer - -mcp = MCPServer(name="Notifications Example") - - -@mcp.tool() -async def process_data(data: str, ctx: Context) -> str: - """Process data with logging.""" - # Different log levels - await ctx.debug(f"Debug: Processing '{data}'") - await ctx.info("Info: Starting processing") - await ctx.warning("Warning: This is experimental") - await ctx.error("Error: (This is just a demo)") - - # Notify about resource changes - await ctx.session.send_resource_list_changed() - - return f"Processed: {data}" +from mcp.server.mcpserver import Context, MCPServer + +mcp = MCPServer(name="Notifications Example") + + +@mcp.tool() +async def process_data(data: str, ctx: Context) -> str: + """Process data with logging.""" + # Different log levels + await ctx.debug(f"Debug: Processing '{data}'") + await ctx.info("Info: Starting processing") + await ctx.warning("Warning: This is experimental") + await ctx.error("Error: (This is just a demo)") + + # Notify about resource changes + await ctx.session.send_resource_list_changed() + + return f"Processed: {data}" diff --git a/examples/snippets/servers/oauth_server.py b/examples/snippets/servers/oauth_server.py index 962ef0615..ad782941a 100644 --- a/examples/snippets/servers/oauth_server.py +++ b/examples/snippets/servers/oauth_server.py @@ -1,45 +1,45 @@ -"""Run from the repository root: -uv run examples/snippets/servers/oauth_server.py -""" - -from pydantic import AnyHttpUrl - -from mcp.server.auth.provider import AccessToken, TokenVerifier -from mcp.server.auth.settings import AuthSettings -from mcp.server.mcpserver import MCPServer - - -class SimpleTokenVerifier(TokenVerifier): - """Simple token verifier for demonstration.""" - - async def verify_token(self, token: str) -> AccessToken | None: - pass # This is where you would implement actual token validation - - -# Create MCPServer instance as a Resource Server -mcp = MCPServer( - "Weather Service", - # Token verifier for authentication - token_verifier=SimpleTokenVerifier(), - # Auth settings for RFC 9728 Protected Resource Metadata - auth=AuthSettings( - issuer_url=AnyHttpUrl("https://auth.example.com"), # Authorization Server URL - resource_server_url=AnyHttpUrl("http://localhost:3001"), # This server's URL - required_scopes=["user"], - ), -) - - -@mcp.tool() -async def get_weather(city: str = "London") -> dict[str, str]: - """Get weather data for a city""" - return { - "city": city, - "temperature": "22", - "condition": "Partly cloudy", - "humidity": "65%", - } - - -if __name__ == "__main__": - mcp.run(transport="streamable-http", json_response=True) +"""Run from the repository root: +uv run examples/snippets/servers/oauth_server.py +""" + +from pydantic import AnyHttpUrl + +from mcp.server.auth.provider import AccessToken, TokenVerifier +from mcp.server.auth.settings import AuthSettings +from mcp.server.mcpserver import MCPServer + + +class SimpleTokenVerifier(TokenVerifier): + """Simple token verifier for demonstration.""" + + async def verify_token(self, token: str) -> AccessToken | None: + pass # This is where you would implement actual token validation + + +# Create MCPServer instance as a Resource Server +mcp = MCPServer( + "Weather Service", + # Token verifier for authentication + token_verifier=SimpleTokenVerifier(), + # Auth settings for RFC 9728 Protected Resource Metadata + auth=AuthSettings( + issuer_url=AnyHttpUrl("https://auth.example.com"), # Authorization Server URL + resource_server_url=AnyHttpUrl("http://localhost:3001"), # This server's URL + required_scopes=["user"], + ), +) + + +@mcp.tool() +async def get_weather(city: str = "London") -> dict[str, str]: + """Get weather data for a city""" + return { + "city": city, + "temperature": "22", + "condition": "Partly cloudy", + "humidity": "65%", + } + + +if __name__ == "__main__": + mcp.run(transport="streamable-http", json_response=True) diff --git a/examples/snippets/servers/pagination_example.py b/examples/snippets/servers/pagination_example.py index bcd0ffb10..6e0dbd044 100644 --- a/examples/snippets/servers/pagination_example.py +++ b/examples/snippets/servers/pagination_example.py @@ -1,35 +1,35 @@ -"""Example of implementing pagination with the low-level MCP server.""" - -from mcp import types -from mcp.server import Server, ServerRequestContext - -# Sample data to paginate -ITEMS = [f"Item {i}" for i in range(1, 101)] # 100 items - - -async def handle_list_resources( - ctx: ServerRequestContext, params: types.PaginatedRequestParams | None -) -> types.ListResourcesResult: - """List resources with pagination support.""" - page_size = 10 - - # Extract cursor from request params - cursor = params.cursor if params is not None else None - - # Parse cursor to get offset - start = 0 if cursor is None else int(cursor) - end = start + page_size - - # Get page of resources - page_items = [ - types.Resource(uri=f"resource://items/{item}", name=item, description=f"Description for {item}") - for item in ITEMS[start:end] - ] - - # Determine next cursor - next_cursor = str(end) if end < len(ITEMS) else None - - return types.ListResourcesResult(resources=page_items, next_cursor=next_cursor) - - -server = Server("paginated-server", on_list_resources=handle_list_resources) +"""Example of implementing pagination with the low-level MCP server.""" + +from mcp import types +from mcp.server import Server, ServerRequestContext + +# Sample data to paginate +ITEMS = [f"Item {i}" for i in range(1, 101)] # 100 items + + +async def handle_list_resources( + ctx: ServerRequestContext, params: types.PaginatedRequestParams | None +) -> types.ListResourcesResult: + """List resources with pagination support.""" + page_size = 10 + + # Extract cursor from request params + cursor = params.cursor if params is not None else None + + # Parse cursor to get offset + start = 0 if cursor is None else int(cursor) + end = start + page_size + + # Get page of resources + page_items = [ + types.Resource(uri=f"resource://items/{item}", name=item, description=f"Description for {item}") + for item in ITEMS[start:end] + ] + + # Determine next cursor + next_cursor = str(end) if end < len(ITEMS) else None + + return types.ListResourcesResult(resources=page_items, next_cursor=next_cursor) + + +server = Server("paginated-server", on_list_resources=handle_list_resources) diff --git a/examples/snippets/servers/sampling.py b/examples/snippets/servers/sampling.py index 43259589a..f85a224cd 100644 --- a/examples/snippets/servers/sampling.py +++ b/examples/snippets/servers/sampling.py @@ -1,25 +1,25 @@ -from mcp.server.mcpserver import Context, MCPServer -from mcp.types import SamplingMessage, TextContent - -mcp = MCPServer(name="Sampling Example") - - -@mcp.tool() -async def generate_poem(topic: str, ctx: Context) -> str: - """Generate a poem using LLM sampling.""" - prompt = f"Write a short poem about {topic}" - - result = await ctx.session.create_message( - messages=[ - SamplingMessage( - role="user", - content=TextContent(type="text", text=prompt), - ) - ], - max_tokens=100, - ) - - # Since we're not passing tools param, result.content is single content - if result.content.type == "text": - return result.content.text - return str(result.content) +from mcp.server.mcpserver import Context, MCPServer +from mcp.types import SamplingMessage, TextContent + +mcp = MCPServer(name="Sampling Example") + + +@mcp.tool() +async def generate_poem(topic: str, ctx: Context) -> str: + """Generate a poem using LLM sampling.""" + prompt = f"Write a short poem about {topic}" + + result = await ctx.session.create_message( + messages=[ + SamplingMessage( + role="user", + content=TextContent(type="text", text=prompt), + ) + ], + max_tokens=100, + ) + + # Since we're not passing tools param, result.content is single content + if result.content.type == "text": + return result.content.text + return str(result.content) diff --git a/examples/snippets/servers/streamable_config.py b/examples/snippets/servers/streamable_config.py index 622e67063..fd867e685 100644 --- a/examples/snippets/servers/streamable_config.py +++ b/examples/snippets/servers/streamable_config.py @@ -1,28 +1,28 @@ -"""Run from the repository root: -uv run examples/snippets/servers/streamable_config.py -""" - -from mcp.server.mcpserver import MCPServer - -mcp = MCPServer("StatelessServer") - - -# Add a simple tool to demonstrate the server -@mcp.tool() -def greet(name: str = "World") -> str: - """Greet someone by name.""" - return f"Hello, {name}!" - - -# Run server with streamable_http transport -# Transport-specific options (stateless_http, json_response) are passed to run() -if __name__ == "__main__": - # Stateless server with JSON responses (recommended) - mcp.run(transport="streamable-http", stateless_http=True, json_response=True) - - # Other configuration options: - # Stateless server with SSE streaming responses - # mcp.run(transport="streamable-http", stateless_http=True) - - # Stateful server with session persistence - # mcp.run(transport="streamable-http") +"""Run from the repository root: +uv run examples/snippets/servers/streamable_config.py +""" + +from mcp.server.mcpserver import MCPServer + +mcp = MCPServer("StatelessServer") + + +# Add a simple tool to demonstrate the server +@mcp.tool() +def greet(name: str = "World") -> str: + """Greet someone by name.""" + return f"Hello, {name}!" + + +# Run server with streamable_http transport +# Transport-specific options (stateless_http, json_response) are passed to run() +if __name__ == "__main__": + # Stateless server with JSON responses (recommended) + mcp.run(transport="streamable-http", stateless_http=True, json_response=True) + + # Other configuration options: + # Stateless server with SSE streaming responses + # mcp.run(transport="streamable-http", stateless_http=True) + + # Stateful server with session persistence + # mcp.run(transport="streamable-http") diff --git a/examples/snippets/servers/streamable_http_basic_mounting.py b/examples/snippets/servers/streamable_http_basic_mounting.py index 9a53034f1..e9086fce0 100644 --- a/examples/snippets/servers/streamable_http_basic_mounting.py +++ b/examples/snippets/servers/streamable_http_basic_mounting.py @@ -1,38 +1,38 @@ -"""Basic example showing how to mount StreamableHTTP server in Starlette. - -Run from the repository root: - uvicorn examples.snippets.servers.streamable_http_basic_mounting:app --reload -""" - -import contextlib - -from starlette.applications import Starlette -from starlette.routing import Mount - -from mcp.server.mcpserver import MCPServer - -# Create MCP server -mcp = MCPServer("My App") - - -@mcp.tool() -def hello() -> str: - """A simple hello tool""" - return "Hello from MCP!" - - -# Create a lifespan context manager to run the session manager -@contextlib.asynccontextmanager -async def lifespan(app: Starlette): - async with mcp.session_manager.run(): - yield - - -# Mount the StreamableHTTP server to the existing ASGI server -# Transport-specific options are passed to streamable_http_app() -app = Starlette( - routes=[ - Mount("/", app=mcp.streamable_http_app(json_response=True)), - ], - lifespan=lifespan, -) +"""Basic example showing how to mount StreamableHTTP server in Starlette. + +Run from the repository root: + uvicorn examples.snippets.servers.streamable_http_basic_mounting:app --reload +""" + +import contextlib + +from starlette.applications import Starlette +from starlette.routing import Mount + +from mcp.server.mcpserver import MCPServer + +# Create MCP server +mcp = MCPServer("My App") + + +@mcp.tool() +def hello() -> str: + """A simple hello tool""" + return "Hello from MCP!" + + +# Create a lifespan context manager to run the session manager +@contextlib.asynccontextmanager +async def lifespan(app: Starlette): + async with mcp.session_manager.run(): + yield + + +# Mount the StreamableHTTP server to the existing ASGI server +# Transport-specific options are passed to streamable_http_app() +app = Starlette( + routes=[ + Mount("/", app=mcp.streamable_http_app(json_response=True)), + ], + lifespan=lifespan, +) diff --git a/examples/snippets/servers/streamable_http_host_mounting.py b/examples/snippets/servers/streamable_http_host_mounting.py index 2a41f74a5..acb4b2a02 100644 --- a/examples/snippets/servers/streamable_http_host_mounting.py +++ b/examples/snippets/servers/streamable_http_host_mounting.py @@ -1,38 +1,38 @@ -"""Example showing how to mount StreamableHTTP server using Host-based routing. - -Run from the repository root: - uvicorn examples.snippets.servers.streamable_http_host_mounting:app --reload -""" - -import contextlib - -from starlette.applications import Starlette -from starlette.routing import Host - -from mcp.server.mcpserver import MCPServer - -# Create MCP server -mcp = MCPServer("MCP Host App") - - -@mcp.tool() -def domain_info() -> str: - """Get domain-specific information""" - return "This is served from mcp.acme.corp" - - -# Create a lifespan context manager to run the session manager -@contextlib.asynccontextmanager -async def lifespan(app: Starlette): - async with mcp.session_manager.run(): - yield - - -# Mount using Host-based routing -# Transport-specific options are passed to streamable_http_app() -app = Starlette( - routes=[ - Host("mcp.acme.corp", app=mcp.streamable_http_app(json_response=True)), - ], - lifespan=lifespan, -) +"""Example showing how to mount StreamableHTTP server using Host-based routing. + +Run from the repository root: + uvicorn examples.snippets.servers.streamable_http_host_mounting:app --reload +""" + +import contextlib + +from starlette.applications import Starlette +from starlette.routing import Host + +from mcp.server.mcpserver import MCPServer + +# Create MCP server +mcp = MCPServer("MCP Host App") + + +@mcp.tool() +def domain_info() -> str: + """Get domain-specific information""" + return "This is served from mcp.acme.corp" + + +# Create a lifespan context manager to run the session manager +@contextlib.asynccontextmanager +async def lifespan(app: Starlette): + async with mcp.session_manager.run(): + yield + + +# Mount using Host-based routing +# Transport-specific options are passed to streamable_http_app() +app = Starlette( + routes=[ + Host("mcp.acme.corp", app=mcp.streamable_http_app(json_response=True)), + ], + lifespan=lifespan, +) diff --git a/examples/snippets/servers/streamable_http_multiple_servers.py b/examples/snippets/servers/streamable_http_multiple_servers.py index 71217bdfe..0b8e8b3fc 100644 --- a/examples/snippets/servers/streamable_http_multiple_servers.py +++ b/examples/snippets/servers/streamable_http_multiple_servers.py @@ -1,48 +1,48 @@ -"""Example showing how to mount multiple StreamableHTTP servers with path configuration. - -Run from the repository root: - uvicorn examples.snippets.servers.streamable_http_multiple_servers:app --reload -""" - -import contextlib - -from starlette.applications import Starlette -from starlette.routing import Mount - -from mcp.server.mcpserver import MCPServer - -# Create multiple MCP servers -api_mcp = MCPServer("API Server") -chat_mcp = MCPServer("Chat Server") - - -@api_mcp.tool() -def api_status() -> str: - """Get API status""" - return "API is running" - - -@chat_mcp.tool() -def send_message(message: str) -> str: - """Send a chat message""" - return f"Message sent: {message}" - - -# Create a combined lifespan to manage both session managers -@contextlib.asynccontextmanager -async def lifespan(app: Starlette): - async with contextlib.AsyncExitStack() as stack: - await stack.enter_async_context(api_mcp.session_manager.run()) - await stack.enter_async_context(chat_mcp.session_manager.run()) - yield - - -# Mount the servers with transport-specific options passed to streamable_http_app() -# streamable_http_path="/" means endpoints will be at /api and /chat instead of /api/mcp and /chat/mcp -app = Starlette( - routes=[ - Mount("/api", app=api_mcp.streamable_http_app(json_response=True, streamable_http_path="/")), - Mount("/chat", app=chat_mcp.streamable_http_app(json_response=True, streamable_http_path="/")), - ], - lifespan=lifespan, -) +"""Example showing how to mount multiple StreamableHTTP servers with path configuration. + +Run from the repository root: + uvicorn examples.snippets.servers.streamable_http_multiple_servers:app --reload +""" + +import contextlib + +from starlette.applications import Starlette +from starlette.routing import Mount + +from mcp.server.mcpserver import MCPServer + +# Create multiple MCP servers +api_mcp = MCPServer("API Server") +chat_mcp = MCPServer("Chat Server") + + +@api_mcp.tool() +def api_status() -> str: + """Get API status""" + return "API is running" + + +@chat_mcp.tool() +def send_message(message: str) -> str: + """Send a chat message""" + return f"Message sent: {message}" + + +# Create a combined lifespan to manage both session managers +@contextlib.asynccontextmanager +async def lifespan(app: Starlette): + async with contextlib.AsyncExitStack() as stack: + await stack.enter_async_context(api_mcp.session_manager.run()) + await stack.enter_async_context(chat_mcp.session_manager.run()) + yield + + +# Mount the servers with transport-specific options passed to streamable_http_app() +# streamable_http_path="/" means endpoints will be at /api and /chat instead of /api/mcp and /chat/mcp +app = Starlette( + routes=[ + Mount("/api", app=api_mcp.streamable_http_app(json_response=True, streamable_http_path="/")), + Mount("/chat", app=chat_mcp.streamable_http_app(json_response=True, streamable_http_path="/")), + ], + lifespan=lifespan, +) diff --git a/examples/snippets/servers/streamable_http_path_config.py b/examples/snippets/servers/streamable_http_path_config.py index 4c65ffdd7..d92e2e65a 100644 --- a/examples/snippets/servers/streamable_http_path_config.py +++ b/examples/snippets/servers/streamable_http_path_config.py @@ -1,31 +1,31 @@ -"""Example showing path configuration when mounting MCPServer. - -Run from the repository root: - uvicorn examples.snippets.servers.streamable_http_path_config:app --reload -""" - -from starlette.applications import Starlette -from starlette.routing import Mount - -from mcp.server.mcpserver import MCPServer - -# Create a simple MCPServer server -mcp_at_root = MCPServer("My Server") - - -@mcp_at_root.tool() -def process_data(data: str) -> str: - """Process some data""" - return f"Processed: {data}" - - -# Mount at /process with streamable_http_path="/" so the endpoint is /process (not /process/mcp) -# Transport-specific options like json_response are passed to streamable_http_app() -app = Starlette( - routes=[ - Mount( - "/process", - app=mcp_at_root.streamable_http_app(json_response=True, streamable_http_path="/"), - ), - ] -) +"""Example showing path configuration when mounting MCPServer. + +Run from the repository root: + uvicorn examples.snippets.servers.streamable_http_path_config:app --reload +""" + +from starlette.applications import Starlette +from starlette.routing import Mount + +from mcp.server.mcpserver import MCPServer + +# Create a simple MCPServer server +mcp_at_root = MCPServer("My Server") + + +@mcp_at_root.tool() +def process_data(data: str) -> str: + """Process some data""" + return f"Processed: {data}" + + +# Mount at /process with streamable_http_path="/" so the endpoint is /process (not /process/mcp) +# Transport-specific options like json_response are passed to streamable_http_app() +app = Starlette( + routes=[ + Mount( + "/process", + app=mcp_at_root.streamable_http_app(json_response=True, streamable_http_path="/"), + ), + ] +) diff --git a/examples/snippets/servers/streamable_starlette_mount.py b/examples/snippets/servers/streamable_starlette_mount.py index eb6f1b809..50e07b001 100644 --- a/examples/snippets/servers/streamable_starlette_mount.py +++ b/examples/snippets/servers/streamable_starlette_mount.py @@ -1,53 +1,53 @@ -"""Run from the repository root: -uvicorn examples.snippets.servers.streamable_starlette_mount:app --reload -""" - -import contextlib - -from starlette.applications import Starlette -from starlette.routing import Mount - -from mcp.server.mcpserver import MCPServer - -# Create the Echo server -echo_mcp = MCPServer(name="EchoServer") - - -@echo_mcp.tool() -def echo(message: str) -> str: - """A simple echo tool""" - return f"Echo: {message}" - - -# Create the Math server -math_mcp = MCPServer(name="MathServer") - - -@math_mcp.tool() -def add_two(n: int) -> int: - """Tool to add two to the input""" - return n + 2 - - -# Create a combined lifespan to manage both session managers -@contextlib.asynccontextmanager -async def lifespan(app: Starlette): - async with contextlib.AsyncExitStack() as stack: - await stack.enter_async_context(echo_mcp.session_manager.run()) - await stack.enter_async_context(math_mcp.session_manager.run()) - yield - - -# Create the Starlette app and mount the MCP servers -app = Starlette( - routes=[ - Mount("/echo", echo_mcp.streamable_http_app(stateless_http=True, json_response=True)), - Mount("/math", math_mcp.streamable_http_app(stateless_http=True, json_response=True)), - ], - lifespan=lifespan, -) - -# Note: Clients connect to http://localhost:8000/echo/mcp and http://localhost:8000/math/mcp -# To mount at the root of each path (e.g., /echo instead of /echo/mcp): -# echo_mcp.streamable_http_app(streamable_http_path="/", stateless_http=True, json_response=True) -# math_mcp.streamable_http_app(streamable_http_path="/", stateless_http=True, json_response=True) +"""Run from the repository root: +uvicorn examples.snippets.servers.streamable_starlette_mount:app --reload +""" + +import contextlib + +from starlette.applications import Starlette +from starlette.routing import Mount + +from mcp.server.mcpserver import MCPServer + +# Create the Echo server +echo_mcp = MCPServer(name="EchoServer") + + +@echo_mcp.tool() +def echo(message: str) -> str: + """A simple echo tool""" + return f"Echo: {message}" + + +# Create the Math server +math_mcp = MCPServer(name="MathServer") + + +@math_mcp.tool() +def add_two(n: int) -> int: + """Tool to add two to the input""" + return n + 2 + + +# Create a combined lifespan to manage both session managers +@contextlib.asynccontextmanager +async def lifespan(app: Starlette): + async with contextlib.AsyncExitStack() as stack: + await stack.enter_async_context(echo_mcp.session_manager.run()) + await stack.enter_async_context(math_mcp.session_manager.run()) + yield + + +# Create the Starlette app and mount the MCP servers +app = Starlette( + routes=[ + Mount("/echo", echo_mcp.streamable_http_app(stateless_http=True, json_response=True)), + Mount("/math", math_mcp.streamable_http_app(stateless_http=True, json_response=True)), + ], + lifespan=lifespan, +) + +# Note: Clients connect to http://localhost:8000/echo/mcp and http://localhost:8000/math/mcp +# To mount at the root of each path (e.g., /echo instead of /echo/mcp): +# echo_mcp.streamable_http_app(streamable_http_path="/", stateless_http=True, json_response=True) +# math_mcp.streamable_http_app(streamable_http_path="/", stateless_http=True, json_response=True) diff --git a/examples/snippets/servers/structured_output.py b/examples/snippets/servers/structured_output.py index bea7b22c1..5e939f8f0 100644 --- a/examples/snippets/servers/structured_output.py +++ b/examples/snippets/servers/structured_output.py @@ -1,97 +1,97 @@ -"""Example showing structured output with tools.""" - -from typing import TypedDict - -from pydantic import BaseModel, Field - -from mcp.server.mcpserver import MCPServer - -mcp = MCPServer("Structured Output Example") - - -# Using Pydantic models for rich structured data -class WeatherData(BaseModel): - """Weather information structure.""" - - temperature: float = Field(description="Temperature in Celsius") - humidity: float = Field(description="Humidity percentage") - condition: str - wind_speed: float - - -@mcp.tool() -def get_weather(city: str) -> WeatherData: - """Get weather for a city - returns structured data.""" - # Simulated weather data - return WeatherData( - temperature=22.5, - humidity=45.0, - condition="sunny", - wind_speed=5.2, - ) - - -# Using TypedDict for simpler structures -class LocationInfo(TypedDict): - latitude: float - longitude: float - name: str - - -@mcp.tool() -def get_location(address: str) -> LocationInfo: - """Get location coordinates""" - return LocationInfo(latitude=51.5074, longitude=-0.1278, name="London, UK") - - -# Using dict[str, Any] for flexible schemas -@mcp.tool() -def get_statistics(data_type: str) -> dict[str, float]: - """Get various statistics""" - return {"mean": 42.5, "median": 40.0, "std_dev": 5.2} - - -# Ordinary classes with type hints work for structured output -class UserProfile: - name: str - age: int - email: str | None = None - - def __init__(self, name: str, age: int, email: str | None = None): - self.name = name - self.age = age - self.email = email - - -@mcp.tool() -def get_user(user_id: str) -> UserProfile: - """Get user profile - returns structured data""" - return UserProfile(name="Alice", age=30, email="alice@example.com") - - -# Classes WITHOUT type hints cannot be used for structured output -class UntypedConfig: - def __init__(self, setting1, setting2): # type: ignore[reportMissingParameterType] - self.setting1 = setting1 - self.setting2 = setting2 - - -@mcp.tool() -def get_config() -> UntypedConfig: - """This returns unstructured output - no schema generated""" - return UntypedConfig("value1", "value2") - - -# Lists and other types are wrapped automatically -@mcp.tool() -def list_cities() -> list[str]: - """Get a list of cities""" - return ["London", "Paris", "Tokyo"] - # Returns: {"result": ["London", "Paris", "Tokyo"]} - - -@mcp.tool() -def get_temperature(city: str) -> float: - """Get temperature as a simple float""" - return 22.5 - # Returns: {"result": 22.5} +"""Example showing structured output with tools.""" + +from typing import TypedDict + +from pydantic import BaseModel, Field + +from mcp.server.mcpserver import MCPServer + +mcp = MCPServer("Structured Output Example") + + +# Using Pydantic models for rich structured data +class WeatherData(BaseModel): + """Weather information structure.""" + + temperature: float = Field(description="Temperature in Celsius") + humidity: float = Field(description="Humidity percentage") + condition: str + wind_speed: float + + +@mcp.tool() +def get_weather(city: str) -> WeatherData: + """Get weather for a city - returns structured data.""" + # Simulated weather data + return WeatherData( + temperature=22.5, + humidity=45.0, + condition="sunny", + wind_speed=5.2, + ) + + +# Using TypedDict for simpler structures +class LocationInfo(TypedDict): + latitude: float + longitude: float + name: str + + +@mcp.tool() +def get_location(address: str) -> LocationInfo: + """Get location coordinates""" + return LocationInfo(latitude=51.5074, longitude=-0.1278, name="London, UK") + + +# Using dict[str, Any] for flexible schemas +@mcp.tool() +def get_statistics(data_type: str) -> dict[str, float]: + """Get various statistics""" + return {"mean": 42.5, "median": 40.0, "std_dev": 5.2} + + +# Ordinary classes with type hints work for structured output +class UserProfile: + name: str + age: int + email: str | None = None + + def __init__(self, name: str, age: int, email: str | None = None): + self.name = name + self.age = age + self.email = email + + +@mcp.tool() +def get_user(user_id: str) -> UserProfile: + """Get user profile - returns structured data""" + return UserProfile(name="Alice", age=30, email="alice@example.com") + + +# Classes WITHOUT type hints cannot be used for structured output +class UntypedConfig: + def __init__(self, setting1, setting2): # type: ignore[reportMissingParameterType] + self.setting1 = setting1 + self.setting2 = setting2 + + +@mcp.tool() +def get_config() -> UntypedConfig: + """This returns unstructured output - no schema generated""" + return UntypedConfig("value1", "value2") + + +# Lists and other types are wrapped automatically +@mcp.tool() +def list_cities() -> list[str]: + """Get a list of cities""" + return ["London", "Paris", "Tokyo"] + # Returns: {"result": ["London", "Paris", "Tokyo"]} + + +@mcp.tool() +def get_temperature(city: str) -> float: + """Get temperature as a simple float""" + return 22.5 + # Returns: {"result": 22.5} diff --git a/examples/snippets/servers/tool_progress.py b/examples/snippets/servers/tool_progress.py index 376dbc5db..52e963a24 100644 --- a/examples/snippets/servers/tool_progress.py +++ b/examples/snippets/servers/tool_progress.py @@ -1,20 +1,20 @@ -from mcp.server.mcpserver import Context, MCPServer - -mcp = MCPServer(name="Progress Example") - - -@mcp.tool() -async def long_running_task(task_name: str, ctx: Context, steps: int = 5) -> str: - """Execute a task with progress updates.""" - await ctx.info(f"Starting: {task_name}") - - for i in range(steps): - progress = (i + 1) / steps - await ctx.report_progress( - progress=progress, - total=1.0, - message=f"Step {i + 1}/{steps}", - ) - await ctx.debug(f"Completed step {i + 1}") - - return f"Task '{task_name}' completed" +from mcp.server.mcpserver import Context, MCPServer + +mcp = MCPServer(name="Progress Example") + + +@mcp.tool() +async def long_running_task(task_name: str, ctx: Context, steps: int = 5) -> str: + """Execute a task with progress updates.""" + await ctx.info(f"Starting: {task_name}") + + for i in range(steps): + progress = (i + 1) / steps + await ctx.report_progress( + progress=progress, + total=1.0, + message=f"Step {i + 1}/{steps}", + ) + await ctx.debug(f"Completed step {i + 1}") + + return f"Task '{task_name}' completed" diff --git a/job_log.txt b/job_log.txt new file mode 100644 index 0000000000000000000000000000000000000000..2fc50a9d860821b9c15fefec4bb59358bfa1e6c7 GIT binary patch literal 1123742 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

*7F7q;y^ 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 z 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