From 15c8b0c64da319f4eb68fbd064cd68ebe92be64d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 7 Oct 2026 12:22:23 -0400 Subject: [PATCH 001/117] Port fetch, git and time to the MCP Python SDK v2 Move the three Python servers from mcp 1.x to mcp>=2.2,<3 and drop the mcp>=1.29.0,<2 pin. Each stays on the low-level Server, now built with on_* constructor handlers instead of decorators, and returns ListToolsResult / ListPromptsResult / CallToolResult explicitly. SDK v2's low-level server no longer validates tool arguments against inputSchema or folds a handler exception into an isError result. Each server's call_tool now does both itself, with v1's messages, so tool results on the wire are unchanged. jsonschema becomes a direct dependency for that. McpError(ErrorData(...)) becomes MCPError(code=..., message=...), and Tool / ToolAnnotations fields move to snake_case. Tests: only import and type changes, plus wire() dumping with exclude_unset (v2 result models default fields the 2025-11-25 wire does not carry). One assertion changes: v2 omits the empty "experimental": {} capability, in git and time. Closes #4851 Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/fetch/README.md | 2 +- src/fetch/pyproject.toml | 3 +- src/fetch/src/mcp_server_fetch/server.py | 190 ++++++++------- src/fetch/tests/harness.py | 2 +- src/fetch/tests/test_protocol.py | 21 +- src/fetch/uv.lock | 137 +++++++---- src/git/README.md | 2 +- src/git/pyproject.toml | 3 +- src/git/src/mcp_server_git/server.py | 296 +++++++++++++---------- src/git/tests/conftest.py | 7 +- src/git/tests/test_entrypoints.py | 2 +- src/git/tests/test_protocol.py | 16 +- src/git/uv.lock | 153 ++++++------ src/time/README.md | 2 +- src/time/pyproject.toml | 3 +- src/time/src/mcp_server_time/server.py | 158 +++++++----- src/time/tests/test_protocol.py | 23 +- src/time/tests/test_server.py | 6 +- src/time/uv.lock | 152 ++++++------ 19 files changed, 666 insertions(+), 512 deletions(-) diff --git a/src/fetch/README.md b/src/fetch/README.md index 5f64d7952b..2441bd2b89 100644 --- a/src/fetch/README.md +++ b/src/fetch/README.md @@ -6,7 +6,7 @@ A Model Context Protocol server that provides web content fetching capabilities. Source: https://github.com/modelcontextprotocol/servers/tree/main/src/fetch -Requires MCP Python SDK 1.x (`mcp>=1.29.0,<2`). SDK 2.0 renamed APIs this server uses. The port to v2 is in progress. +Requires MCP Python SDK 2.x (`mcp>=2.2,<3`). > [!CAUTION] > This server can access local/internal IP addresses and may represent a security risk. Exercise caution when using this MCP server to ensure this does not expose any sensitive data. diff --git a/src/fetch/pyproject.toml b/src/fetch/pyproject.toml index a2f289ef77..0666d3b768 100644 --- a/src/fetch/pyproject.toml +++ b/src/fetch/pyproject.toml @@ -18,7 +18,8 @@ classifiers = [ dependencies = [ "httpx[socks]>=0.27", "markdownify>=0.13.1", - "mcp>=1.29.0,<2", + "jsonschema>=4.20.0", + "mcp>=2.2,<3", "protego>=0.3.1", "pydantic>=2.0.0", "readabilipy>=0.2.0", diff --git a/src/fetch/src/mcp_server_fetch/server.py b/src/fetch/src/mcp_server_fetch/server.py index 6b0073f478..2adb74dcaf 100644 --- a/src/fetch/src/mcp_server_fetch/server.py +++ b/src/fetch/src/mcp_server_fetch/server.py @@ -2,19 +2,26 @@ import ipaddress import os import socket -from typing import Annotated, Tuple +from typing import Annotated, Any, Tuple from importlib.metadata import version from urllib.parse import urlparse, urlunparse import httpx +import jsonschema import markdownify import readabilipy.simple_json -from mcp.shared.exceptions import McpError -from mcp.server import Server +from mcp.shared.exceptions import MCPError +from mcp.server import Server, ServerRequestContext from mcp.server.stdio import stdio_server from mcp.types import ( - ErrorData, + CallToolRequestParams, + CallToolResult, + ContentBlock, + GetPromptRequestParams, GetPromptResult, + ListPromptsResult, + ListToolsResult, + PaginatedRequestParams, Prompt, PromptArgument, PromptMessage, @@ -132,13 +139,11 @@ async def _refuse_private_destination(request: httpx.Request) -> None: return for address in addresses: if not _is_public_address(address): - raise McpError( - ErrorData( - code=INVALID_PARAMS, - message=f"Refused to fetch {request.url}: {host} resolves to {address}, " - "which is not a public address. Start the server with " - "--allow-private-ips to allow private, loopback and link-local addresses.", - ) + raise MCPError( + code=INVALID_PARAMS, + message=f"Refused to fetch {request.url}: {host} resolves to {address}, " + "which is not a public address. Start the server with " + "--allow-private-ips to allow private, loopback and link-local addresses.", ) @@ -227,13 +232,11 @@ def make_client(proxy_url: str | None) -> httpx.AsyncClient: try: return httpx.AsyncClient(proxy=proxy_url) except (ValueError, ImportError) as e: - raise McpError( - ErrorData( - code=INTERNAL_ERROR, - message=f"Failed to set up the HTTP client, check the proxy " - f"configuration (--proxy-url or the HTTP_PROXY, HTTPS_PROXY and " - f"ALL_PROXY environment variables): {e}", - ) + raise MCPError( + code=INTERNAL_ERROR, + message=f"Failed to set up the HTTP client, check the proxy " + f"configuration (--proxy-url or the HTTP_PROXY, HTTPS_PROXY and " + f"ALL_PROXY environment variables): {e}", ) @@ -245,7 +248,7 @@ async def check_may_autonomously_fetch_url( ) -> None: """ Check if the URL can be fetched by the user agent according to the robots.txt file. - Raises a McpError if not. + Raises an MCPError if not. """ from httpx import HTTPError @@ -261,18 +264,14 @@ async def check_may_autonomously_fetch_url( headers={"User-Agent": user_agent}, ) except HTTPError: - raise McpError( - ErrorData( - code=INTERNAL_ERROR, - message=f"Failed to fetch robots.txt {robot_txt_url} due to a connection issue", - ) + raise MCPError( + code=INTERNAL_ERROR, + message=f"Failed to fetch robots.txt {robot_txt_url} due to a connection issue", ) if response.status_code in (401, 403): - raise McpError( - ErrorData( - code=INTERNAL_ERROR, - message=f"When fetching robots.txt ({robot_txt_url}), received status {response.status_code} so assuming that autonomous fetching is not allowed, the user can try manually fetching by using the fetch prompt", - ) + raise MCPError( + code=INTERNAL_ERROR, + message=f"When fetching robots.txt ({robot_txt_url}), received status {response.status_code} so assuming that autonomous fetching is not allowed, the user can try manually fetching by using the fetch prompt", ) elif 400 <= response.status_code < 500: return @@ -282,16 +281,14 @@ async def check_may_autonomously_fetch_url( ) robot_parser = Protego.parse(processed_robot_txt) if not robot_parser.can_fetch(str(url), user_agent): - raise McpError( - ErrorData( - code=INTERNAL_ERROR, - message=f"The sites robots.txt ({robot_txt_url}), specifies that autonomous fetching of this page is not allowed, " - f"{user_agent}\n" - f"{url}" - f"\n{robot_txt}\n\n" - f"The assistant must let the user know that it failed to view the page. The assistant may provide further guidance based on the above information.\n" - f"The assistant can tell the user that they can try manually fetching the page by using the fetch prompt within their UI.", - ) + raise MCPError( + code=INTERNAL_ERROR, + message=f"The sites robots.txt ({robot_txt_url}), specifies that autonomous fetching of this page is not allowed, " + f"{user_agent}\n" + f"{url}" + f"\n{robot_txt}\n\n" + f"The assistant must let the user know that it failed to view the page. The assistant may provide further guidance based on the above information.\n" + f"The assistant can tell the user that they can try manually fetching the page by using the fetch prompt within their UI.", ) @@ -318,15 +315,11 @@ async def fetch_url( timeout=30, ) except HTTPError as e: - raise McpError( - ErrorData(code=INTERNAL_ERROR, message=f"Failed to fetch {url}: {e!r}") - ) + raise MCPError(code=INTERNAL_ERROR, message=f"Failed to fetch {url}: {e!r}") if response.status_code >= 400: - raise McpError( - ErrorData( - code=INTERNAL_ERROR, - message=f"Failed to fetch {url} - status code {response.status_code}", - ) + raise MCPError( + code=INTERNAL_ERROR, + message=f"Failed to fetch {url} - status code {response.status_code}", ) page_raw = response.text @@ -378,6 +371,13 @@ class Fetch(BaseModel): ] +def tool_error(message: str) -> CallToolResult: + """A tool call that failed, as an `isError` result the model can read.""" + return CallToolResult( + content=[TextContent(type="text", text=message)], is_error=True + ) + + async def serve( custom_user_agent: str | None = None, ignore_robots_txt: bool = False, @@ -396,44 +396,46 @@ async def serve( if proxy_url: proxy_url = normalize_proxy_url(proxy_url) normalize_proxy_env() - server = Server("mcp-fetch", version=SERVER_VERSION) user_agent_autonomous = custom_user_agent or DEFAULT_USER_AGENT_AUTONOMOUS user_agent_manual = custom_user_agent or DEFAULT_USER_AGENT_MANUAL - @server.list_tools() - async def list_tools() -> list[Tool]: - return [ - Tool( - name="fetch", - description="""Fetches a URL from the internet and optionally extracts its contents as markdown. + tool = Tool( + name="fetch", + description="""Fetches a URL from the internet and optionally extracts its contents as markdown. Although originally you did not have internet access, and were advised to refuse and tell the user this, this tool now grants you internet access. Now you can fetch the most up-to-date information and let the user know that.""", - inputSchema=Fetch.model_json_schema(), - ) - ] - - @server.list_prompts() - async def list_prompts() -> list[Prompt]: - return [ - Prompt( - name="fetch", - description="Fetch a URL and extract its contents as markdown", - arguments=[ - PromptArgument( - name="url", description="URL to fetch", required=True - ) - ], - ) - ] + input_schema=Fetch.model_json_schema(), + ) + + async def list_tools( + ctx: ServerRequestContext, params: PaginatedRequestParams | None + ) -> ListToolsResult: + return ListToolsResult(tools=[tool]) + + async def list_prompts( + ctx: ServerRequestContext, params: PaginatedRequestParams | None + ) -> ListPromptsResult: + return ListPromptsResult( + prompts=[ + Prompt( + name="fetch", + description="Fetch a URL and extract its contents as markdown", + arguments=[ + PromptArgument( + name="url", description="URL to fetch", required=True + ) + ], + ) + ] + ) - @server.call_tool() - async def call_tool(name, arguments: dict) -> list[TextContent]: + async def run_tool(name: str, arguments: dict[str, Any]) -> list[ContentBlock]: if name != "fetch": raise ValueError(f"Unknown tool: {name}") try: args = Fetch(**arguments) except ValueError as e: - raise McpError(ErrorData(code=INVALID_PARAMS, message=str(e))) + raise MCPError(code=INVALID_PARAMS, message=str(e)) url = str(args.url) @@ -468,14 +470,31 @@ async def call_tool(name, arguments: dict) -> list[TextContent]: content += f"\n\nContent truncated. Call the fetch tool with a start_index of {next_start} to get more content." return [TextContent(type="text", text=f"{prefix}Contents of {url}:\n{content}")] - @server.get_prompt() - async def get_prompt(name: str, arguments: dict | None) -> GetPromptResult: + async def call_tool( + ctx: ServerRequestContext, params: CallToolRequestParams + ) -> CallToolResult: + # SDK v2's low-level server neither validates arguments nor turns a + # handler exception into an isError result, both of which SDK v1 did. + # Both are done here, with v1's messages, so the wire is unchanged. + arguments = params.arguments or {} + if params.name == tool.name: + try: + jsonschema.validate(instance=arguments, schema=tool.input_schema) + except jsonschema.ValidationError as e: + return tool_error(f"Input validation error: {e.message}") + try: + return CallToolResult(content=await run_tool(params.name, arguments)) + except Exception as e: + return tool_error(str(e)) + + async def get_prompt( + ctx: ServerRequestContext, params: GetPromptRequestParams + ) -> GetPromptResult: + name, arguments = params.name, params.arguments if name != "fetch": - raise McpError( - ErrorData(code=INVALID_PARAMS, message=f"Unknown prompt: {name}") - ) + raise MCPError(code=INVALID_PARAMS, message=f"Unknown prompt: {name}") if not arguments or "url" not in arguments: - raise McpError(ErrorData(code=INVALID_PARAMS, message="URL is required")) + raise MCPError(code=INVALID_PARAMS, message="URL is required") url = arguments["url"] @@ -487,7 +506,7 @@ async def get_prompt(name: str, arguments: dict | None) -> GetPromptResult: allow_private_ips=allow_private_ips, ) # TODO: after SDK bug is addressed, don't catch the exception - except McpError as e: + except MCPError as e: return GetPromptResult( description=f"Failed to fetch {url}", messages=[ @@ -506,6 +525,15 @@ async def get_prompt(name: str, arguments: dict | None) -> GetPromptResult: ], ) + server = Server( + "mcp-fetch", + version=SERVER_VERSION, + on_list_tools=list_tools, + on_list_prompts=list_prompts, + on_call_tool=call_tool, + on_get_prompt=get_prompt, + ) + options = server.create_initialization_options() async with stdio_server() as (read_stream, write_stream): await server.run(read_stream, write_stream, options, raise_exceptions=False) diff --git a/src/fetch/tests/harness.py b/src/fetch/tests/harness.py index 6c521769b8..650faa34d1 100644 --- a/src/fetch/tests/harness.py +++ b/src/fetch/tests/harness.py @@ -4,7 +4,7 @@ - ``connect(...)`` runs the real ``serve()`` in-process and yields an initialized ``ClientSession`` linked to it over in-memory streams, so tests - go through the SDK's validation and error mapping exactly as a client would. + go through the server's validation and error mapping exactly as a client would. - ``web`` replaces ``httpx.AsyncClient`` with one backed by an ``httpx.MockTransport``, so no test touches the network. It records every request and the keyword arguments each client was constructed with. It also diff --git a/src/fetch/tests/test_protocol.py b/src/fetch/tests/test_protocol.py index deb5d9099f..b24f739025 100644 --- a/src/fetch/tests/test_protocol.py +++ b/src/fetch/tests/test_protocol.py @@ -22,7 +22,7 @@ import httpx import pytest from mcp import ClientSession -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError from mcp.types import INVALID_PARAMS, CallToolResult from mcp_server_fetch.server import ( @@ -52,7 +52,12 @@ def wire(model: Any) -> dict[str, Any]: - return model.model_dump(by_alias=True, mode="json", exclude_none=True) + # exclude_unset: SDK v2 result models default fields the 2025-11-25 wire + # does not carry (resultType, ttlMs, cacheScope), so only what the server + # actually sent is compared. + return model.model_dump( + by_alias=True, mode="json", exclude_none=True, exclude_unset=True + ) def text_of(result: CallToolResult) -> str: @@ -193,7 +198,7 @@ async def test_call_with_only_url_applies_defaults(web: FakeWeb) -> None: async def test_schema_violations_are_rejected_by_the_sdk( web: FakeWeb, arguments: dict[str, Any], offending: str ) -> None: - # The SDK validates against inputSchema before the handler runs. The rest + # call_tool validates against inputSchema before the tool runs. The rest # of the message is jsonschema's wording, which varies by version. async with connect() as (session, _): result = await call(session, arguments) @@ -206,7 +211,7 @@ async def test_schema_violations_are_rejected_by_the_sdk( @pytest.mark.parametrize("max_length", [1, 999999]) async def test_max_length_bounds_are_inclusive(web: FakeWeb, max_length: int) -> None: - # #1624: the edges of the inclusive range pass both the SDK's schema check + # #1624: the edges of the inclusive range pass both the schema check # and Fetch's own validation, and the page is fetched. web.add(ROBOTS, plain("", status=404)) web.add(PAGE, plain("hello")) @@ -230,8 +235,8 @@ async def test_invalid_url_is_rejected_by_pydantic(web: FakeWeb, url: str) -> No async def test_unknown_tool_name_is_rejected_without_fetching(web: FakeWeb) -> None: - # The SDK skips schema validation for a tool it has not listed, so the - # name check in call_tool is what stops an unknown name from fetching. + # call_tool skips schema validation for a tool it has not listed, so the + # name check in run_tool is what stops an unknown name from fetching. web.add(ROBOTS, plain("", status=404)) web.add(PAGE, plain("fetched anyway")) async with connect() as (session, _): @@ -949,7 +954,7 @@ async def test_get_prompt_without_url_is_a_jsonrpc_error( web: FakeWeb, arguments: dict[str, str] | None ) -> None: async with connect() as (session, _): - with pytest.raises(McpError) as excinfo: + with pytest.raises(MCPError) as excinfo: await session.get_prompt("fetch", arguments) assert wire(excinfo.value.error) == { "code": INVALID_PARAMS, @@ -966,7 +971,7 @@ async def test_get_prompt_with_unknown_name_is_a_jsonrpc_error( # whether or not a URL was given, and nothing is fetched. web.add(PAGE, plain("ok")) async with connect() as (session, _): - with pytest.raises(McpError) as excinfo: + with pytest.raises(MCPError) as excinfo: await session.get_prompt("nope", arguments) assert wire(excinfo.value.error) == { "code": INVALID_PARAMS, diff --git a/src/fetch/uv.lock b/src/fetch/uv.lock index b16b179358..dc2c712ded 100644 --- a/src/fetch/uv.lock +++ b/src/fetch/uv.lock @@ -1,11 +1,12 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", "python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version < '3.14' and sys_platform != 'win32'", + "(python_full_version < '3.12' and sys_platform == 'emscripten') or (python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32')", ] [[package]] @@ -19,17 +20,16 @@ wheels = [ [[package]] name = "anyio" -version = "4.6.2.post1" +version = "4.15.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, - { name = "sniffio" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/09/45b9b7a6d4e45c6bcb5bf61d19e3ab87df68e0601fa8c5293de3542546cc/anyio-4.6.2.post1.tar.gz", hash = "sha256:4c8bc31ccdb51c7f7bd251f51c609e038d63e34219b44aa86e47576389880b4c", size = 173422, upload-time = "2024-10-14T14:31:44.021Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/f5/f2b75d2fc6f1a260f340f0e7c6a060f4dd2961cc16884ed851b0d18da06a/anyio-4.6.2.post1-py3-none-any.whl", hash = "sha256:6d170c36fba3bdd840c73d3868c1e777e33676a69c3a72cf0a0d5d6d8009b61d", size = 90377, upload-time = "2024-10-14T14:31:42.623Z" }, + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, ] [[package]] @@ -469,6 +469,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/ba/a4568248771ce81957bfb7cc600264a40fbcda092391ee1c415c50be4bea/httpcore2-2.13.1-py3-none-any.whl", hash = "sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a", size = 83423, upload-time = "2026-09-23T07:47:19.365Z" }, +] + [[package]] name = "httpx" version = "0.27.2" @@ -491,12 +504,29 @@ socks = [ ] [[package]] -name = "httpx-sse" -version = "0.4.0" +name = "httpx2" +version = "2.13.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624, upload-time = "2023-12-22T08:01:21.083Z" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/44/474bef2a0e9d90f1715d32cb98b0738695ca17ba324095fb2497ed7fbd59/httpx2-2.13.1.tar.gz", hash = "sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa", size = 100405, upload-time = "2026-09-23T07:47:23.052Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819, upload-time = "2023-12-22T08:01:19.89Z" }, + { url = "https://files.pythonhosted.org/packages/d8/9c/6fe8931fd9f381042a9e4c7d5a7b4cbf7016b252bec0c99a49fce42c3326/httpx2-2.13.1-py3-none-any.whl", hash = "sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4", size = 95597, upload-time = "2026-09-23T07:47:20.995Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, ] [[package]] @@ -677,15 +707,15 @@ wheels = [ [[package]] name = "mcp" -version = "1.29.0" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, + { name = "httpx2" }, { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, - { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, { name = "python-multipart" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, @@ -695,9 +725,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/30/d3/f9acc21dfc886e4f78e2add1a47db46ce16884346afde53f8a064c02c891/mcp-1.29.0.tar.gz", hash = "sha256:52d01f334de1868cc3bb2d6604931126a67631f99a6c5d3b82ba47290315ec36", size = 643148, upload-time = "2026-07-28T13:41:41.939Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/8d/e0d339616f4810e9051d4aba6887afab289ab1f81875fe908b606cdfd0e3/mcp-2.3.0.tar.gz", hash = "sha256:8b147a50441cf059dc88c684e0aeed3687f0aa0f39c6cde7b90330effd2b34d8", size = 4171955, upload-time = "2026-10-02T22:06:56.091Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/c8/248b201f6d753d69fd5d6506011abbb35a946d9142b2ae311a948fd0be3d/mcp-1.29.0-py3-none-any.whl", hash = "sha256:f5a075bb611f23d6f4d080c6a1699fa62772eebc562ba9e66b306ddde1c755f7", size = 223436, upload-time = "2026-07-28T13:41:40.337Z" }, + { url = "https://files.pythonhosted.org/packages/20/d7/4a2878f128df729db3ca04e71bfce26405cc2e0c9ebe19c592feeb4c61b9/mcp-2.3.0-py3-none-any.whl", hash = "sha256:dd0c44c089d16453e8ae31a3877a0054d7a2314caaa81f5e0541b9b1734b2377", size = 368664, upload-time = "2026-10-02T22:06:52.119Z" }, ] [[package]] @@ -706,6 +736,7 @@ version = "0.6.3" source = { editable = "." } dependencies = [ { name = "httpx", extra = ["socks"] }, + { name = "jsonschema" }, { name = "markdownify" }, { name = "mcp" }, { name = "protego" }, @@ -726,8 +757,9 @@ dev = [ [package.metadata] requires-dist = [ { name = "httpx", extras = ["socks"], specifier = ">=0.27" }, + { name = "jsonschema", specifier = ">=4.20.0" }, { name = "markdownify", specifier = ">=0.13.1" }, - { name = "mcp", specifier = ">=1.29.0,<2" }, + { name = "mcp", specifier = ">=2.2,<3" }, { name = "protego", specifier = ">=0.3.1" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "readabilipy", specifier = ">=0.2.0" }, @@ -743,6 +775,19 @@ dev = [ { name = "ruff", specifier = ">=0.7.3" }, ] +[[package]] +name = "mcp-types" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/2d/7c251e34207f6c51000312fc8839111ac45cfe02023f90b44e7f1051dd8e/mcp_types-2.3.0.tar.gz", hash = "sha256:d1e46549edb35ee19a94940fcee6d1addd7e589ab7ea92dda83f5d84781fc362", size = 65898, upload-time = "2026-10-02T22:06:57.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/48/32583066b155bb5d4507e7887dc88ee23118cc4de8e2a12870c49719e2a8/mcp_types-2.3.0-py3-none-any.whl", hash = "sha256:968efdbdaedfab06adae40d378a34395f1090c5921d4be3c9cde283aaf76d91d", size = 69108, upload-time = "2026-10-02T22:06:54.099Z" }, +] + [[package]] name = "nodeenv" version = "1.9.1" @@ -752,6 +797,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" }, +] + [[package]] name = "packaging" version = "26.0" @@ -921,19 +978,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] -[[package]] -name = "pydantic-settings" -version = "2.6.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b5/d4/9dfbe238f45ad8b168f5c96ee49a3df0598ce18a0795a983b419949ce65b/pydantic_settings-2.6.1.tar.gz", hash = "sha256:e0f92546d8a9923cb8941689abf85d6601a8c19a23e97a34b2964a2e3f813ca0", size = 75646, upload-time = "2024-11-01T11:00:05.17Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/f9/ff95fd7d760af42f647ea87f9b8a383d891cdb5e5dbd4613edaeb094252a/pydantic_settings-2.6.1-py3-none-any.whl", hash = "sha256:7fb0637c786a558d3103436278a7c4f1cfd29ba8973238a50c5bb9a55387da87", size = 28595, upload-time = "2024-11-01T11:00:02.64Z" }, -] - [[package]] name = "pygments" version = "2.20.0" @@ -1019,15 +1063,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] -[[package]] -name = "python-dotenv" -version = "1.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, -] - [[package]] name = "python-multipart" version = "0.0.32" @@ -1357,16 +1392,15 @@ wheels = [ [[package]] name = "sse-starlette" -version = "2.1.3" +version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "starlette" }, - { name = "uvicorn" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/fc/56ab9f116b2133521f532fce8d03194cf04dcac25f583cf3d839be4c0496/sse_starlette-2.1.3.tar.gz", hash = "sha256:9cd27eb35319e1414e3d2558ee7414487f9529ce3b3cf9b21434fd110e017169", size = 19678, upload-time = "2024-08-01T08:52:50.248Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/be/0123026f719d1a7936f214a88b553bb5701e04ff2511147c1dab0c5035eb/sse_starlette-3.5.0.tar.gz", hash = "sha256:75de713aa8a9441513cc283220826da079d982770965b951e9437720e8bafdb2", size = 36794, upload-time = "2026-09-28T17:48:14.7Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/52/aa/36b271bc4fa1d2796311ee7c7283a3a1c348bad426d37293609ca4300eef/sse_starlette-2.1.3-py3-none-any.whl", hash = "sha256:8ec846438b4665b9e8c560fcdea6bc8081a3abf7942faa95e5a744999d219772", size = 9383, upload-time = "2024-08-01T08:52:48.659Z" }, + { url = "https://files.pythonhosted.org/packages/be/e4/cdda14023c316d71493bc54fdffc3dd006631b88866145c9d3cc33e0f1df/sse_starlette-3.5.0-py3-none-any.whl", hash = "sha256:3e6e1070df3f0f5d9cea81496de92dbb72f6721871d99748ece67441dd8b7997", size = 17407, upload-time = "2026-09-28T17:48:13.228Z" }, ] [[package]] @@ -1436,13 +1470,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] [[package]] diff --git a/src/git/README.md b/src/git/README.md index 377cdac3eb..45e96b8121 100644 --- a/src/git/README.md +++ b/src/git/README.md @@ -8,7 +8,7 @@ A Model Context Protocol server for Git repository interaction and automation. T Source: https://github.com/modelcontextprotocol/servers/tree/main/src/git -Requires MCP Python SDK 1.x (`mcp>=1.29.0,<2`). SDK 2.0 renamed APIs this server uses. The port to v2 is in progress. +Requires MCP Python SDK 2.x (`mcp>=2.2,<3`). Please note that mcp-server-git is currently in early development. The functionality and available tools are subject to change and expansion as we continue to develop and improve the server. diff --git a/src/git/pyproject.toml b/src/git/pyproject.toml index 39f9f17b23..cf6374776e 100644 --- a/src/git/pyproject.toml +++ b/src/git/pyproject.toml @@ -18,7 +18,8 @@ classifiers = [ dependencies = [ "click>=8.1.7", "gitpython>=3.1.50", - "mcp>=1.29.0,<2", + "jsonschema>=4.20.0", + "mcp>=2.2,<3", "pydantic>=2.0.0", ] diff --git a/src/git/src/mcp_server_git/server.py b/src/git/src/mcp_server_git/server.py index 626e79b48c..a43ad717e7 100644 --- a/src/git/src/mcp_server_git/server.py +++ b/src/git/src/mcp_server_git/server.py @@ -2,9 +2,15 @@ from importlib.metadata import version from pathlib import Path from typing import Any, Optional -from mcp.server import Server +import jsonschema +from mcp.server import Server, ServerRequestContext from mcp.server.stdio import stdio_server from mcp.types import ( + CallToolRequestParams, + CallToolResult, + ContentBlock, + ListToolsResult, + PaginatedRequestParams, TextContent, Tool, ToolAnnotations, @@ -465,6 +471,13 @@ def git_branch( return branch_info +def tool_error(message: str) -> CallToolResult: + """A tool call that failed, as an `isError` result the model can read.""" + return CallToolResult( + content=[TextContent(type="text", text=message)], is_error=True + ) + + async def serve(repository: Path | None) -> None: logger = logging.getLogger(__name__) @@ -486,147 +499,148 @@ async def serve(repository: Path | None) -> None: repository = root logger.info(f"Using repository at {repository}") - server = Server("mcp-git", version=SERVER_VERSION) - - @server.list_tools() - async def list_tools() -> list[Tool]: - return [ - Tool( - name=GitTools.STATUS, - description="Shows the working tree status", - inputSchema=GitStatus.model_json_schema(), - annotations=ToolAnnotations( - readOnlyHint=True, - destructiveHint=False, - idempotentHint=True, - openWorldHint=False, - ), + tools = [ + Tool( + name=GitTools.STATUS, + description="Shows the working tree status", + input_schema=GitStatus.model_json_schema(), + annotations=ToolAnnotations( + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=False, ), - Tool( - name=GitTools.DIFF_UNSTAGED, - description="Shows changes in the working directory that are not yet staged", - inputSchema=GitDiffUnstaged.model_json_schema(), - annotations=ToolAnnotations( - readOnlyHint=True, - destructiveHint=False, - idempotentHint=True, - openWorldHint=False, - ), + ), + Tool( + name=GitTools.DIFF_UNSTAGED, + description="Shows changes in the working directory that are not yet staged", + input_schema=GitDiffUnstaged.model_json_schema(), + annotations=ToolAnnotations( + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=False, ), - Tool( - name=GitTools.DIFF_STAGED, - description="Shows changes that are staged for commit", - inputSchema=GitDiffStaged.model_json_schema(), - annotations=ToolAnnotations( - readOnlyHint=True, - destructiveHint=False, - idempotentHint=True, - openWorldHint=False, - ), + ), + Tool( + name=GitTools.DIFF_STAGED, + description="Shows changes that are staged for commit", + input_schema=GitDiffStaged.model_json_schema(), + annotations=ToolAnnotations( + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=False, ), - Tool( - name=GitTools.DIFF, - description="Shows differences between branches or commits", - inputSchema=GitDiff.model_json_schema(), - annotations=ToolAnnotations( - readOnlyHint=True, - destructiveHint=False, - idempotentHint=True, - openWorldHint=False, - ), + ), + Tool( + name=GitTools.DIFF, + description="Shows differences between branches or commits", + input_schema=GitDiff.model_json_schema(), + annotations=ToolAnnotations( + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=False, ), - Tool( - name=GitTools.COMMIT, - description="Records changes to the repository", - inputSchema=GitCommit.model_json_schema(), - annotations=ToolAnnotations( - readOnlyHint=False, - destructiveHint=False, - idempotentHint=False, - openWorldHint=False, - ), + ), + Tool( + name=GitTools.COMMIT, + description="Records changes to the repository", + input_schema=GitCommit.model_json_schema(), + annotations=ToolAnnotations( + read_only_hint=False, + destructive_hint=False, + idempotent_hint=False, + open_world_hint=False, ), - Tool( - name=GitTools.ADD, - description="Adds file contents to the staging area", - inputSchema=GitAdd.model_json_schema(), - annotations=ToolAnnotations( - readOnlyHint=False, - destructiveHint=False, - idempotentHint=True, - openWorldHint=False, - ), + ), + Tool( + name=GitTools.ADD, + description="Adds file contents to the staging area", + input_schema=GitAdd.model_json_schema(), + annotations=ToolAnnotations( + read_only_hint=False, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=False, ), - Tool( - name=GitTools.RESET, - description="Unstages all staged changes", - inputSchema=GitReset.model_json_schema(), - annotations=ToolAnnotations( - readOnlyHint=False, - destructiveHint=True, - idempotentHint=True, - openWorldHint=False, - ), + ), + Tool( + name=GitTools.RESET, + description="Unstages all staged changes", + input_schema=GitReset.model_json_schema(), + annotations=ToolAnnotations( + read_only_hint=False, + destructive_hint=True, + idempotent_hint=True, + open_world_hint=False, ), - Tool( - name=GitTools.LOG, - description="Shows the commit logs", - inputSchema=GitLog.model_json_schema(), - annotations=ToolAnnotations( - readOnlyHint=True, - destructiveHint=False, - idempotentHint=True, - openWorldHint=False, - ), + ), + Tool( + name=GitTools.LOG, + description="Shows the commit logs", + input_schema=GitLog.model_json_schema(), + annotations=ToolAnnotations( + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=False, ), - Tool( - name=GitTools.CREATE_BRANCH, - description="Creates a new branch from an optional base branch", - inputSchema=GitCreateBranch.model_json_schema(), - annotations=ToolAnnotations( - readOnlyHint=False, - destructiveHint=False, - idempotentHint=False, - openWorldHint=False, - ), + ), + Tool( + name=GitTools.CREATE_BRANCH, + description="Creates a new branch from an optional base branch", + input_schema=GitCreateBranch.model_json_schema(), + annotations=ToolAnnotations( + read_only_hint=False, + destructive_hint=False, + idempotent_hint=False, + open_world_hint=False, ), - Tool( - name=GitTools.CHECKOUT, - description="Switches branches", - inputSchema=GitCheckout.model_json_schema(), - annotations=ToolAnnotations( - readOnlyHint=False, - destructiveHint=False, - idempotentHint=False, - openWorldHint=False, - ), + ), + Tool( + name=GitTools.CHECKOUT, + description="Switches branches", + input_schema=GitCheckout.model_json_schema(), + annotations=ToolAnnotations( + read_only_hint=False, + destructive_hint=False, + idempotent_hint=False, + open_world_hint=False, ), - Tool( - name=GitTools.SHOW, - description="Shows the contents of a commit, or of a file or directory given as :", - inputSchema=GitShow.model_json_schema(), - annotations=ToolAnnotations( - readOnlyHint=True, - destructiveHint=False, - idempotentHint=True, - openWorldHint=False, - ), + ), + Tool( + name=GitTools.SHOW, + description="Shows the contents of a commit, or of a file or directory given as :", + input_schema=GitShow.model_json_schema(), + annotations=ToolAnnotations( + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=False, ), - Tool( - name=GitTools.BRANCH, - description="List Git branches", - inputSchema=GitBranch.model_json_schema(), - annotations=ToolAnnotations( - readOnlyHint=True, - destructiveHint=False, - idempotentHint=True, - openWorldHint=False, - ), + ), + Tool( + name=GitTools.BRANCH, + description="List Git branches", + input_schema=GitBranch.model_json_schema(), + annotations=ToolAnnotations( + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=False, ), - ] + ), + ] + schemas = {tool.name: tool.input_schema for tool in tools} - @server.call_tool() - async def call_tool(name: str, arguments: dict) -> list[TextContent]: + async def list_tools( + ctx: ServerRequestContext, params: PaginatedRequestParams | None + ) -> ListToolsResult: + return ListToolsResult(tools=tools) + + async def run_tool(name: str, arguments: dict[str, Any]) -> list[ContentBlock]: # Reject an unknown tool before reading its arguments, so a call # without repo_path still reports "Unknown tool" (#4994). if name not in {tool.value for tool in GitTools}: @@ -720,6 +734,30 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]: case _: # pragma: no cover raise ValueError(f"Unknown tool: {name}") + async def call_tool( + ctx: ServerRequestContext, params: CallToolRequestParams + ) -> CallToolResult: + # SDK v2's low-level server neither validates arguments nor turns a + # handler exception into an isError result, both of which SDK v1 did. + # Both are done here, with v1's messages, so the wire is unchanged. + arguments = params.arguments or {} + schema = schemas.get(params.name) + if schema is not None: + try: + jsonschema.validate(instance=arguments, schema=schema) + except jsonschema.ValidationError as e: + return tool_error(f"Input validation error: {e.message}") + try: + return CallToolResult(content=await run_tool(params.name, arguments)) + except Exception as e: + return tool_error(str(e)) + + server = Server( + "mcp-git", + version=SERVER_VERSION, + on_list_tools=list_tools, + on_call_tool=call_tool, + ) options = server.create_initialization_options() async with stdio_server() as (read_stream, write_stream): await server.run(read_stream, write_stream, options) diff --git a/src/git/tests/conftest.py b/src/git/tests/conftest.py index 8b8f984bd1..b68086bdc0 100644 --- a/src/git/tests/conftest.py +++ b/src/git/tests/conftest.py @@ -130,7 +130,12 @@ async def fake_stdio_server() -> AsyncIterator[Any]: def wire(model: BaseModel) -> dict[str, Any]: """A result as it travels on the wire, independent of SDK attribute names.""" - return model.model_dump(by_alias=True, mode="json", exclude_none=True) + # exclude_unset: SDK v2 result models default fields the 2025-11-25 wire + # does not carry (resultType, ttlMs, cacheScope), so only what the server + # actually sent is compared. + return model.model_dump( + by_alias=True, mode="json", exclude_none=True, exclude_unset=True + ) def text_result(text: str, *, is_error: bool = False) -> dict[str, Any]: diff --git a/src/git/tests/test_entrypoints.py b/src/git/tests/test_entrypoints.py index 9c765beeb9..8b99cc4078 100644 --- a/src/git/tests/test_entrypoints.py +++ b/src/git/tests/test_entrypoints.py @@ -51,7 +51,7 @@ async def test_serve_resolves_repository_subdirectory_to_root( with caplog.at_level(logging.INFO, logger="mcp_server_git.server"): async with connect(sub) as session: result = await session.call_tool("git_status", {"repo_path": str(root)}) - assert not result.isError + assert not result.is_error assert f"Resolved --repository {sub} to repository root {root}" in caplog.text assert f"Using repository at {root}" in caplog.text diff --git a/src/git/tests/test_protocol.py b/src/git/tests/test_protocol.py index efc95fb895..39c0bc749f 100644 --- a/src/git/tests/test_protocol.py +++ b/src/git/tests/test_protocol.py @@ -17,7 +17,7 @@ import git import pytest -from mcp.shared.context import RequestContext +from mcp.client.session import ClientRequestContext from mcp.types import ListRootsResult, Root from pydantic import FileUrl @@ -63,10 +63,9 @@ async def test_initialize_reports_package_version_as_server_version(repo: git.Re "version": version("mcp-server-git"), } assert version("mcp") != version("mcp-server-git") - assert init["capabilities"] == { - "experimental": {}, - "tools": {"listChanged": False}, - } + # Behavior change in the SDK v2 port (#4851): v2 omits the empty + # `experimental: {}` object v1 always sent. It advertised nothing. + assert init["capabilities"] == {"tools": {"listChanged": False}} async def test_list_tools_matches_wire_snapshot(): @@ -381,7 +380,7 @@ async def test_git_add_file_starting_with_dash_is_a_path(repo: git.Repo): async def test_git_add_empty_list_is_rejected(repo: git.Repo): # #4763: `files: []` would run `git add --`, a no-op. The schema's - # minItems makes the SDK reject it before the tool runs. + # minItems makes call_tool reject it before the tool runs. root = root_of(repo) (root / "test.txt").write_text("edited\n", newline="\n") result = await call(None, "git_add", {"repo_path": str(root), "files": []}) @@ -1114,6 +1113,9 @@ def resolve(self: Path, strict: bool = False) -> Path: # -------------------------------------------------------------------------- +# The client side of this test offers Roots, which SDK v2 deprecates as of +# 2026-07-28 (SEP-2577); the warning is about the test's client, not the server. +@pytest.mark.filterwarnings("ignore::mcp.shared.exceptions.MCPDeprecationWarning") async def test_server_never_requests_roots(tmp_path: Path, repo: git.Repo): # The server ignores Roots entirely: it never sends roots/list, not even # after notifications/roots/list_changed, and a repository outside every @@ -1123,7 +1125,7 @@ async def test_server_never_requests_roots(tmp_path: Path, repo: git.Repo): elsewhere.mkdir() calls: list[object] = [] - async def list_roots(context: RequestContext[Any, Any]) -> ListRootsResult: + async def list_roots(context: ClientRequestContext) -> ListRootsResult: calls.append(context) return ListRootsResult(roots=[Root(uri=FileUrl(elsewhere.as_uri()))]) diff --git a/src/git/uv.lock b/src/git/uv.lock index caa3d61b5e..92d67b2a77 100644 --- a/src/git/uv.lock +++ b/src/git/uv.lock @@ -1,11 +1,12 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", "python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version < '3.14' and sys_platform != 'win32'", + "(python_full_version < '3.12' and sys_platform == 'emscripten') or (python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32')", ] [[package]] @@ -19,17 +20,16 @@ wheels = [ [[package]] name = "anyio" -version = "4.6.2.post1" +version = "4.15.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, - { name = "sniffio" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/09/45b9b7a6d4e45c6bcb5bf61d19e3ab87df68e0601fa8c5293de3542546cc/anyio-4.6.2.post1.tar.gz", hash = "sha256:4c8bc31ccdb51c7f7bd251f51c609e038d63e34219b44aa86e47576389880b4c", size = 173422, upload-time = "2024-10-14T14:31:44.021Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/f5/f2b75d2fc6f1a260f340f0e7c6a060f4dd2961cc16884ed851b0d18da06a/anyio-4.6.2.post1-py3-none-any.whl", hash = "sha256:6d170c36fba3bdd840c73d3868c1e777e33676a69c3a72cf0a0d5d6d8009b61d", size = 90377, upload-time = "2024-10-14T14:31:42.623Z" }, + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, ] [[package]] @@ -50,15 +50,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, ] -[[package]] -name = "certifi" -version = "2024.8.30" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507, upload-time = "2024-08-30T01:55:04.365Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321, upload-time = "2024-08-30T01:55:02.591Z" }, -] - [[package]] name = "cffi" version = "2.0.0" @@ -395,41 +386,42 @@ wheels = [ ] [[package]] -name = "httpcore" -version = "1.0.9" +name = "httpcore2" +version = "2.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi" }, { name = "h11" }, + { name = "truststore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, + { url = "https://files.pythonhosted.org/packages/09/ba/a4568248771ce81957bfb7cc600264a40fbcda092391ee1c415c50be4bea/httpcore2-2.13.1-py3-none-any.whl", hash = "sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a", size = 83423, upload-time = "2026-09-23T07:47:19.365Z" }, ] [[package]] -name = "httpx" -version = "0.27.2" +name = "httpx2" +version = "2.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, { name = "idna" }, - { name = "sniffio" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/82/08f8c936781f67d9e6b9eeb8a0c8b4e406136ea4c3d1f89a5db71d42e0e6/httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2", size = 144189, upload-time = "2024-08-27T12:54:01.334Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/44/474bef2a0e9d90f1715d32cb98b0738695ca17ba324095fb2497ed7fbd59/httpx2-2.13.1.tar.gz", hash = "sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa", size = 100405, upload-time = "2026-09-23T07:47:23.052Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/56/95/9377bcb415797e44274b51d46e3249eba641711cf3348050f76ee7b15ffc/httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0", size = 76395, upload-time = "2024-08-27T12:53:59.653Z" }, + { url = "https://files.pythonhosted.org/packages/d8/9c/6fe8931fd9f381042a9e4c7d5a7b4cbf7016b252bec0c99a49fce42c3326/httpx2-2.13.1-py3-none-any.whl", hash = "sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4", size = 95597, upload-time = "2026-09-23T07:47:20.995Z" }, ] [[package]] -name = "httpx-sse" -version = "0.4.0" +name = "httpx2-jsfetch" +version = "1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624, upload-time = "2023-12-22T08:01:21.083Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819, upload-time = "2023-12-22T08:01:19.89Z" }, + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, ] [[package]] @@ -479,15 +471,15 @@ wheels = [ [[package]] name = "mcp" -version = "1.29.0" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, + { name = "httpx2" }, { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, - { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, { name = "python-multipart" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, @@ -497,9 +489,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/30/d3/f9acc21dfc886e4f78e2add1a47db46ce16884346afde53f8a064c02c891/mcp-1.29.0.tar.gz", hash = "sha256:52d01f334de1868cc3bb2d6604931126a67631f99a6c5d3b82ba47290315ec36", size = 643148, upload-time = "2026-07-28T13:41:41.939Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/8d/e0d339616f4810e9051d4aba6887afab289ab1f81875fe908b606cdfd0e3/mcp-2.3.0.tar.gz", hash = "sha256:8b147a50441cf059dc88c684e0aeed3687f0aa0f39c6cde7b90330effd2b34d8", size = 4171955, upload-time = "2026-10-02T22:06:56.091Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/c8/248b201f6d753d69fd5d6506011abbb35a946d9142b2ae311a948fd0be3d/mcp-1.29.0-py3-none-any.whl", hash = "sha256:f5a075bb611f23d6f4d080c6a1699fa62772eebc562ba9e66b306ddde1c755f7", size = 223436, upload-time = "2026-07-28T13:41:40.337Z" }, + { url = "https://files.pythonhosted.org/packages/20/d7/4a2878f128df729db3ca04e71bfce26405cc2e0c9ebe19c592feeb4c61b9/mcp-2.3.0-py3-none-any.whl", hash = "sha256:dd0c44c089d16453e8ae31a3877a0054d7a2314caaa81f5e0541b9b1734b2377", size = 368664, upload-time = "2026-10-02T22:06:52.119Z" }, ] [[package]] @@ -509,6 +501,7 @@ source = { editable = "." } dependencies = [ { name = "click" }, { name = "gitpython" }, + { name = "jsonschema" }, { name = "mcp" }, { name = "pydantic" }, ] @@ -526,7 +519,8 @@ dev = [ requires-dist = [ { name = "click", specifier = ">=8.1.7" }, { name = "gitpython", specifier = ">=3.1.50" }, - { name = "mcp", specifier = ">=1.29.0,<2" }, + { name = "jsonschema", specifier = ">=4.20.0" }, + { name = "mcp", specifier = ">=2.2,<3" }, { name = "pydantic", specifier = ">=2.0.0" }, ] @@ -539,6 +533,19 @@ dev = [ { name = "ruff", specifier = ">=0.7.3" }, ] +[[package]] +name = "mcp-types" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/2d/7c251e34207f6c51000312fc8839111ac45cfe02023f90b44e7f1051dd8e/mcp_types-2.3.0.tar.gz", hash = "sha256:d1e46549edb35ee19a94940fcee6d1addd7e589ab7ea92dda83f5d84781fc362", size = 65898, upload-time = "2026-10-02T22:06:57.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/48/32583066b155bb5d4507e7887dc88ee23118cc4de8e2a12870c49719e2a8/mcp_types-2.3.0-py3-none-any.whl", hash = "sha256:968efdbdaedfab06adae40d378a34395f1090c5921d4be3c9cde283aaf76d91d", size = 69108, upload-time = "2026-10-02T22:06:54.099Z" }, +] + [[package]] name = "nodeenv" version = "1.9.1" @@ -548,6 +555,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" }, +] + [[package]] name = "packaging" version = "24.2" @@ -708,20 +727,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] -[[package]] -name = "pydantic-settings" -version = "2.14.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, -] - [[package]] name = "pygments" version = "2.20.0" @@ -807,15 +812,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] -[[package]] -name = "python-dotenv" -version = "1.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, -] - [[package]] name = "python-multipart" version = "0.0.32" @@ -1017,27 +1013,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/a5/10f97f73544edcdef54409f1d839f6049a0d79df68adbc1ceb24d1aaca42/smmap-5.0.1-py3-none-any.whl", hash = "sha256:e6d8668fa5f93e706934a62d7b4db19c8d9eb8cf2adbb75ef1b675aa332b69da", size = 24282, upload-time = "2023-09-17T11:35:03.253Z" }, ] -[[package]] -name = "sniffio" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, -] - [[package]] name = "sse-starlette" -version = "2.1.3" +version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "starlette" }, - { name = "uvicorn" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/fc/56ab9f116b2133521f532fce8d03194cf04dcac25f583cf3d839be4c0496/sse_starlette-2.1.3.tar.gz", hash = "sha256:9cd27eb35319e1414e3d2558ee7414487f9529ce3b3cf9b21434fd110e017169", size = 19678, upload-time = "2024-08-01T08:52:50.248Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/be/0123026f719d1a7936f214a88b553bb5701e04ff2511147c1dab0c5035eb/sse_starlette-3.5.0.tar.gz", hash = "sha256:75de713aa8a9441513cc283220826da079d982770965b951e9437720e8bafdb2", size = 36794, upload-time = "2026-09-28T17:48:14.7Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/52/aa/36b271bc4fa1d2796311ee7c7283a3a1c348bad426d37293609ca4300eef/sse_starlette-2.1.3-py3-none-any.whl", hash = "sha256:8ec846438b4665b9e8c560fcdea6bc8081a3abf7942faa95e5a744999d219772", size = 9383, upload-time = "2024-08-01T08:52:48.659Z" }, + { url = "https://files.pythonhosted.org/packages/be/e4/cdda14023c316d71493bc54fdffc3dd006631b88866145c9d3cc33e0f1df/sse_starlette-3.5.0-py3-none-any.whl", hash = "sha256:3e6e1070df3f0f5d9cea81496de92dbb72f6721871d99748ece67441dd8b7997", size = 17407, upload-time = "2026-09-28T17:48:13.228Z" }, ] [[package]] @@ -1092,13 +1078,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] [[package]] diff --git a/src/time/README.md b/src/time/README.md index 43775f71fa..2b878eab81 100644 --- a/src/time/README.md +++ b/src/time/README.md @@ -6,7 +6,7 @@ A Model Context Protocol server that provides time and timezone conversion capab Source: https://github.com/modelcontextprotocol/servers/tree/main/src/time -Requires MCP Python SDK 1.x (`mcp>=1.29.0,<2`). SDK 2.0 renamed APIs this server uses. The port to v2 is in progress. +Requires MCP Python SDK 2.x (`mcp>=2.2,<3`). ### Available Tools diff --git a/src/time/pyproject.toml b/src/time/pyproject.toml index 44d24a99a0..014dbf629e 100644 --- a/src/time/pyproject.toml +++ b/src/time/pyproject.toml @@ -17,7 +17,8 @@ classifiers = [ "Programming Language :: Python :: 3.10", ] dependencies = [ - "mcp>=1.29.0,<2", + "jsonschema>=4.20.0", + "mcp>=2.2,<3", "pydantic>=2.0.0", "tzdata>=2024.2", "tzlocal>=5.3.1", diff --git a/src/time/src/mcp_server_time/server.py b/src/time/src/mcp_server_time/server.py index 461bc4a2ae..57fb2763df 100644 --- a/src/time/src/mcp_server_time/server.py +++ b/src/time/src/mcp_server_time/server.py @@ -3,23 +3,26 @@ from enum import Enum import json import sys -from typing import Sequence +from typing import Any from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from tzlocal import get_localzone_name # ← returns "Europe/Paris", etc. -from mcp.server import Server +import jsonschema +from mcp.server import Server, ServerRequestContext from mcp.server.stdio import stdio_server from mcp.types import ( + CallToolRequestParams, + CallToolResult, + ContentBlock, + ListToolsResult, + PaginatedRequestParams, Tool, ToolAnnotations, TextContent, - ImageContent, - EmbeddedResource, - ErrorData, INVALID_PARAMS, ) -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError from pydantic import BaseModel @@ -69,9 +72,7 @@ def get_zoneinfo(timezone_name: str) -> ZoneInfo: try: return ZoneInfo(timezone_name) except Exception as e: - raise McpError( - ErrorData(code=INVALID_PARAMS, message=f"Invalid timezone: {str(e)}") - ) + raise MCPError(code=INVALID_PARAMS, message=f"Invalid timezone: {str(e)}") class TimeServer: @@ -146,8 +147,14 @@ def convert_time( ) +def tool_error(message: str) -> CallToolResult: + """A tool call that failed, as an `isError` result the model can read.""" + return CallToolResult( + content=[TextContent(type="text", text=message)], is_error=True + ) + + async def serve(local_timezone: str | None = None) -> None: - server = Server("mcp-time", version=SERVER_VERSION) time_server = TimeServer() if local_timezone: # Fail before the transport opens, with one line naming the bad value @@ -163,64 +170,65 @@ async def serve(local_timezone: str | None = None) -> None: ) local_tz = str(get_local_tz(local_timezone)) - @server.list_tools() - async def list_tools() -> list[Tool]: - """List available time tools.""" - return [ - Tool( - name=TimeTools.GET_CURRENT_TIME.value, - description="Get current time in a specific timezone", - inputSchema={ - "type": "object", - "properties": { - "timezone": { - "type": "string", - "description": f"IANA timezone name (e.g., 'America/New_York', 'Europe/London'). Use '{local_tz}' as local timezone if no timezone provided by the user.", - } - }, - "required": ["timezone"], + tools = [ + Tool( + name=TimeTools.GET_CURRENT_TIME.value, + description="Get current time in a specific timezone", + input_schema={ + "type": "object", + "properties": { + "timezone": { + "type": "string", + "description": f"IANA timezone name (e.g., 'America/New_York', 'Europe/London'). Use '{local_tz}' as local timezone if no timezone provided by the user.", + } }, - annotations=ToolAnnotations( - readOnlyHint=True, - destructiveHint=False, - idempotentHint=True, - openWorldHint=False, - ), + "required": ["timezone"], + }, + annotations=ToolAnnotations( + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=False, ), - Tool( - name=TimeTools.CONVERT_TIME.value, - description="Convert time between timezones", - inputSchema={ - "type": "object", - "properties": { - "source_timezone": { - "type": "string", - "description": f"Source IANA timezone name (e.g., 'America/New_York', 'Europe/London'). Use '{local_tz}' as local timezone if no source timezone provided by the user.", - }, - "time": { - "type": "string", - "description": "Time to convert in 24-hour format (HH:MM)", - }, - "target_timezone": { - "type": "string", - "description": f"Target IANA timezone name (e.g., 'Asia/Tokyo', 'America/San_Francisco'). Use '{local_tz}' as local timezone if no target timezone provided by the user.", - }, + ), + Tool( + name=TimeTools.CONVERT_TIME.value, + description="Convert time between timezones", + input_schema={ + "type": "object", + "properties": { + "source_timezone": { + "type": "string", + "description": f"Source IANA timezone name (e.g., 'America/New_York', 'Europe/London'). Use '{local_tz}' as local timezone if no source timezone provided by the user.", + }, + "time": { + "type": "string", + "description": "Time to convert in 24-hour format (HH:MM)", + }, + "target_timezone": { + "type": "string", + "description": f"Target IANA timezone name (e.g., 'Asia/Tokyo', 'America/San_Francisco'). Use '{local_tz}' as local timezone if no target timezone provided by the user.", }, - "required": ["source_timezone", "time", "target_timezone"], }, - annotations=ToolAnnotations( - readOnlyHint=True, - destructiveHint=False, - idempotentHint=True, - openWorldHint=False, - ), + "required": ["source_timezone", "time", "target_timezone"], + }, + annotations=ToolAnnotations( + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=False, ), - ] + ), + ] + schemas = {tool.name: tool.input_schema for tool in tools} - @server.call_tool() - async def call_tool( - name: str, arguments: dict - ) -> Sequence[TextContent | ImageContent | EmbeddedResource]: + async def list_tools( + ctx: ServerRequestContext, params: PaginatedRequestParams | None + ) -> ListToolsResult: + """List available time tools.""" + return ListToolsResult(tools=tools) + + def run_tool(name: str, arguments: dict[str, Any]) -> list[ContentBlock]: """Handle tool calls for time queries.""" try: match name: @@ -236,7 +244,7 @@ async def call_tool( k in arguments for k in ["source_timezone", "time", "target_timezone"] ): - raise ValueError( # pragma: no cover # unreachable: the SDK rejects a missing key against inputSchema first + raise ValueError( # pragma: no cover # unreachable: call_tool rejects a missing key against inputSchema first "Missing required arguments" ) for key in ["source_timezone", "target_timezone"]: @@ -258,6 +266,30 @@ async def call_tool( except Exception as e: raise ValueError(f"Error processing mcp-server-time query: {str(e)}") + async def call_tool( + ctx: ServerRequestContext, params: CallToolRequestParams + ) -> CallToolResult: + # SDK v2's low-level server neither validates arguments nor turns a + # handler exception into an isError result, both of which SDK v1 did. + # Both are done here, with v1's messages, so the wire is unchanged. + arguments = params.arguments or {} + schema = schemas.get(params.name) + if schema is not None: + try: + jsonschema.validate(instance=arguments, schema=schema) + except jsonschema.ValidationError as e: + return tool_error(f"Input validation error: {e.message}") + try: + return CallToolResult(content=run_tool(params.name, arguments)) + except Exception as e: + return tool_error(str(e)) + + server = Server( + "mcp-time", + version=SERVER_VERSION, + on_list_tools=list_tools, + on_call_tool=call_tool, + ) options = server.create_initialization_options() async with stdio_server() as (read_stream, write_stream): await server.run(read_stream, write_stream, options) diff --git a/src/time/tests/test_protocol.py b/src/time/tests/test_protocol.py index f76aa58051..e3f99f6d9e 100644 --- a/src/time/tests/test_protocol.py +++ b/src/time/tests/test_protocol.py @@ -3,7 +3,7 @@ # # Each test runs the real `serve()` coroutine with `stdio_server` swapped for # an in-memory stream pair, and talks to it with a real `ClientSession`. That -# exercises what a client actually sees: the SDK's input validation, its +# exercises what a client actually sees: the server's input validation, its # folding of handler exceptions into `isError` results, the advertised tool # schemas and the initialize handshake. The pure helpers in `server.py` are # covered directly in `test_server.py`. @@ -54,7 +54,12 @@ def now(cls, tz: tzinfo | None = None): def wire(model: BaseModel) -> Any: """Serialize an SDK model the way it travels over the wire.""" - return model.model_dump(by_alias=True, mode="json", exclude_none=True) + # exclude_unset: SDK v2 result models default fields the 2025-11-25 wire + # does not carry (resultType, ttlMs, cacheScope), so only what the server + # actually sent is compared. + return model.model_dump( + by_alias=True, mode="json", exclude_none=True, exclude_unset=True + ) @asynccontextmanager @@ -131,7 +136,9 @@ async def test_initialize_reports_server_info_and_capabilities() -> None: } assert init["serverInfo"]["version"] != version("mcp") # Only tools are advertised; no resources, prompts or logging. - assert init["capabilities"] == {"experimental": {}, "tools": {"listChanged": False}} + # Behavior change in the SDK v2 port (#4851): v2 omits the empty + # `experimental: {}` object v1 always sent. It advertised nothing. + assert init["capabilities"] == {"tools": {"listChanged": False}} assert "instructions" not in init @@ -467,7 +474,7 @@ async def test_get_current_time_does_not_default_to_the_local_timezone() -> None @pytest.mark.parametrize( "arguments,expected", [ - # Rejected by the SDK's input validation against inputSchema, before + # Rejected by call_tool's input validation against inputSchema, before # the handler runs. ({}, error_result("Input validation error: 'timezone' is a required property")), ( @@ -478,7 +485,7 @@ async def test_get_current_time_does_not_default_to_the_local_timezone() -> None {"timezone": None}, error_result("Input validation error: None is not of type 'string'"), ), - # Raised by the handler and folded into an error result by the SDK. + # Raised by the handler and folded into an error result by call_tool. ({"timezone": ""}, handler_error("Missing required argument: timezone")), ( {"timezone": "Invalid/Timezone"}, @@ -817,7 +824,7 @@ async def test_convert_time_ignores_extra_arguments() -> None: @pytest.mark.parametrize( "arguments,expected", [ - # Rejected by the SDK's input validation, before the handler runs. A + # Rejected by call_tool's input validation, before the handler runs. A # missing key never reaches the handler's own "Missing required # arguments" check, which is why that branch is marked unreachable. ( @@ -899,8 +906,8 @@ async def test_convert_time_checks_timezones_before_the_time_string() -> None: async def test_unknown_tool_is_an_error_result_not_a_protocol_error() -> None: - # The SDK skips validation for an unlisted tool (and logs a warning), then - # the handler's fallthrough raises, which the SDK folds into `isError`. + # call_tool skips validation for an unlisted tool, then the handler's + # fallthrough raises, which call_tool folds into `isError`. async with connected() as session: result = await call(session, "no_such_tool", {"timezone": "UTC"}) assert result == handler_error("Unknown tool: no_such_tool") diff --git a/src/time/tests/test_server.py b/src/time/tests/test_server.py index a6a6c675f4..a197daa07a 100644 --- a/src/time/tests/test_server.py +++ b/src/time/tests/test_server.py @@ -1,5 +1,5 @@ from freezegun import freeze_time -from mcp.shared.exceptions import McpError +from mcp.shared.exceptions import MCPError import pytest from unittest.mock import patch from zoneinfo import ZoneInfo @@ -84,7 +84,7 @@ def test_get_current_time(test_time, timezone, expected): def test_get_current_time_with_invalid_timezone(): time_server = TimeServer() with pytest.raises( - McpError, + MCPError, match=r"Invalid timezone: 'No time zone found with key Invalid/Timezone'", ): time_server.get_current_time("Invalid/Timezone") @@ -115,7 +115,7 @@ def test_get_current_time_with_invalid_timezone(): ) def test_convert_time_errors(source_tz, time_str, target_tz, expected_error): time_server = TimeServer() - with pytest.raises((McpError, ValueError), match=expected_error): + with pytest.raises((MCPError, ValueError), match=expected_error): time_server.convert_time(source_tz, time_str, target_tz) diff --git a/src/time/uv.lock b/src/time/uv.lock index a54c221cad..d5201d4788 100644 --- a/src/time/uv.lock +++ b/src/time/uv.lock @@ -1,11 +1,12 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", "python_full_version < '3.14' and sys_platform == 'win32'", - "python_full_version < '3.14' and sys_platform != 'win32'", + "(python_full_version < '3.12' and sys_platform == 'emscripten') or (python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32')", ] [[package]] @@ -19,17 +20,16 @@ wheels = [ [[package]] name = "anyio" -version = "4.6.2.post1" +version = "4.15.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, - { name = "sniffio" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/09/45b9b7a6d4e45c6bcb5bf61d19e3ab87df68e0601fa8c5293de3542546cc/anyio-4.6.2.post1.tar.gz", hash = "sha256:4c8bc31ccdb51c7f7bd251f51c609e038d63e34219b44aa86e47576389880b4c", size = 173422, upload-time = "2024-10-14T14:31:44.021Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/f5/f2b75d2fc6f1a260f340f0e7c6a060f4dd2961cc16884ed851b0d18da06a/anyio-4.6.2.post1-py3-none-any.whl", hash = "sha256:6d170c36fba3bdd840c73d3868c1e777e33676a69c3a72cf0a0d5d6d8009b61d", size = 90377, upload-time = "2024-10-14T14:31:42.623Z" }, + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, ] [[package]] @@ -50,15 +50,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, ] -[[package]] -name = "certifi" -version = "2024.8.30" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507, upload-time = "2024-08-30T01:55:04.365Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321, upload-time = "2024-08-30T01:55:02.591Z" }, -] - [[package]] name = "cffi" version = "2.0.0" @@ -383,40 +374,42 @@ wheels = [ ] [[package]] -name = "httpcore" -version = "1.0.9" +name = "httpcore2" +version = "2.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "certifi" }, { name = "h11" }, + { name = "truststore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, + { url = "https://files.pythonhosted.org/packages/09/ba/a4568248771ce81957bfb7cc600264a40fbcda092391ee1c415c50be4bea/httpcore2-2.13.1-py3-none-any.whl", hash = "sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a", size = 83423, upload-time = "2026-09-23T07:47:19.365Z" }, ] [[package]] -name = "httpx" -version = "0.28.0" +name = "httpx2" +version = "2.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "certifi" }, - { name = "httpcore" }, + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/10/df/676b7cf674dd1bdc71a64ad393c89879f75e4a0ab8395165b498262ae106/httpx-0.28.0.tar.gz", hash = "sha256:0858d3bab51ba7e386637f22a61d8ccddaeec5f3fe4209da3a6168dbb91573e0", size = 141307, upload-time = "2024-11-28T14:54:56.977Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/44/474bef2a0e9d90f1715d32cb98b0738695ca17ba324095fb2497ed7fbd59/httpx2-2.13.1.tar.gz", hash = "sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa", size = 100405, upload-time = "2026-09-23T07:47:23.052Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/fb/a19866137577ba60c6d8b69498dc36be479b13ba454f691348ddf428f185/httpx-0.28.0-py3-none-any.whl", hash = "sha256:dc0b419a0cfeb6e8b34e85167c0da2671206f5095f1baa9663d23bcfd6b535fc", size = 73551, upload-time = "2024-11-28T14:54:55.141Z" }, + { url = "https://files.pythonhosted.org/packages/d8/9c/6fe8931fd9f381042a9e4c7d5a7b4cbf7016b252bec0c99a49fce42c3326/httpx2-2.13.1-py3-none-any.whl", hash = "sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4", size = 95597, upload-time = "2026-09-23T07:47:20.995Z" }, ] [[package]] -name = "httpx-sse" -version = "0.4.0" +name = "httpx2-jsfetch" +version = "1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624, upload-time = "2023-12-22T08:01:21.083Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819, upload-time = "2023-12-22T08:01:19.89Z" }, + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, ] [[package]] @@ -466,15 +459,15 @@ wheels = [ [[package]] name = "mcp" -version = "1.29.0" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, - { name = "httpx" }, - { name = "httpx-sse" }, + { name = "httpx2" }, { name = "jsonschema" }, + { name = "mcp-types" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, - { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, { name = "python-multipart" }, { name = "pywin32", marker = "sys_platform == 'win32'" }, @@ -484,9 +477,9 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/30/d3/f9acc21dfc886e4f78e2add1a47db46ce16884346afde53f8a064c02c891/mcp-1.29.0.tar.gz", hash = "sha256:52d01f334de1868cc3bb2d6604931126a67631f99a6c5d3b82ba47290315ec36", size = 643148, upload-time = "2026-07-28T13:41:41.939Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/8d/e0d339616f4810e9051d4aba6887afab289ab1f81875fe908b606cdfd0e3/mcp-2.3.0.tar.gz", hash = "sha256:8b147a50441cf059dc88c684e0aeed3687f0aa0f39c6cde7b90330effd2b34d8", size = 4171955, upload-time = "2026-10-02T22:06:56.091Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/c8/248b201f6d753d69fd5d6506011abbb35a946d9142b2ae311a948fd0be3d/mcp-1.29.0-py3-none-any.whl", hash = "sha256:f5a075bb611f23d6f4d080c6a1699fa62772eebc562ba9e66b306ddde1c755f7", size = 223436, upload-time = "2026-07-28T13:41:40.337Z" }, + { url = "https://files.pythonhosted.org/packages/20/d7/4a2878f128df729db3ca04e71bfce26405cc2e0c9ebe19c592feeb4c61b9/mcp-2.3.0-py3-none-any.whl", hash = "sha256:dd0c44c089d16453e8ae31a3877a0054d7a2314caaa81f5e0541b9b1734b2377", size = 368664, upload-time = "2026-10-02T22:06:52.119Z" }, ] [[package]] @@ -494,6 +487,7 @@ name = "mcp-server-time" version = "0.6.2" source = { editable = "." } dependencies = [ + { name = "jsonschema" }, { name = "mcp" }, { name = "pydantic" }, { name = "tzdata" }, @@ -512,7 +506,8 @@ dev = [ [package.metadata] requires-dist = [ - { name = "mcp", specifier = ">=1.29.0,<2" }, + { name = "jsonschema", specifier = ">=4.20.0" }, + { name = "mcp", specifier = ">=2.2,<3" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "tzdata", specifier = ">=2024.2" }, { name = "tzlocal", specifier = ">=5.3.1" }, @@ -528,6 +523,19 @@ dev = [ { name = "ruff", specifier = ">=0.8.1" }, ] +[[package]] +name = "mcp-types" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/2d/7c251e34207f6c51000312fc8839111ac45cfe02023f90b44e7f1051dd8e/mcp_types-2.3.0.tar.gz", hash = "sha256:d1e46549edb35ee19a94940fcee6d1addd7e589ab7ea92dda83f5d84781fc362", size = 65898, upload-time = "2026-10-02T22:06:57.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/48/32583066b155bb5d4507e7887dc88ee23118cc4de8e2a12870c49719e2a8/mcp_types-2.3.0-py3-none-any.whl", hash = "sha256:968efdbdaedfab06adae40d378a34395f1090c5921d4be3c9cde283aaf76d91d", size = 69108, upload-time = "2026-10-02T22:06:54.099Z" }, +] + [[package]] name = "nodeenv" version = "1.9.1" @@ -537,6 +545,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" }, +] + [[package]] name = "packaging" version = "24.2" @@ -697,20 +717,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, ] -[[package]] -name = "pydantic-settings" -version = "2.14.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pydantic" }, - { name = "python-dotenv" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, -] - [[package]] name = "pygments" version = "2.20.0" @@ -808,15 +814,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] -[[package]] -name = "python-dotenv" -version = "1.2.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, -] - [[package]] name = "python-multipart" version = "0.0.32" @@ -1018,27 +1015,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d9/5a/e7c31adbe875f2abbb91bd84cf2dc52d792b5a01506781dbcf25c91daf11/six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254", size = 11053, upload-time = "2021-05-05T14:18:17.237Z" }, ] -[[package]] -name = "sniffio" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, -] - [[package]] name = "sse-starlette" -version = "2.1.3" +version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "starlette" }, - { name = "uvicorn" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/fc/56ab9f116b2133521f532fce8d03194cf04dcac25f583cf3d839be4c0496/sse_starlette-2.1.3.tar.gz", hash = "sha256:9cd27eb35319e1414e3d2558ee7414487f9529ce3b3cf9b21434fd110e017169", size = 19678, upload-time = "2024-08-01T08:52:50.248Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/be/0123026f719d1a7936f214a88b553bb5701e04ff2511147c1dab0c5035eb/sse_starlette-3.5.0.tar.gz", hash = "sha256:75de713aa8a9441513cc283220826da079d982770965b951e9437720e8bafdb2", size = 36794, upload-time = "2026-09-28T17:48:14.7Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/52/aa/36b271bc4fa1d2796311ee7c7283a3a1c348bad426d37293609ca4300eef/sse_starlette-2.1.3-py3-none-any.whl", hash = "sha256:8ec846438b4665b9e8c560fcdea6bc8081a3abf7942faa95e5a744999d219772", size = 9383, upload-time = "2024-08-01T08:52:48.659Z" }, + { url = "https://files.pythonhosted.org/packages/be/e4/cdda14023c316d71493bc54fdffc3dd006631b88866145c9d3cc33e0f1df/sse_starlette-3.5.0-py3-none-any.whl", hash = "sha256:3e6e1070df3f0f5d9cea81496de92dbb72f6721871d99748ece67441dd8b7997", size = 17407, upload-time = "2026-09-28T17:48:13.228Z" }, ] [[package]] @@ -1093,13 +1080,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257, upload-time = "2024-11-27T22:38:35.385Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] [[package]] From b5af22668592700c02236d737f9836c42654edb6 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 7 Oct 2026 12:24:56 -0400 Subject: [PATCH 002/117] chore: migrate the TypeScript servers to TS SDK v2 (#4856) Run @modelcontextprotocol/codemod v1-to-v2 (2.3.1) on everything, filesystem, memory and sequentialthinking, and finish what it leaves: - Manifests: drop @modelcontextprotocol/sdk; depend on @modelcontextprotocol/server (plus node and server-legacy in everything), with client (and core, for everything's skipped task tests) as devDependencies; zod ^4.2.0; engines node >=20. The root devDependency for the boot smoke moves to @modelcontextprotocol/client. - everything: wrap the three raw-shape prompt argsSchemas in z.object(); drop the result schemas from ctx.mcpReq.send(); keep HTTP+SSE on the frozen server-legacy bridge. - Experimental tasks are gone in v2 (SEP-2663): remove simulate-research-query and the two trigger-*-async tools and the tasks capability, pending #4852. Their tests stay, skipped, behind typed stand-ins for the removed v1 surface. - Re-baseline the Part 1 (#4854) assertions that SDK v2's documented wire changes move: unknown tools reject with -32602, the "MCP error : " prefix is gone, validation issues read "field: message", tool schemas declare JSON Schema 2020-12, tools no longer advertise execution.taskSupport, and the resource-not-found message reads "Resource not found: ". - Docs, skills and the smoke script follow; one major changeset per server records the breaking changes. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .changeset/everything-sdk-v2.md | 5 + .changeset/filesystem-sdk-v2.md | 5 + .changeset/memory-sdk-v2.md | 5 + .changeset/sequentialthinking-sdk-v2.md | 5 + .claude/skills/security-advisory/SKILL.md | 3 +- .claude/skills/testing/SKILL.md | 2 +- package-lock.json | 253 +++++++------ package.json | 2 +- scripts/smoke-servers.mjs | 12 +- src/everything/__tests__/harness.ts | 13 +- .../__tests__/in-memory-event-store.test.ts | 2 +- .../__tests__/registrations.test.ts | 44 +-- src/everything/__tests__/resources.test.ts | 2 +- src/everything/__tests__/roots.test.ts | 9 +- .../__tests__/sampling-elicitation.test.ts | 42 +-- .../__tests__/server-version.test.ts | 3 +- src/everything/__tests__/server.test.ts | 5 +- src/everything/__tests__/sse.test.ts | 3 +- src/everything/__tests__/stdio.test.ts | 4 +- .../__tests__/streamable-http.test.ts | 16 +- src/everything/__tests__/surface.test.ts | 218 +++-------- src/everything/__tests__/tasks.test.ts | 175 ++++++--- src/everything/__tests__/tools.test.ts | 14 +- src/everything/docs/features.md | 56 +-- src/everything/docs/instructions.md | 2 - src/everything/docs/structure.md | 11 +- src/everything/package.json | 11 +- src/everything/prompts/args.ts | 6 +- src/everything/prompts/completions.ts | 7 +- src/everything/prompts/index.ts | 2 +- src/everything/prompts/resource.ts | 7 +- src/everything/prompts/simple.ts | 2 +- src/everything/resources/files.ts | 2 +- src/everything/resources/index.ts | 2 +- src/everything/resources/session.ts | 5 +- src/everything/resources/subscriptions.ts | 18 +- src/everything/resources/templates.ts | 4 +- src/everything/server/index.ts | 26 +- src/everything/server/logging.ts | 3 +- src/everything/server/roots.ts | 10 +- src/everything/tools/echo.ts | 3 +- src/everything/tools/get-annotated-message.ts | 3 +- src/everything/tools/get-env.ts | 3 +- src/everything/tools/get-resource-links.ts | 3 +- .../tools/get-resource-reference.ts | 3 +- src/everything/tools/get-roots-list.ts | 7 +- .../tools/get-structured-content.ts | 4 +- src/everything/tools/get-sum.ts | 3 +- src/everything/tools/get-tiny-image.ts | 3 +- src/everything/tools/gzip-file-as-resource.ts | 7 +- src/everything/tools/index.ts | 14 +- .../tools/simulate-research-query.ts | 348 ------------------ .../tools/toggle-simulated-logging.ts | 7 +- .../tools/toggle-subscriber-updates.ts | 7 +- .../trigger-elicitation-request-async.ts | 295 --------------- .../tools/trigger-elicitation-request.ts | 11 +- .../tools/trigger-long-running-operation.ts | 9 +- .../tools/trigger-sampling-request-async.ts | 236 ------------ .../tools/trigger-sampling-request.ts | 12 +- .../tools/trigger-url-elicitation.ts | 12 +- .../transports/in-memory-event-store.ts | 4 +- src/everything/transports/sse.ts | 2 +- src/everything/transports/stdio.ts | 2 +- src/everything/transports/streamableHttp.ts | 12 +- src/filesystem/__tests__/bin-smoke.test.ts | 4 +- src/filesystem/__tests__/helpers.ts | 16 +- src/filesystem/__tests__/server-paths.test.ts | 2 +- src/filesystem/__tests__/server-roots.test.ts | 6 +- src/filesystem/__tests__/server-tools.test.ts | 25 +- src/filesystem/__tests__/stdio.test.ts | 6 +- src/filesystem/index.ts | 4 +- src/filesystem/package.json | 8 +- src/filesystem/roots-utils.ts | 2 +- src/filesystem/server.ts | 17 +- src/memory/__tests__/entry-point.test.ts | 3 +- src/memory/__tests__/helpers.ts | 7 +- src/memory/__tests__/resource.test.ts | 8 +- src/memory/__tests__/stdio-smoke.test.ts | 4 +- src/memory/__tests__/tools.test.ts | 37 +- src/memory/index.ts | 85 ++--- src/memory/package.json | 8 +- .../__tests__/entry-point.test.ts | 3 +- src/sequentialthinking/__tests__/helpers.ts | 3 +- .../__tests__/input-schema.test.ts | 26 +- .../__tests__/stdio-smoke.test.ts | 4 +- .../__tests__/tools-list.test.ts | 43 ++- src/sequentialthinking/index.ts | 14 +- src/sequentialthinking/package.json | 8 +- 88 files changed, 674 insertions(+), 1695 deletions(-) create mode 100644 .changeset/everything-sdk-v2.md create mode 100644 .changeset/filesystem-sdk-v2.md create mode 100644 .changeset/memory-sdk-v2.md create mode 100644 .changeset/sequentialthinking-sdk-v2.md delete mode 100644 src/everything/tools/simulate-research-query.ts delete mode 100644 src/everything/tools/trigger-elicitation-request-async.ts delete mode 100644 src/everything/tools/trigger-sampling-request-async.ts diff --git a/.changeset/everything-sdk-v2.md b/.changeset/everything-sdk-v2.md new file mode 100644 index 0000000000..39558eefc6 --- /dev/null +++ b/.changeset/everything-sdk-v2.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/server-everything": major +--- + +Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`, `@modelcontextprotocol/node`, and `@modelcontextprotocol/server-legacy` for the deprecated HTTP+SSE transport) from `@modelcontextprotocol/sdk` 1.x (#4856). The task tools `simulate-research-query`, `trigger-sampling-request-async` and `trigger-elicitation-request-async` are removed, and the server no longer advertises the `tasks` capability, because SDK v2 dropped the experimental tasks layer they were built on; they return on the tasks extension in #4852. Breaking for clients: the server now requires Node.js 20 or later; an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); tool schemas in `tools/list` declare JSON Schema 2020-12 instead of draft-07; and tools no longer advertise `execution.taskSupport`. The optional `state` argument of `args-prompt` now carries its description. diff --git a/.changeset/filesystem-sdk-v2.md b/.changeset/filesystem-sdk-v2.md new file mode 100644 index 0000000000..c39ccdf732 --- /dev/null +++ b/.changeset/filesystem-sdk-v2.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/server-filesystem": major +--- + +Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`) from `@modelcontextprotocol/sdk` 1.x (#4856). Breaking for clients: the server now requires Node.js 20 or later; an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); and tools no longer advertise `execution.taskSupport`. diff --git a/.changeset/memory-sdk-v2.md b/.changeset/memory-sdk-v2.md new file mode 100644 index 0000000000..9d0b41c03b --- /dev/null +++ b/.changeset/memory-sdk-v2.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/server-memory": major +--- + +Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`) from `@modelcontextprotocol/sdk` 1.x (#4856). Breaking for clients: the server now requires Node.js 20 or later; an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); tool schemas in `tools/list` declare JSON Schema 2020-12 instead of draft-07; and tools no longer advertise `execution.taskSupport`. diff --git a/.changeset/sequentialthinking-sdk-v2.md b/.changeset/sequentialthinking-sdk-v2.md new file mode 100644 index 0000000000..4965463dd1 --- /dev/null +++ b/.changeset/sequentialthinking-sdk-v2.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/server-sequential-thinking": major +--- + +Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`) from `@modelcontextprotocol/sdk` 1.x (#4856). Breaking for clients: the server now requires Node.js 20 or later; an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); tool schemas in `tools/list` declare JSON Schema 2020-12 instead of draft-07; and tools no longer advertise `execution.taskSupport`. diff --git a/.claude/skills/security-advisory/SKILL.md b/.claude/skills/security-advisory/SKILL.md index e2b85e39c6..841f6bc476 100644 --- a/.claude/skills/security-advisory/SKILL.md +++ b/.claude/skills/security-advisory/SKILL.md @@ -140,7 +140,8 @@ this order: Servers listed in the README or the Registry are third-party; their reports go to their own maintainers. 2. **Is the code path the server's, or an SDK's?** The TypeScript servers are - built on `@modelcontextprotocol/sdk` + built on the TypeScript SDK's v2 packages (`@modelcontextprotocol/server`, + `@modelcontextprotocol/node` and the frozen `@modelcontextprotocol/server-legacy`) ([typescript-sdk](https://github.com/modelcontextprotocol/typescript-sdk)), the Python servers on `mcp` ([python-sdk](https://github.com/modelcontextprotocol/python-sdk)). Transport diff --git a/.claude/skills/testing/SKILL.md b/.claude/skills/testing/SKILL.md index 7ba7883001..bfaecb72d8 100644 --- a/.claude/skills/testing/SKILL.md +++ b/.claude/skills/testing/SKILL.md @@ -83,7 +83,7 @@ Each `connect` returns the `client` and a `close()`; call `close()` in ```ts import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import type { Client } from "@modelcontextprotocol/client"; import { connect, contentOf, textOf, type Session } from "./harness.js"; describe("echo, over the protocol", () => { diff --git a/package-lock.json b/package-lock.json index 6cdfdc7427..f9b270efc3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "@changesets/changelog-github": "^1.0.1", "@changesets/cli": "^3.0.3", "@eslint/js": "^10.0.1", - "@modelcontextprotocol/sdk": "^1.30.0", + "@modelcontextprotocol/client": "^2.3.1", "eslint": "^10.11.0", "globals": "^17.12.0", "prettier": "3.8.4", @@ -576,18 +576,6 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@hono/node-server": { - "version": "2.0.12", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.12.tgz", - "integrity": "sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg==", - "license": "MIT", - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "hono": "^4" - } - }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -748,44 +736,76 @@ "node": ">=20.0.0" } }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.30.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", - "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", - "license": "MIT", + "node_modules/@modelcontextprotocol/client": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.3.1.tgz", + "integrity": "sha512-mIGZXpHsjnZ6lD+gD/WCMpR5k8yVQQ8nNFH1N0Srf7AvnwTUMYD6pvTY8ng2+He5FfV7YMZLmrjL7cLa/cc3dQ==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@hono/node-server": "^1.19.9 || ^2.0.5", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "content-type": "^1.0.5", - "cors": "^2.8.5", + "@modelcontextprotocol/core": "2.3.1", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", + "eventsource-parser": "^3.0.8", "jose": "^6.1.3", - "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" + "zod": "^4.2.0" }, "engines": { - "node": ">=18" + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.3.1.tgz", + "integrity": "sha512-laVmIhPGpWi7rG9q+0UigblRN0Hws/Yjlb6qLFtVqryXVLkc4h+yN8z731POD2LnAGQlFe7/tXhUm7Bi0YB91g==", + "license": "Apache-2.0", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/node": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/node/-/node-2.1.1.tgz", + "integrity": "sha512-EY92OAXN2xYL7tNS/MVsWJW1v8VHAPwl9B4Ca3pCrjmvqyC+S1C/6UHsMxF2GoFPraS5Yyg56vFz9brVpFojMA==", + "license": "Apache-2.0", + "dependencies": { + "@hono/node-server": "^1.19.9", + "hono": "^4.11.4" + }, + "engines": { + "node": ">=20" }, "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" + "@modelcontextprotocol/server": "^2.3.0" + } + }, + "node_modules/@modelcontextprotocol/node/node_modules/@hono/node-server": { + "version": "1.19.17", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", + "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - }, - "zod": { - "optional": false - } + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.3.1.tgz", + "integrity": "sha512-e59MfWuSssj6DoQ96JCTqdW1AXT5/XtnTl0WWd6MU1Ka434fFUGGosCLJbGNySDhHGvxQWoRWK137FXYFkYNWw==", + "license": "Apache-2.0", + "dependencies": { + "@modelcontextprotocol/core": "2.3.1", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" } }, "node_modules/@modelcontextprotocol/server-everything": { @@ -796,6 +816,33 @@ "resolved": "src/filesystem", "link": true }, + "node_modules/@modelcontextprotocol/server-legacy": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server-legacy/-/server-legacy-2.3.1.tgz", + "integrity": "sha512-+cuB+hl7s8DiFXIt13le3cD2wZPOeDYwlycAkRMPljqqNbOLekGRiyslx8WXfqaXSCveS1eDD8/B8rmxz9BZFQ==", + "deprecated": "This package is a frozen copy of v1's SSE transport and OAuth Authorization Server helpers for migration purposes only. Use StreamableHTTP from @modelcontextprotocol/server and a dedicated OAuth server in production. Will not receive new features.", + "license": "Apache-2.0", + "dependencies": { + "@modelcontextprotocol/core": "2.3.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "express-rate-limit": "^8.2.1", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "express": "^4.18.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "express": { + "optional": true + } + } + }, "node_modules/@modelcontextprotocol/server-memory": { "resolved": "src/memory", "link": true @@ -1754,39 +1801,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2143,6 +2157,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -2531,6 +2546,7 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, "license": "MIT", "dependencies": { "eventsource-parser": "^3.0.1" @@ -2543,6 +2559,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "dev": true, "license": "MIT", "engines": { "node": ">=18.0.0" @@ -2715,6 +2732,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -2765,22 +2783,6 @@ "fast-string-truncated-width": "^3.0.2" } }, - "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/fast-wrap-ansi": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", @@ -3333,6 +3335,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -3385,6 +3388,7 @@ "version": "6.2.3", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -3397,18 +3401,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "license": "BSD-2-Clause" - }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -4152,6 +4144,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4449,15 +4442,6 @@ "node": ">=0.10.0" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -4661,6 +4645,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -4673,6 +4658,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5280,6 +5266,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -5439,36 +5426,34 @@ "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/zod-to-json-schema": { - "version": "3.25.2", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", - "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.25.28 || ^4" - } - }, "src/everything": { "name": "@modelcontextprotocol/server-everything", "version": "1.0.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@modelcontextprotocol/sdk": "^1.30.0", + "@modelcontextprotocol/node": "^2.1.1", + "@modelcontextprotocol/server": "^2.3.1", + "@modelcontextprotocol/server-legacy": "^2.3.1", "cors": "^2.8.5", "express": "^5.2.1", "jszip": "^3.10.1", - "zod": "^4.0.0" + "zod": "^4.2.0" }, "bin": { "mcp-server-everything": "dist/index.js" }, "devDependencies": { + "@modelcontextprotocol/client": "^2.3.1", + "@modelcontextprotocol/core": "^2.3.1", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@vitest/coverage-v8": "^4.1.8", "shx": "^0.4.0", "typescript": "^5.8.2", "vitest": "^4.1.8" + }, + "engines": { + "node": ">=20" } }, "src/filesystem": { @@ -5476,16 +5461,17 @@ "version": "1.0.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@modelcontextprotocol/sdk": "^1.30.0", + "@modelcontextprotocol/server": "^2.3.1", "diff": "^8.0.3", "glob": "^13.0.6", "minimatch": "^10.0.1", - "zod": "^4.0.0" + "zod": "^4.2.0" }, "bin": { "mcp-server-filesystem": "dist/index.js" }, "devDependencies": { + "@modelcontextprotocol/client": "^2.3.1", "@types/diff": "^5.0.9", "@types/minimatch": "^5.1.2", "@types/node": "^22", @@ -5493,6 +5479,9 @@ "shx": "^0.4.0", "typescript": "^5.8.2", "vitest": "^4.1.8" + }, + "engines": { + "node": ">=20" } }, "src/memory": { @@ -5500,18 +5489,22 @@ "version": "1.0.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@modelcontextprotocol/sdk": "^1.30.0", - "zod": "^4.0.0" + "@modelcontextprotocol/server": "^2.3.1", + "zod": "^4.2.0" }, "bin": { "mcp-server-memory": "dist/index.js" }, "devDependencies": { + "@modelcontextprotocol/client": "^2.3.1", "@types/node": "^22", "@vitest/coverage-v8": "^4.1.8", "shx": "^0.4.0", "typescript": "^5.8.2", "vitest": "^4.1.8" + }, + "engines": { + "node": ">=20" } }, "src/sequentialthinking": { @@ -5519,21 +5512,25 @@ "version": "1.0.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@modelcontextprotocol/sdk": "^1.30.0", + "@modelcontextprotocol/server": "^2.3.1", "chalk": "^5.3.0", "yargs": "^17.7.2", - "zod": "^4.0.0" + "zod": "^4.2.0" }, "bin": { "mcp-server-sequential-thinking": "dist/index.js" }, "devDependencies": { + "@modelcontextprotocol/client": "^2.3.1", "@types/node": "^22", "@types/yargs": "^17.0.32", "@vitest/coverage-v8": "^4.1.8", "shx": "^0.4.0", "typescript": "^5.8.2", "vitest": "^4.1.8" + }, + "engines": { + "node": ">=20" } } } diff --git a/package.json b/package.json index 44af657abf..4db728718f 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "@changesets/changelog-github": "^1.0.1", "@changesets/cli": "^3.0.3", "@eslint/js": "^10.0.1", - "@modelcontextprotocol/sdk": "^1.30.0", + "@modelcontextprotocol/client": "^2.3.1", "eslint": "^10.11.0", "globals": "^17.12.0", "prettier": "3.8.4", diff --git a/scripts/smoke-servers.mjs b/scripts/smoke-servers.mjs index ed21b3f0ff..f0e98f67d5 100755 --- a/scripts/smoke-servers.mjs +++ b/scripts/smoke-servers.mjs @@ -62,10 +62,12 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { setTimeout as delay } from "node:timers/promises"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; -import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { + Client, + SSEClientTransport, + StreamableHTTPClientTransport, +} from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; const repoRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -310,7 +312,7 @@ async function exercise(client, server, ctx) { throw new Error( `tools/list does not include \`${call.name}\` (got: ${tools.map((t) => t.name).join(", ")})`, ); - const result = await client.callTool(call, undefined, options); + const result = await client.callTool(call, options); const text = textOf(result); if (result.isError) throw new Error(`\`${call.name}\` returned an error result: ${text}`); diff --git a/src/everything/__tests__/harness.ts b/src/everything/__tests__/harness.ts index b87cbd39fe..326058244b 100644 --- a/src/everything/__tests__/harness.ts +++ b/src/everything/__tests__/harness.ts @@ -17,22 +17,18 @@ * runs the server's `cleanup(sessionId)` the way the HTTP transports do. */ import { randomUUID } from "node:crypto"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import type { TaskStore } from "@modelcontextprotocol/sdk/experimental/tasks"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import type { ClientCapabilities, ContentBlock, ServerNotification, -} from "@modelcontextprotocol/sdk/types.js"; +} from "@modelcontextprotocol/client"; import { createServer } from "../server/index.js"; /** Options for one in-process session. */ export type ConnectOptions = { /** The capabilities the client declares in `initialize`. */ capabilities?: ClientCapabilities; - /** A client-side task store, for servers that send task-augmented requests. */ - taskStore?: TaskStore; /** Install request handlers on the client before it connects. */ setup?: (client: Client) => void; /** The server transport's session id; `null` leaves it undefined. */ @@ -62,10 +58,7 @@ export async function connect(options: ConnectOptions = {}): Promise { const client = new Client( { name: "everything-test-client", version: "0.0.0" }, - { - capabilities: options.capabilities ?? {}, - ...(options.taskStore ? { taskStore: options.taskStore } : {}), - }, + { capabilities: options.capabilities ?? {} }, ); const notifications: ServerNotification[] = []; client.fallbackNotificationHandler = async (notification) => { diff --git a/src/everything/__tests__/in-memory-event-store.test.ts b/src/everything/__tests__/in-memory-event-store.test.ts index 6d59c296a7..1f65ac7be1 100644 --- a/src/everything/__tests__/in-memory-event-store.test.ts +++ b/src/everything/__tests__/in-memory-event-store.test.ts @@ -5,7 +5,7 @@ // unknown id before replaying). import { describe, it, expect } from "vitest"; -import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; +import type { JSONRPCMessage } from "@modelcontextprotocol/server"; import { InMemoryEventStore } from "../transports/in-memory-event-store.js"; const message = (method: string): JSONRPCMessage => ({ diff --git a/src/everything/__tests__/registrations.test.ts b/src/everything/__tests__/registrations.test.ts index de73827f3a..f828ce4ffa 100644 --- a/src/everything/__tests__/registrations.test.ts +++ b/src/everything/__tests__/registrations.test.ts @@ -3,7 +3,7 @@ // imports they replace ran inside the 5 s test timeout, and a cold start // (transforming the whole tool tree) could take longer than that. import { describe, it, expect, vi } from "vitest"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "@modelcontextprotocol/server"; import { registerConditionalTools, registerTools } from "../tools/index.js"; import { registerPrompts } from "../prompts/index.js"; import { readInstructions, registerResources } from "../resources/index.js"; @@ -52,7 +52,7 @@ describe("Registration Index Files", () => { }); it("should register conditional tools based on capabilities", () => { - // Server with all capabilities including experimental tasks API + // Server with all capabilities const mockServerWithCapabilities = { registerTool: vi.fn(), server: { @@ -62,18 +62,13 @@ describe("Registration Index Files", () => { sampling: {}, })), }, - experimental: { - tasks: { - registerToolTask: vi.fn(), - }, - }, } as unknown as McpServer; registerConditionalTools(mockServerWithCapabilities); // Should register 4 conditional tools via registerTool when all capabilities - // are present. Task-based tools register via registerToolTask (counted separately), - // so they are not included in this registerTool count. + // are present. The task tools went with SDK v2's experimental tasks layer; + // Part 5 (#4852) brings them back. expect(mockServerWithCapabilities.registerTool).toHaveBeenCalledTimes(4); const registeredTools = vi @@ -83,11 +78,6 @@ describe("Registration Index Files", () => { expect(registeredTools).toContain("trigger-elicitation-request"); expect(registeredTools).toContain("trigger-url-elicitation"); expect(registeredTools).toContain("trigger-sampling-request"); - - // Task-based tools are registered via experimental.tasks.registerToolTask - expect( - mockServerWithCapabilities.experimental.tasks.registerToolTask, - ).toHaveBeenCalled(); }); it("should not register conditional tools before capabilities are known", () => { @@ -98,11 +88,6 @@ describe("Registration Index Files", () => { server: { getClientCapabilities: vi.fn(() => undefined), }, - experimental: { - tasks: { - registerToolTask: vi.fn(), - }, - }, } as unknown as McpServer; // partial mock: McpServer's private members rule out a structural literal registerConditionalTools(mockServerBeforeInit); @@ -116,11 +101,6 @@ describe("Registration Index Files", () => { server: { getClientCapabilities: vi.fn(() => ({})), }, - experimental: { - tasks: { - registerToolTask: vi.fn(), - }, - }, } as unknown as McpServer; registerConditionalTools(mockServerNoCapabilities); @@ -140,11 +120,6 @@ describe("Registration Index Files", () => { tasks: { requests: { elicitation: { create: {} } } }, })), }, - experimental: { - tasks: { - registerToolTask: vi.fn(), - }, - }, } as unknown as McpServer; // partial mock: McpServer's private members rule out a structural literal registerConditionalTools(mockServerUrlOnly); @@ -202,22 +177,13 @@ describe("Registration Index Files", () => { server: { getClientCapabilities: vi.fn(() => capabilities), }, - experimental: { - tasks: { - registerToolTask: vi.fn(), - }, - }, } as unknown as McpServer; // partial mock: McpServer's private members rule out a structural literal registerConditionalTools(mockServer); - const viaRegisterTool = vi + return vi .mocked(mockServer.registerTool) .mock.calls.map((call) => call[0]); - const viaRegisterToolTask = vi - .mocked(mockServer.experimental.tasks.registerToolTask) - .mock.calls.map((call) => call[0]); - return [...viaRegisterTool, ...viaRegisterToolTask]; }; // A tool is capability-gated if declaring the capabilities makes it appear. diff --git a/src/everything/__tests__/resources.test.ts b/src/everything/__tests__/resources.test.ts index 6fdabcba4b..4e01f2e0bb 100644 --- a/src/everything/__tests__/resources.test.ts +++ b/src/everything/__tests__/resources.test.ts @@ -84,7 +84,7 @@ describe("dynamic resource templates", () => { it("rejects a URI that matches no resource or template", async () => { await expect( session.client.readResource({ uri: "demo://resource/nowhere" }), - ).rejects.toThrow("Resource demo://resource/nowhere not found"); + ).rejects.toThrow("Resource not found: demo://resource/nowhere"); }); }); diff --git a/src/everything/__tests__/roots.test.ts b/src/everything/__tests__/roots.test.ts index 9c98aba1b2..88cada7c13 100644 --- a/src/everything/__tests__/roots.test.ts +++ b/src/everything/__tests__/roots.test.ts @@ -6,10 +6,7 @@ * all driven by a test client that answers the server's `roots/list`. */ import { afterEach, describe, expect, it, vi } from "vitest"; -import { - ListRootsRequestSchema, - type Root, -} from "@modelcontextprotocol/sdk/types.js"; +import type { Root } from "@modelcontextprotocol/server"; import { connect, contentOf, @@ -32,7 +29,7 @@ async function connectWithRoots(answer: () => Root[]) { const s = await connect({ capabilities: { roots: { listChanged: true } }, setup: (client) => - client.setRequestHandler(ListRootsRequestSchema, async () => { + client.setRequestHandler("roots/list", async () => { requests++; return { roots: answer() }; }), @@ -110,7 +107,7 @@ describe("roots sync after initialize", () => { session = s; await vi.waitFor(() => expect(errorSpy).toHaveBeenCalledWith( - `Failed to request roots from client ${s.sessionId}: MCP error -32603: no roots for you`, + `Failed to request roots from client ${s.sessionId}: no roots for you`, ), ); expect(logData(s)).toEqual([]); diff --git a/src/everything/__tests__/sampling-elicitation.test.ts b/src/everything/__tests__/sampling-elicitation.test.ts index bc5e36f8b7..4acbc272e1 100644 --- a/src/everything/__tests__/sampling-elicitation.test.ts +++ b/src/everything/__tests__/sampling-elicitation.test.ts @@ -7,16 +7,13 @@ * server asked and what the tool then returned. */ import { afterEach, describe, expect, it } from "vitest"; -import { - CreateMessageRequestSchema, - ElicitRequestSchema, - ErrorCode, - McpError, - type ClientCapabilities, - type CreateMessageRequest, - type ElicitRequest, - type ElicitResult, -} from "@modelcontextprotocol/sdk/types.js"; +import { ProtocolError, ProtocolErrorCode } from "@modelcontextprotocol/server"; +import type { + ClientCapabilities, + CreateMessageRequest, + ElicitRequest, + ElicitResult, +} from "@modelcontextprotocol/server"; import { __resetIssuedErrorPathElicitations } from "../tools/trigger-url-elicitation.js"; import { connect, @@ -40,7 +37,7 @@ describe("trigger-sampling-request", () => { session = await connect({ capabilities: { sampling: {} }, setup: (client) => - client.setRequestHandler(CreateMessageRequestSchema, async (req) => { + client.setRequestHandler("sampling/createMessage", async (req) => { asked.push(req.params); return { role: "assistant", @@ -91,7 +88,7 @@ describe("trigger-sampling-request", () => { session = await connect({ capabilities: { sampling: {} }, setup: (client) => - client.setRequestHandler(CreateMessageRequestSchema, async (req) => { + client.setRequestHandler("sampling/createMessage", async (req) => { maxTokens = req.params.maxTokens; return { role: "assistant", @@ -111,8 +108,11 @@ describe("trigger-sampling-request", () => { session = await connect({ capabilities: { sampling: {} }, setup: (client) => - client.setRequestHandler(CreateMessageRequestSchema, async () => { - throw new McpError(ErrorCode.InvalidRequest, "user rejected"); + client.setRequestHandler("sampling/createMessage", async () => { + throw new ProtocolError( + ProtocolErrorCode.InvalidRequest, + "user rejected", + ); }), }); const result = await session.client.callTool({ @@ -125,7 +125,7 @@ describe("trigger-sampling-request", () => { content: [ { type: "text", - text: "MCP error -32600: MCP error -32600: user rejected", + text: "user rejected", }, ], }); @@ -143,7 +143,7 @@ async function connectElicitation( ...options, capabilities, setup: (client) => - client.setRequestHandler(ElicitRequestSchema, async (req) => { + client.setRequestHandler("elicitation/create", async (req) => { asked.push(req.params); return answer(req); }), @@ -476,12 +476,12 @@ describe("trigger-url-elicitation", () => { const { s, asked } = await connectElicitation(() => ({ action: "accept" })); session = s; const error = await errorPathRejection(s); - expect(error).toBeInstanceOf(McpError); - const mcpError = error as McpError; + expect(error).toBeInstanceOf(ProtocolError); + const mcpError = error as ProtocolError; expect(mcpError.code).toBe(-32042); // The SDK prefixes the code twice on the client side. expect(mcpError.message).toBe( - "MCP error -32042: MCP error -32042: This request requires browser-based authorization.", + "This request requires browser-based authorization.", ); expect(mcpError.data).toEqual({ elicitations: [ @@ -510,7 +510,7 @@ describe("trigger-url-elicitation", () => { // The one-shot marker was consumed, so a third call errors again. expect(await errorPathRejection(s, { elicitationId: "x" })).toBeInstanceOf( - McpError, + ProtocolError, ); }); @@ -521,7 +521,7 @@ describe("trigger-url-elicitation", () => { // A different requested id is a different call: it errors. expect( await errorPathRejection(s, { elicitationId: "other" }), - ).toBeInstanceOf(McpError); + ).toBeInstanceOf(ProtocolError); // The original call (no requested id) is recognized as the retry. const retry = await callUrlTool(s, { errorPath: true }); expect(retry.isError).toBeUndefined(); diff --git a/src/everything/__tests__/server-version.test.ts b/src/everything/__tests__/server-version.test.ts index 579b42897d..4335b8c524 100644 --- a/src/everything/__tests__/server-version.test.ts +++ b/src/everything/__tests__/server-version.test.ts @@ -5,8 +5,7 @@ import { describe, it, expect, vi } from "vitest"; import { createRequire } from "node:module"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { createServer } from "../server/index.js"; import { resolvePackageVersion, SERVER_VERSION } from "../version.js"; diff --git a/src/everything/__tests__/server.test.ts b/src/everything/__tests__/server.test.ts index 2135944a4f..a864088ae6 100644 --- a/src/everything/__tests__/server.test.ts +++ b/src/everything/__tests__/server.test.ts @@ -6,9 +6,8 @@ * for a server with no initialized client. */ import { describe, expect, it } from "vitest"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { InMemoryTransport, McpServer } from "@modelcontextprotocol/server"; +import { Client } from "@modelcontextprotocol/client"; import { createServer } from "../server/index.js"; import { registerSessionResource } from "../resources/session.js"; import { syncRoots } from "../server/roots.js"; diff --git a/src/everything/__tests__/sse.test.ts b/src/everything/__tests__/sse.test.ts index 62c7c4f12f..886cd30c66 100644 --- a/src/everything/__tests__/sse.test.ts +++ b/src/everything/__tests__/sse.test.ts @@ -8,8 +8,7 @@ import type { Server } from "node:http"; import { once } from "node:events"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { Client, SSEClientTransport } from "@modelcontextprotocol/client"; import { createApp } from "../transports/sse.js"; let http: Server; diff --git a/src/everything/__tests__/stdio.test.ts b/src/everything/__tests__/stdio.test.ts index a5ff839568..b1b9847d2c 100644 --- a/src/everything/__tests__/stdio.test.ts +++ b/src/everything/__tests__/stdio.test.ts @@ -7,8 +7,8 @@ */ import { PassThrough } from "node:stream"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; +import { Client } from "@modelcontextprotocol/client"; import { main, startStdioServer, diff --git a/src/everything/__tests__/streamable-http.test.ts b/src/everything/__tests__/streamable-http.test.ts index d374bfac3a..fe735c97b9 100644 --- a/src/everything/__tests__/streamable-http.test.ts +++ b/src/everything/__tests__/streamable-http.test.ts @@ -11,9 +11,11 @@ import type { Server } from "node:http"; import { once } from "node:events"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; -import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node"; +import { + Client, + StreamableHTTPClientTransport, +} from "@modelcontextprotocol/client"; import { createApp, type StreamableHttpApp, @@ -328,7 +330,7 @@ describe("Streamable HTTP transport", () => { it("maps a POST that throws before answering to a JSON-RPC internal error", async () => { vi.spyOn( - StreamableHTTPServerTransport.prototype, + NodeStreamableHTTPServerTransport.prototype, "handleRequest", ).mockRejectedValueOnce(new Error("boom")); const response = await post({ jsonrpc: "2.0", id: 1, method: "ping" }); @@ -346,7 +348,7 @@ describe("Streamable HTTP transport", () => { it("leaves a POST alone when it throws after answering", async () => { const { sessionId } = await rawInitialize(); vi.spyOn( - StreamableHTTPServerTransport.prototype, + NodeStreamableHTTPServerTransport.prototype, "handleRequest", ).mockImplementationOnce(async (_req, res) => { res.writeHead(200).end("partial"); @@ -363,7 +365,7 @@ describe("Streamable HTTP transport", () => { it("maps a DELETE that throws to a JSON-RPC internal error, unless it already answered", async () => { const { sessionId } = await rawInitialize(); const handle = vi.spyOn( - StreamableHTTPServerTransport.prototype, + NodeStreamableHTTPServerTransport.prototype, "handleRequest", ); handle.mockRejectedValueOnce(new Error("boom")); @@ -444,7 +446,7 @@ describe("shutdown", () => { const { client, sessionId } = await connectHttp(); const failure = new Error("close failed"); vi.spyOn( - StreamableHTTPServerTransport.prototype, + NodeStreamableHTTPServerTransport.prototype, "close", ).mockRejectedValueOnce(failure); await app.shutdown(); diff --git a/src/everything/__tests__/surface.test.ts b/src/everything/__tests__/surface.test.ts index 0f589341d8..4086b323f2 100644 --- a/src/everything/__tests__/surface.test.ts +++ b/src/everything/__tests__/surface.test.ts @@ -31,7 +31,6 @@ const BASE_TOOLS = [ "get-sum", "get-tiny-image", "gzip-file-as-resource", - "simulate-research-query", "toggle-simulated-logging", "toggle-subscriber-updates", "trigger-long-running-operation", @@ -52,11 +51,6 @@ describe("initialize", () => { resources: { subscribe: true, listChanged: true }, logging: {}, completions: {}, - tasks: { - list: {}, - cancel: {}, - requests: { tools: { call: {} } }, - }, }); expect(client.getInstructions()).toMatch( /^# Everything Server – Server Instructions/, @@ -75,9 +69,7 @@ describe("initialize", () => { const gated = [ "get-roots-list", "trigger-elicitation-request", - "trigger-elicitation-request-async", "trigger-sampling-request", - "trigger-sampling-request-async", "trigger-url-elicitation", ]; @@ -116,9 +108,7 @@ describe("tools/list", () => { ...BASE_TOOLS, "get-roots-list", "trigger-elicitation-request", - "trigger-elicitation-request-async", "trigger-sampling-request", - "trigger-sampling-request-async", "trigger-url-elicitation", ].sort(), ); @@ -135,6 +125,20 @@ describe("tools/list", () => { { elicitation: { form: {}, url: {} } }, ["trigger-elicitation-request", "trigger-url-elicitation"], ], + ])( + "gates tools on the client's declared capabilities: %j", + async (capabilities, extra) => { + session = await connect({ capabilities }); + const { tools } = await session.client.listTools(); + expect(tools.map((t) => t.name).sort()).toEqual( + [...BASE_TOOLS, ...extra].sort(), + ); + }, + ); + + // The async task tools went with SDK v2's experimental tasks layer + // (SEP-2663). Part 5 (#4852) brings them back on the tasks extension. + it.skip.each([ [ { sampling: {}, @@ -166,7 +170,7 @@ describe("tools/list", () => { [{ tasks: { requests: { sampling: { createMessage: {} } } } }, []], [{ tasks: { requests: { elicitation: { create: {} } } } }, []], ])( - "gates tools on the client's declared capabilities: %j", + "gates the async task tools on the client's declared capabilities: %j", async (capabilities, extra) => { session = await connect({ capabilities }); const { tools } = await session.client.listTools(); @@ -177,7 +181,9 @@ describe("tools/list", () => { ); it("sends tools/list_changed after initialize, when the conditional tools are registered", async () => { - session = await connect(); + // A client with no capabilities gets no conditional tools, so nothing is + // registered after initialize and no list_changed is sent. + session = await connect({ capabilities: ALL_CAPABILITIES }); await session.client.listTools(); expect( ofMethod(session.notifications, "notifications/tools/list_changed") @@ -210,9 +216,7 @@ describe("tools/list", () => { "openWorldHint": false, "readOnlyHint": true, }, - "execution": { - "taskSupport": "forbidden", - }, + "execution": undefined, "hasOutputSchema": false, "title": "Echo Tool", }, @@ -223,9 +227,7 @@ describe("tools/list", () => { "openWorldHint": false, "readOnlyHint": true, }, - "execution": { - "taskSupport": "forbidden", - }, + "execution": undefined, "hasOutputSchema": false, "title": "Get Annotated Message Tool", }, @@ -236,9 +238,7 @@ describe("tools/list", () => { "openWorldHint": false, "readOnlyHint": true, }, - "execution": { - "taskSupport": "forbidden", - }, + "execution": undefined, "hasOutputSchema": false, "title": "Print Environment Tool", }, @@ -249,9 +249,7 @@ describe("tools/list", () => { "openWorldHint": false, "readOnlyHint": true, }, - "execution": { - "taskSupport": "forbidden", - }, + "execution": undefined, "hasOutputSchema": false, "title": "Get Resource Links Tool", }, @@ -262,9 +260,7 @@ describe("tools/list", () => { "openWorldHint": false, "readOnlyHint": true, }, - "execution": { - "taskSupport": "forbidden", - }, + "execution": undefined, "hasOutputSchema": false, "title": "Get Resource Reference Tool", }, @@ -275,9 +271,7 @@ describe("tools/list", () => { "openWorldHint": false, "readOnlyHint": true, }, - "execution": { - "taskSupport": "forbidden", - }, + "execution": undefined, "hasOutputSchema": false, "title": "Get Roots List Tool", }, @@ -288,9 +282,7 @@ describe("tools/list", () => { "openWorldHint": false, "readOnlyHint": true, }, - "execution": { - "taskSupport": "forbidden", - }, + "execution": undefined, "hasOutputSchema": true, "title": "Get Structured Content Tool", }, @@ -301,9 +293,7 @@ describe("tools/list", () => { "openWorldHint": false, "readOnlyHint": true, }, - "execution": { - "taskSupport": "forbidden", - }, + "execution": undefined, "hasOutputSchema": false, "title": "Get Sum Tool", }, @@ -314,9 +304,7 @@ describe("tools/list", () => { "openWorldHint": false, "readOnlyHint": true, }, - "execution": { - "taskSupport": "forbidden", - }, + "execution": undefined, "hasOutputSchema": false, "title": "Get Tiny Image Tool", }, @@ -327,25 +315,10 @@ describe("tools/list", () => { "openWorldHint": true, "readOnlyHint": false, }, - "execution": { - "taskSupport": "forbidden", - }, + "execution": undefined, "hasOutputSchema": false, "title": "GZip File as Resource Tool", }, - "simulate-research-query": { - "annotations": { - "destructiveHint": false, - "idempotentHint": false, - "openWorldHint": false, - "readOnlyHint": false, - }, - "execution": { - "taskSupport": "required", - }, - "hasOutputSchema": false, - "title": "Simulate Research Query", - }, "toggle-simulated-logging": { "annotations": { "destructiveHint": false, @@ -353,9 +326,7 @@ describe("tools/list", () => { "openWorldHint": false, "readOnlyHint": false, }, - "execution": { - "taskSupport": "forbidden", - }, + "execution": undefined, "hasOutputSchema": false, "title": "Toggle Simulated Logging", }, @@ -366,9 +337,7 @@ describe("tools/list", () => { "openWorldHint": false, "readOnlyHint": false, }, - "execution": { - "taskSupport": "forbidden", - }, + "execution": undefined, "hasOutputSchema": false, "title": "Toggle Subscriber Updates", }, @@ -379,25 +348,10 @@ describe("tools/list", () => { "openWorldHint": false, "readOnlyHint": false, }, - "execution": { - "taskSupport": "forbidden", - }, + "execution": undefined, "hasOutputSchema": false, "title": "Trigger Elicitation Request Tool", }, - "trigger-elicitation-request-async": { - "annotations": { - "destructiveHint": false, - "idempotentHint": false, - "openWorldHint": false, - "readOnlyHint": false, - }, - "execution": { - "taskSupport": "forbidden", - }, - "hasOutputSchema": false, - "title": "Trigger Async Elicitation Request Tool", - }, "trigger-long-running-operation": { "annotations": { "destructiveHint": false, @@ -405,9 +359,7 @@ describe("tools/list", () => { "openWorldHint": false, "readOnlyHint": true, }, - "execution": { - "taskSupport": "forbidden", - }, + "execution": undefined, "hasOutputSchema": false, "title": "Trigger Long Running Operation Tool", }, @@ -418,25 +370,10 @@ describe("tools/list", () => { "openWorldHint": true, "readOnlyHint": false, }, - "execution": { - "taskSupport": "forbidden", - }, + "execution": undefined, "hasOutputSchema": false, "title": "Trigger Sampling Request Tool", }, - "trigger-sampling-request-async": { - "annotations": { - "destructiveHint": false, - "idempotentHint": false, - "openWorldHint": true, - "readOnlyHint": false, - }, - "execution": { - "taskSupport": "forbidden", - }, - "hasOutputSchema": false, - "title": "Trigger Async Sampling Request Tool", - }, "trigger-url-elicitation": { "annotations": { "destructiveHint": false, @@ -444,9 +381,7 @@ describe("tools/list", () => { "openWorldHint": true, "readOnlyHint": false, }, - "execution": { - "taskSupport": "forbidden", - }, + "execution": undefined, "hasOutputSchema": false, "title": "Trigger URL Elicitation Tool", }, @@ -465,7 +400,7 @@ describe("tools/list", () => { expect(schemas).toMatchInlineSnapshot(` { "echo": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "message": { "description": "Message to echo", @@ -478,7 +413,7 @@ describe("tools/list", () => { "type": "object", }, "get-annotated-message": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "includeImage": { "default": false, @@ -501,12 +436,12 @@ describe("tools/list", () => { "type": "object", }, "get-env": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": {}, "type": "object", }, "get-resource-links": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "count": { "default": 3, @@ -519,7 +454,7 @@ describe("tools/list", () => { "type": "object", }, "get-resource-reference": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "resourceId": { "default": 1, @@ -538,12 +473,12 @@ describe("tools/list", () => { "type": "object", }, "get-roots-list": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": {}, "type": "object", }, "get-structured-content": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "location": { "description": "Choose city", @@ -561,7 +496,7 @@ describe("tools/list", () => { "type": "object", }, "get-sum": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "a": { "description": "First number", @@ -579,12 +514,12 @@ describe("tools/list", () => { "type": "object", }, "get-tiny-image": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": {}, "type": "object", }, "gzip-file-as-resource": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "data": { "default": "https://raw.githubusercontent.com/modelcontextprotocol/servers/refs/heads/main/README.md", @@ -609,46 +544,23 @@ describe("tools/list", () => { }, "type": "object", }, - "simulate-research-query": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "ambiguous": { - "default": false, - "description": "Simulate an ambiguous query that requires clarification (triggers input_required status)", - "type": "boolean", - }, - "topic": { - "description": "The research topic to investigate", - "type": "string", - }, - }, - "required": [ - "topic", - ], - "type": "object", - }, "toggle-simulated-logging": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": {}, "type": "object", }, "toggle-subscriber-updates": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": {}, "type": "object", }, "trigger-elicitation-request": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": {}, - "type": "object", - }, - "trigger-elicitation-request-async": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": {}, "type": "object", }, "trigger-long-running-operation": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "duration": { "default": 10, @@ -664,25 +576,7 @@ describe("tools/list", () => { "type": "object", }, "trigger-sampling-request": { - "$schema": "http://json-schema.org/draft-07/schema#", - "properties": { - "maxTokens": { - "default": 100, - "description": "Maximum number of tokens to generate", - "type": "number", - }, - "prompt": { - "description": "The prompt to send to the LLM", - "type": "string", - }, - }, - "required": [ - "prompt", - ], - "type": "object", - }, - "trigger-sampling-request-async": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "maxTokens": { "default": 100, @@ -700,7 +594,7 @@ describe("tools/list", () => { "type": "object", }, "trigger-url-elicitation": { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "elicitationId": { "description": "Optional explicit elicitation ID. Defaults to a random UUID.", @@ -737,7 +631,7 @@ describe("tools/list", () => { const tool = tools.find((t) => t.name === "get-structured-content"); expect(tool?.outputSchema).toMatchInlineSnapshot(` { - "$schema": "http://json-schema.org/draft-07/schema#", + "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { "conditions": { @@ -771,12 +665,15 @@ describe("prompts/list", () => { expect(prompts).toMatchInlineSnapshot(` [ { + "_meta": undefined, "arguments": undefined, "description": "A prompt with no arguments", + "icons": undefined, "name": "simple-prompt", "title": "Simple Prompt", }, { + "_meta": undefined, "arguments": [ { "description": "Name of the city", @@ -784,16 +681,18 @@ describe("prompts/list", () => { "required": true, }, { - "description": undefined, + "description": "Name of the state", "name": "state", "required": false, }, ], "description": "A prompt with two arguments, one required and one optional", + "icons": undefined, "name": "args-prompt", "title": "Arguments Prompt", }, { + "_meta": undefined, "arguments": [ { "description": "Choose the department.", @@ -807,10 +706,12 @@ describe("prompts/list", () => { }, ], "description": "First argument choice narrows values for second argument.", + "icons": undefined, "name": "completable-prompt", "title": "Team Management", }, { + "_meta": undefined, "arguments": [ { "description": "Type of resource to fetch", @@ -824,6 +725,7 @@ describe("prompts/list", () => { }, ], "description": "A prompt that includes an embedded resource reference", + "icons": undefined, "name": "resource-prompt", "title": "Resource Prompt", }, diff --git a/src/everything/__tests__/tasks.test.ts b/src/everything/__tests__/tasks.test.ts index 6a6fa61e88..1d8318b395 100644 --- a/src/everything/__tests__/tasks.test.ts +++ b/src/everything/__tests__/tasks.test.ts @@ -11,19 +11,100 @@ * capabilities and keeps its tasks in an `InMemoryTaskStore`. * * Every wait in these tools is a `setTimeout` (one-second stages and polls), - * so the tests fake `setTimeout` and advance the clock. + * so the tests fake `setTimeout` and advance the clock. * + * SDK v2 removed the experimental tasks layer these tools and tests were + * built on (SEP-2663), so the three tools are gone and every test here is + * skipped until Part 5 (#4852) re-implements them on the + * `io.modelcontextprotocol/tasks` extension. The tests are kept, not deleted, + * as the record of the behavior Part 5 restores. The v1 client surface they + * drive (`client.experimental.tasks`, `InMemoryTaskStore`, task-returning + * request handlers) is typed by the stand-ins below so the bodies still + * compile; calling any of them throws. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { InMemoryTaskStore } from "@modelcontextprotocol/sdk/experimental/tasks"; -import { - CallToolResultSchema, - CreateMessageRequestSchema, - ElicitRequestSchema, - type ElicitRequest, - type ElicitResult, -} from "@modelcontextprotocol/sdk/types.js"; +import type { + CallToolResult, + Client, + ElicitRequest, + ElicitResult, + GetTaskResult, +} from "@modelcontextprotocol/client"; +import { CallToolResultSchema } from "@modelcontextprotocol/core"; import { connect, contentOf, textOf, type Session } from "./harness.js"; +/** A task, as the v1 tasks API reported it. */ +type Task = GetTaskResult; + +/** One message of v1's `callToolStream`. */ +type TaskStreamMessage = + | { type: "taskCreated"; task: Task } + | { type: "taskStatus"; task: Task } + | { type: "result"; result: CallToolResult } + | { type: "error"; error: Error }; + +const REMOVED = + "SDK v2 removed the experimental tasks API (SEP-2663); see #4852"; + +/** Stand-in for v1's `client.experimental.tasks`. */ +function experimentalTasks(_client: Client): { + callToolStream: ( + params: { name: string; arguments: Record }, + resultSchema: unknown, + ) => AsyncGenerator; + listTasks: () => Promise<{ tasks: Task[] }>; + cancelTask: (taskId: string) => Promise; + getTask: (taskId: string) => Promise; +} { + throw new Error(REMOVED); +} + +/** Stand-in for v1's client-side `InMemoryTaskStore`. */ +class InMemoryTaskStore { + constructor() { + throw new Error(REMOVED); + } + createTask( + _params: { ttl?: number }, + _requestId: number, + _request: unknown, + ): Promise { + throw new Error(REMOVED); + } + getTask(_taskId: string): Promise { + throw new Error(REMOVED); + } + storeTaskResult( + _taskId: string, + _status: "completed" | "failed", + _result: Record, + ): Promise { + throw new Error(REMOVED); + } + updateTaskStatus( + _taskId: string, + _status: Task["status"], + _statusMessage?: string, + ): Promise { + throw new Error(REMOVED); + } +} + +/** + * Stand-in for a v1 client request handler that answers a task-augmented + * request with a `CreateTaskResult`, which v2's typed handlers do not allow. + */ +function setTaskRequestHandler< + M extends "sampling/createMessage" | "elicitation/create", +>( + _client: Client, + _method: M, + _handler: (request: { + params: { task?: { ttl?: number } }; + }) => Promise<{ task: Task }>, +): void { + throw new Error(REMOVED); +} + let session: Session | undefined; beforeEach(() => { @@ -65,7 +146,7 @@ describe("simulate-research-query", () => { await s.client.listTools(); // lets callToolStream see taskSupport return settle( collect( - s.client.experimental.tasks.callToolStream( + experimentalTasks(s.client).callToolStream( { name: "simulate-research-query", arguments: args }, CallToolResultSchema, ), @@ -85,7 +166,7 @@ describe("simulate-research-query", () => { return textOf(contentOf(last.result)[0]); } - it("creates a task that works through four stages and completes with a report", async () => { + it.skip("creates a task that works through four stages and completes with a report", async () => { session = await connect(); const messages = await research({ topic: "tides" }); @@ -140,7 +221,7 @@ describe("simulate-research-query", () => { `); }); - it("is rejected as a tool error when called without a task", async () => { + it.skip("is rejected as a tool error when called without a task", async () => { session = await connect(); const result = await session.client.callTool({ name: "simulate-research-query", @@ -157,7 +238,7 @@ describe("simulate-research-query", () => { }); }); - it("ignores 'ambiguous' for a client without elicitation", async () => { + it.skip("ignores 'ambiguous' for a client without elicitation", async () => { session = await connect(); const messages = await research({ topic: "python", ambiguous: true }); expect(statusMessages(messages)).not.toContain( @@ -174,7 +255,7 @@ describe("simulate-research-query", () => { session = await connect({ capabilities: { elicitation: { form: {} } }, setup: (client) => - client.setRequestHandler(ElicitRequestSchema, async (req) => { + client.setRequestHandler("elicitation/create", async (req) => { asked.push(req.params); return answer(req); }), @@ -182,7 +263,7 @@ describe("simulate-research-query", () => { return asked; } - it("asks for a clarification through tasks/result, then completes with it", async () => { + it.skip("asks for a clarification through tasks/result, then completes with it", async () => { const asked = await connectAnswering(() => ({ action: "accept", content: { interpretation: "programming" }, @@ -221,7 +302,7 @@ describe("simulate-research-query", () => { ); }); - it("offers generic interpretations for other topics", async () => { + it.skip("offers generic interpretations for other topics", async () => { const asked = await connectAnswering(() => ({ action: "decline" })); await research({ topic: "Mercury", ambiguous: true }); const params = asked[0]; @@ -236,7 +317,7 @@ describe("simulate-research-query", () => { ); }); - it.each([ + it.skip.each([ [{ action: "decline" }, "User declined - using default interpretation"], [{ action: "cancel" }, "User cancelled - using default interpretation"], [{ action: "accept", content: {} }, "User accepted without selection"], @@ -253,7 +334,7 @@ describe("simulate-research-query", () => { }, ); - it("falls back to a default interpretation when the elicitation fails", async () => { + it.skip("falls back to a default interpretation when the elicitation fails", async () => { const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); await connectAnswering(() => { throw new Error("no UI"); @@ -273,12 +354,12 @@ describe("simulate-research-query", () => { }); }); - it("lists its task, and stops working on it once the client cancels it", async () => { + it.skip("lists its task, and stops working on it once the client cancels it", async () => { const errors = vi.spyOn(console, "error").mockImplementation(() => {}); session = await connect(); const { client } = session; await client.listTools(); - const stream = client.experimental.tasks.callToolStream( + const stream = experimentalTasks(client).callToolStream( { name: "simulate-research-query", arguments: { topic: "tides" } }, CallToolResultSchema, ); @@ -289,10 +370,10 @@ describe("simulate-research-query", () => { const { taskId } = first.value.task; await stream.return(undefined); - const { tasks } = await client.experimental.tasks.listTasks(); + const { tasks } = await experimentalTasks(client).listTasks(); expect(tasks.map((t) => t.taskId)).toContain(taskId); - await client.experimental.tasks.cancelTask(taskId); + await experimentalTasks(client).cancelTask(taskId); // The next stage's status update fails on the cancelled task; the // failure is logged, marking it failed fails too, and it stays cancelled. await vi.advanceTimersByTimeAsync(5000); @@ -300,7 +381,7 @@ describe("simulate-research-query", () => { `Research task ${taskId} failed:`, expect.any(Error), ); - expect(await client.experimental.tasks.getTask(taskId)).toMatchObject({ + expect(await experimentalTasks(client).getTask(taskId)).toMatchObject({ status: "cancelled", statusMessage: "Client cancelled task execution.", }); @@ -346,12 +427,11 @@ async function connectTaskClient( }, }, }, - taskStore, setup: (client) => { - client.setRequestHandler(CreateMessageRequestSchema, async (req) => + setTaskRequestHandler(client, "sampling/createMessage", async (req) => createTask("sampling", req, req.params.task?.ttl), ); - client.setRequestHandler(ElicitRequestSchema, async (req) => + setTaskRequestHandler(client, "elicitation/create", async (req) => createTask("elicitation", req, req.params.task?.ttl), ); }, @@ -361,9 +441,12 @@ async function connectTaskClient( async function callAsync(name: string, args: Record = {}) { const result = await settle( - session!.client.callTool({ name, arguments: args }, undefined, { - timeout: 3_600_000, - }), + session!.client.callTool( + { name, arguments: args }, + { + timeout: 3_600_000, + }, + ), 700_000, ); return contentOf(result).map(textOf); @@ -381,7 +464,7 @@ const SAMPLE = { }; describe("trigger-sampling-request-async", () => { - it("sends a task-augmented sampling request, polls the client's task, and returns its result", async () => { + it.skip("sends a task-augmented sampling request, polls the client's task, and returns its result", async () => { let created = ""; const { taskStore } = await connectTaskClient((_method, taskId) => { created = taskId!; @@ -408,7 +491,7 @@ describe("trigger-sampling-request-async", () => { ); }); - it("reports a failed client task with its status message", async () => { + it.skip("reports a failed client task with its status message", async () => { const { taskStore } = await connectTaskClient((_m, taskId) => { later(() => taskStore.updateTaskStatus(taskId!, "failed", "model overloaded"), @@ -422,7 +505,7 @@ describe("trigger-sampling-request-async", () => { ); }); - it("reports the status message of a client task that is already finished when it is created", async () => { + it.skip("reports the status message of a client task that is already finished when it is created", async () => { // A task that fails before it is returned is never polled, so its status // message comes from the CreateTaskResult. const { taskStore } = await connectTaskClient((_m, taskId) => { @@ -436,7 +519,7 @@ describe("trigger-sampling-request-async", () => { ); }); - it("reports a cancelled client task with no message", async () => { + it.skip("reports a cancelled client task with no message", async () => { const { taskStore } = await connectTaskClient((_m, taskId) => { later(() => taskStore.updateTaskStatus(taskId!, "cancelled")); }); @@ -446,7 +529,7 @@ describe("trigger-sampling-request-async", () => { expect(text).toMatch(/^\[CANCELLED\] No message\n\nProgress:\n/); }); - it("gives up after 60 polls of a task that never finishes", async () => { + it.skip("gives up after 60 polls of a task that never finishes", async () => { await connectTaskClient(() => {}); const [text] = await callAsync("trigger-sampling-request-async", { prompt: "p", @@ -459,7 +542,7 @@ describe("trigger-sampling-request-async", () => { ); }); - it("returns a synchronous answer from a client that ignores the task request", async () => { + it.skip("returns a synchronous answer from a client that ignores the task request", async () => { session = await connect({ capabilities: { sampling: {}, @@ -481,7 +564,7 @@ describe("trigger-sampling-request-async", () => { }); describe("trigger-elicitation-request-async", () => { - it("sends a task-augmented elicitation and summarizes the accepted answer", async () => { + it.skip("sends a task-augmented elicitation and summarizes the accepted answer", async () => { const asked: string[] = []; const { taskStore } = await connectTaskClient((method, taskId) => { asked.push(method); @@ -505,7 +588,7 @@ describe("trigger-elicitation-request-async", () => { ); }); - it("summarizes an accepted answer with no fields", async () => { + it.skip("summarizes an accepted answer with no fields", async () => { const { taskStore } = await connectTaskClient((_m, taskId) => { later(() => taskStore.storeTaskResult(taskId!, "completed", { @@ -521,7 +604,7 @@ describe("trigger-elicitation-request-async", () => { ]); }); - it.each([ + it.skip.each([ [ "decline", "[DECLINED] User declined to provide the requested information.", @@ -535,7 +618,7 @@ describe("trigger-elicitation-request-async", () => { expect(texts[0]).toBe(expected); }); - it("reports only progress and the raw result for an answer of another shape", async () => { + it.skip("reports only progress and the raw result for an answer of another shape", async () => { const { taskStore } = await connectTaskClient((_m, taskId) => { later(() => taskStore.storeTaskResult(taskId!, "completed", { action: "accept" }), @@ -546,7 +629,7 @@ describe("trigger-elicitation-request-async", () => { expect(texts[0]).toMatch(/^\nProgress:\n/); }); - it("logs the first poll, every tenth, and every status change while input is required", async () => { + it.skip("logs the first poll, every tenth, and every status change while input is required", async () => { let taskId = ""; const { taskStore } = await connectTaskClient((_m, id) => { taskId = id!; @@ -568,7 +651,7 @@ describe("trigger-elicitation-request-async", () => { expect(polls).toHaveLength(4); }); - it("reports the status message of a client task that is already finished when it is created", async () => { + it.skip("reports the status message of a client task that is already finished when it is created", async () => { // A task that fails before it is returned is never polled, so its status // message comes from the CreateTaskResult. const { taskStore } = await connectTaskClient((_m, taskId) => { @@ -582,7 +665,7 @@ describe("trigger-elicitation-request-async", () => { ]); }); - it("reports a failed client task", async () => { + it.skip("reports a failed client task", async () => { const { taskStore } = await connectTaskClient((_m, taskId) => { later(() => taskStore.updateTaskStatus(taskId!, "failed")); }); @@ -597,7 +680,7 @@ describe("trigger-elicitation-request-async", () => { }); } - it("gives up 5 seconds before the 10-minute TTL of a task that never finishes", async () => { + it.skip("gives up 5 seconds before the 10-minute TTL of a task that never finishes", async () => { fakeClock(); // A client that keeps its task longer than the 10-minute TTL asked for. await connectTaskClient(() => {}, 60_000); @@ -607,7 +690,7 @@ describe("trigger-elicitation-request-async", () => { ); }); - it("times out before a client that honors the 10-minute TTL expires the task", async () => { + it.skip("times out before a client that honors the 10-minute TTL expires the task", async () => { fakeClock(); let created = ""; const { taskStore } = await connectTaskClient((_m, taskId) => { @@ -625,7 +708,7 @@ describe("trigger-elicitation-request-async", () => { expect((await taskStore.getTask(created))?.ttl).toBe(600000); }); - it("times out without polling when its wait ends past the deadline", async () => { + it.skip("times out without polling when its wait ends past the deadline", async () => { // A timer that fires late: the first reading sets the deadline, and every // later one is already past it. let readings = 0; @@ -641,7 +724,7 @@ describe("trigger-elicitation-request-async", () => { ]); }); - it("returns a synchronous answer from a client that ignores the task request", async () => { + it.skip("returns a synchronous answer from a client that ignores the task request", async () => { session = await connect({ capabilities: { elicitation: {}, diff --git a/src/everything/__tests__/tools.test.ts b/src/everything/__tests__/tools.test.ts index 56650c47b6..66aef69288 100644 --- a/src/everything/__tests__/tools.test.ts +++ b/src/everything/__tests__/tools.test.ts @@ -44,7 +44,7 @@ describe("echo", () => { const result = await call("echo", {}); expect(result.isError).toBe(true); expect(textOf(contentOf(result)[0])).toMatch( - /^MCP error -32602: Input validation error: Invalid arguments for tool echo/, + /^Input validation error: Invalid arguments for tool echo/, ); }); }); @@ -175,7 +175,6 @@ describe("trigger-long-running-operation", () => { name: "trigger-long-running-operation", arguments: { duration: 0.03, steps: 3 }, }, - undefined, { onprogress: (p) => progress.push(p) }, ); expect(textOf(contentOf(result)[0])).toBe( @@ -308,11 +307,10 @@ describe("get-resource-reference", () => { }); describe("an unknown tool", () => { - it("is reported as a tool error", async () => { - const result = await call("no-such-tool"); - expect(result.isError).toBe(true); - expect(textOf(contentOf(result)[0])).toBe( - "MCP error -32602: Tool no-such-tool not found", - ); + it("is rejected with -32602", async () => { + await expect(call("no-such-tool")).rejects.toMatchObject({ + code: -32602, + message: "Tool no-such-tool not found", + }); }); }); diff --git a/src/everything/docs/features.md b/src/everything/docs/features.md index 84327a6987..12f8435130 100644 --- a/src/everything/docs/features.md +++ b/src/everything/docs/features.md @@ -25,9 +25,6 @@ - `trigger-elicitation-request` (tools/trigger-elicitation-request.ts): Issues an `elicitation/create` request using form-mode fields (strings, numbers, booleans, enums, and format validation) and returns the resulting action/content. Requires client form-mode elicitation: `elicitation.form`, or an `elicitation` object that declares neither mode. - `trigger-url-elicitation` (tools/trigger-url-elicitation.ts): Issues an `elicitation/create` request in URL mode (`mode: "url"`) with an `elicitationId`, or throws MCP error `-32042` (`UrlElicitationRequiredError`) when `errorPath=true`. On the error path the prerequisite elicitation it returns points at a different URL than the failing request (`https://modelcontextprotocol.io`); when the client satisfies it and retries the same call, the retry ignores `errorPath` and proceeds via the request path, so the client does not loop on the same error. The retry marker is one-shot per `(session, url, elicitationId)`: it is cleared on the recognized retry, so re-running the error path with identical arguments without an intervening prerequisite is treated as a retry and proceeds. Requires client capability `elicitation.url`. - `trigger-sampling-request` (tools/trigger-sampling-request.ts): Issues a `sampling/createMessage` request to the client/LLM using provided `prompt` and optional generation controls; returns the LLM's response payload. -- `simulate-research-query` (tools/simulate-research-query.ts): Demonstrates MCP Tasks (SEP-1686) with a simulated multi-stage research operation. Accepts `topic` and `ambiguous` parameters. Returns a task that progresses through stages with status updates. If `ambiguous` is true and client supports elicitation, sends an elicitation request directly to gather clarification before completing. -- `trigger-sampling-request-async` (tools/trigger-sampling-request-async.ts): Demonstrates bidirectional tasks where the server sends a sampling request that the client executes as a background task. Server polls for status and retrieves the LLM result when complete. Requires client to support `tasks.requests.sampling.createMessage`. -- `trigger-elicitation-request-async` (tools/trigger-elicitation-request-async.ts): Demonstrates bidirectional tasks where the server sends an elicitation request that the client executes as a background task. Server polls while waiting for user input. Requires client form-mode elicitation (as for `trigger-elicitation-request`) and `tasks.requests.elicitation.create`. ## Prompts @@ -56,50 +53,11 @@ - Use the `toggle-simulated-logging` tool to start/stop periodic log messages of varying levels (debug, info, notice, warning, error, critical, alert, emergency) per session. - Clients can control the minimum level they receive via the standard MCP `logging/setLevel` request. -## Tasks (SEP-1686) +## Tasks -The server advertises support for MCP Tasks, enabling long-running operations with status tracking: - -- **Capabilities advertised**: `tasks.list`, `tasks.cancel`, `tasks.requests.tools.call` -- **Task Store**: Uses `InMemoryTaskStore` from SDK experimental for task lifecycle management -- **Message Queue**: Uses `InMemoryTaskMessageQueue` for task-related messaging - -### Task Lifecycle - -1. Client calls `tools/call` with `task: true` parameter -2. Server returns `CreateTaskResult` with `taskId` instead of immediate result -3. Client polls `tasks/get` to check status and receive `statusMessage` updates -4. When status is `completed`, client calls `tasks/result` to retrieve the final result - -### Task Statuses - -- `working`: Task is actively processing -- `input_required`: Task needs additional input (server sends elicitation request directly) -- `completed`: Task finished successfully -- `failed`: Task encountered an error -- `cancelled`: Task was cancelled by client - -### Demo Tools - -**Server-side tasks (client calls server):** -Use the `simulate-research-query` tool to exercise the full task lifecycle. Set `ambiguous: true` to trigger elicitation - the server will send an `elicitation/create` request directly and await the response before completing. - -**Client-side tasks (server calls client):** -Use `trigger-sampling-request-async` or `trigger-elicitation-request-async` to demonstrate bidirectional tasks where the server sends requests that the client executes as background tasks. These require the client to advertise `sampling` with `tasks.requests.sampling.createMessage`, or form-mode `elicitation` with `tasks.requests.elicitation.create`, respectively. - -### Bidirectional Task Flow - -MCP Tasks are bidirectional - both server and client can be task executors: - -| Direction | Request Type | Task Executor | Demo Tool | -| ---------------- | ------------------------ | ------------- | ----------------------------------- | -| Client -> Server | `tools/call` | Server | `simulate-research-query` | -| Server -> Client | `sampling/createMessage` | Client | `trigger-sampling-request-async` | -| Server -> Client | `elicitation/create` | Client | `trigger-elicitation-request-async` | - -For client-side tasks: - -1. Server sends request with task metadata (e.g., `params.task.ttl`) -2. Client creates task and returns `CreateTaskResult` with `taskId` -3. Server polls `tasks/get` for status updates -4. When complete, server calls `tasks/result` to retrieve the result +The server does not support MCP Tasks at the moment. Its three task tools +(`simulate-research-query`, `trigger-sampling-request-async` and +`trigger-elicitation-request-async`) were built on the TypeScript SDK's +experimental tasks layer, which SDK v2 removed (SEP-2663). They return on the +`io.modelcontextprotocol/tasks` extension in +[#4852](https://github.com/modelcontextprotocol/servers/issues/4852). diff --git a/src/everything/docs/instructions.md b/src/everything/docs/instructions.md index 4e6a1e0ac4..2abe5f916a 100644 --- a/src/everything/docs/instructions.md +++ b/src/everything/docs/instructions.md @@ -23,8 +23,6 @@ Registered only if your client declared the capability shown. Otherwise they are - `trigger-sampling-request`: `sampling` - `trigger-elicitation-request`: `elicitation.form`, or `elicitation` with no mode - `trigger-url-elicitation`: `elicitation.url` -- `trigger-sampling-request-async`: `sampling` and `tasks.requests.sampling.createMessage` -- `trigger-elicitation-request-async`: form-mode `elicitation` and `tasks.requests.elicitation.create` ## Operational Patterns diff --git a/src/everything/docs/structure.md b/src/everything/docs/structure.md index 24530e03af..9aac31847d 100644 --- a/src/everything/docs/structure.md +++ b/src/everything/docs/structure.md @@ -48,14 +48,11 @@ src/everything │ ├── get-sum.ts │ ├── get-tiny-image.ts │ ├── gzip-file-as-resource.ts - │ ├── simulate-research-query.ts │ ├── toggle-simulated-logging.ts │ ├── toggle-subscriber-updates.ts │ ├── trigger-elicitation-request.ts - │ ├── trigger-elicitation-request-async.ts │ ├── trigger-long-running-operation.ts │ ├── trigger-sampling-request.ts - │ ├── trigger-sampling-request-async.ts │ └── trigger-url-elicitation.ts └── transports ├── in-memory-event-store.ts @@ -82,7 +79,7 @@ src/everything - Package metadata and scripts: - `build`: TypeScript compile to `dist/`, copies `docs/` into `dist/` and marks the compiled entry scripts as executable. - `start:stdio`, `start:sse`, `start:streamableHttp`: Run built transports from `dist/`. -- Declares dependencies on `@modelcontextprotocol/sdk`, `express`, `cors`, `zod`, etc. +- Declares dependencies on the TypeScript SDK v2 packages (`@modelcontextprotocol/server`, `@modelcontextprotocol/node`, `@modelcontextprotocol/server-legacy`), `express`, `cors`, `zod`, etc. ### `docs/` @@ -163,18 +160,12 @@ src/everything - `GZIP_MAX_FETCH_SIZE` (bytes, default 10 MiB) - `GZIP_MAX_FETCH_TIME_MILLIS` (ms, default 30000) - `GZIP_ALLOWED_DOMAINS` (comma-separated allowlist; empty means all domains allowed) -- `simulate-research-query.ts` - - Registers a `simulate-research-query` task-based tool that demonstrates the MCP Tasks feature (SEP-1686). Simulates a multi-stage research operation with progress updates. If the query is marked as ambiguous and the client supports elicitation, it pauses mid-execution to request clarification via `elicitation/create`. Uses `server.experimental.tasks.registerToolTask()` with `execution: { taskSupport: "required" }`. - `trigger-elicitation-request.ts` - Registers a `trigger-elicitation-request` tool that sends an `elicitation/create` request to the client/LLM and returns the elicitation result. - `trigger-url-elicitation.ts` - Registers a `trigger-url-elicitation` tool that either sends an out-of-band URL-mode `elicitation/create` request (`mode: "url"`) including an `elicitationId` (request path) or throws `UrlElicitationRequiredError` (`-32042`) for client-handled URL elicitation (error path). On the error path the carried prerequisite elicitation points at a different URL than the failing one (`https://modelcontextprotocol.io`), and when the client satisfies it and retries the same call, the retry ignores `errorPath` and proceeds via the request path — so the client does not loop on the same error. -- `trigger-elicitation-request-async.ts` - - Registers a `trigger-elicitation-request-async` tool that demonstrates bidirectional MCP tasks for elicitation. Sends an elicitation request with task metadata, then polls the client's `tasks/get` endpoint for completion status before fetching the final result. - `trigger-sampling-request.ts` - Registers a `trigger-sampling-request` tool that sends a `sampling/createMessage` request to the client/LLM and returns the sampling result. -- `trigger-sampling-request-async.ts` - - Registers a `trigger-sampling-request-async` tool that demonstrates bidirectional MCP tasks for sampling. Sends a sampling request with task metadata, then polls the client's `tasks/get` endpoint for completion status before fetching the final result. - `get-structured-content.ts` - Registers a `get-structured-content` tool that demonstrates structuredContent block responses. - `get-sum.ts` diff --git a/src/everything/package.json b/src/everything/package.json index e65007cdc4..51ce62ead7 100644 --- a/src/everything/package.json +++ b/src/everything/package.json @@ -12,6 +12,9 @@ "url": "https://github.com/modelcontextprotocol/servers.git" }, "type": "module", + "engines": { + "node": ">=20" + }, "bin": { "mcp-server-everything": "dist/index.js" }, @@ -34,13 +37,17 @@ "validate": "npm run format:check && npm run lint && npm run typecheck && npm run build && npm run test" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.30.0", + "@modelcontextprotocol/node": "^2.1.1", + "@modelcontextprotocol/server": "^2.3.1", + "@modelcontextprotocol/server-legacy": "^2.3.1", "cors": "^2.8.5", "express": "^5.2.1", "jszip": "^3.10.1", - "zod": "^4.0.0" + "zod": "^4.2.0" }, "devDependencies": { + "@modelcontextprotocol/client": "^2.3.1", + "@modelcontextprotocol/core": "^2.3.1", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@vitest/coverage-v8": "^4.1.8", diff --git a/src/everything/prompts/args.ts b/src/everything/prompts/args.ts index bbd3741799..d3113d3687 100644 --- a/src/everything/prompts/args.ts +++ b/src/everything/prompts/args.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "@modelcontextprotocol/server"; /** * Register a prompt with arguments @@ -10,10 +10,10 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; */ export const registerArgumentsPrompt = (server: McpServer) => { // Prompt arguments - const promptArgsSchema = { + const promptArgsSchema = z.object({ city: z.string().describe("Name of the city"), state: z.string().describe("Name of the state").optional(), - }; + }); // Register the prompt server.registerPrompt( diff --git a/src/everything/prompts/completions.ts b/src/everything/prompts/completions.ts index f7c76d6b8e..aff3c77f77 100644 --- a/src/everything/prompts/completions.ts +++ b/src/everything/prompts/completions.ts @@ -1,6 +1,5 @@ import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { completable } from "@modelcontextprotocol/sdk/server/completable.js"; +import { McpServer, completable } from "@modelcontextprotocol/server"; /** * Register a prompt with completable arguments @@ -12,7 +11,7 @@ import { completable } from "@modelcontextprotocol/sdk/server/completable.js"; */ export const registerPromptWithCompletions = (server: McpServer) => { // Prompt arguments - const promptArgsSchema = { + const promptArgsSchema = z.object({ department: completable( z.string().describe("Choose the department."), (value) => { @@ -39,7 +38,7 @@ export const registerPromptWithCompletions = (server: McpServer) => { return []; }, ), - }; + }); // Register the prompt server.registerPrompt( diff --git a/src/everything/prompts/index.ts b/src/everything/prompts/index.ts index 6efa7b7297..22d6a22baf 100644 --- a/src/everything/prompts/index.ts +++ b/src/everything/prompts/index.ts @@ -1,4 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "@modelcontextprotocol/server"; import { registerSimplePrompt } from "./simple.js"; import { registerArgumentsPrompt } from "./args.js"; import { registerPromptWithCompletions } from "./completions.js"; diff --git a/src/everything/prompts/resource.ts b/src/everything/prompts/resource.ts index e825941673..5b96aba194 100644 --- a/src/everything/prompts/resource.ts +++ b/src/everything/prompts/resource.ts @@ -1,4 +1,5 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "@modelcontextprotocol/server"; +import { z } from "zod"; import { resourceTypeCompleter, resourceIdForPromptCompleter, @@ -22,10 +23,10 @@ import { */ export const registerEmbeddedResourcePrompt = (server: McpServer) => { // Prompt arguments - const promptArgsSchema = { + const promptArgsSchema = z.object({ resourceType: resourceTypeCompleter, resourceId: resourceIdForPromptCompleter, - }; + }); // Register the prompt server.registerPrompt( diff --git a/src/everything/prompts/simple.ts b/src/everything/prompts/simple.ts index e194a8361f..13d20d6667 100644 --- a/src/everything/prompts/simple.ts +++ b/src/everything/prompts/simple.ts @@ -1,4 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "@modelcontextprotocol/server"; /** * Register a simple prompt with no arguments diff --git a/src/everything/resources/files.ts b/src/everything/resources/files.ts index abd6dac7f8..8e051ef362 100644 --- a/src/everything/resources/files.ts +++ b/src/everything/resources/files.ts @@ -1,4 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "@modelcontextprotocol/server"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; import { readdirSync, readFileSync, statSync } from "fs"; diff --git a/src/everything/resources/index.ts b/src/everything/resources/index.ts index 30c6f7dcf8..b7ec741376 100644 --- a/src/everything/resources/index.ts +++ b/src/everything/resources/index.ts @@ -1,4 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "@modelcontextprotocol/server"; import { registerResourceTemplates } from "./templates.js"; import { registerFileResources } from "./files.js"; import { fileURLToPath } from "url"; diff --git a/src/everything/resources/session.ts b/src/everything/resources/session.ts index db0e02c259..d3c7dbc05c 100644 --- a/src/everything/resources/session.ts +++ b/src/everything/resources/session.ts @@ -1,8 +1,9 @@ import { McpServer, RegisteredResource, -} from "@modelcontextprotocol/sdk/server/mcp.js"; -import { Resource, ResourceLink } from "@modelcontextprotocol/sdk/types.js"; + Resource, + ResourceLink, +} from "@modelcontextprotocol/server"; /** * Tracks registered session resources per server, by URI, to allow updating/removing on diff --git a/src/everything/resources/subscriptions.ts b/src/everything/resources/subscriptions.ts index 295e186e4a..510254ed00 100644 --- a/src/everything/resources/subscriptions.ts +++ b/src/everything/resources/subscriptions.ts @@ -1,8 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { - SubscribeRequestSchema, - UnsubscribeRequestSchema, -} from "@modelcontextprotocol/sdk/types.js"; +import { McpServer } from "@modelcontextprotocol/server"; // Track subscriber session id lists by URI const subscriptions: Map> = new Map< @@ -36,13 +32,13 @@ const subsUpdateIntervals: Map = export const setSubscriptionHandlers = (server: McpServer) => { // Set the subscription handler server.server.setRequestHandler( - SubscribeRequestSchema, - async (request, extra) => { + "resources/subscribe", + async (request, ctx) => { // Get the URI to subscribe to const { uri } = request.params; // Get the session id (can be undefined for stdio) - const sessionId = extra.sessionId as string; + const sessionId = ctx.sessionId as string; // Acknowledge the subscribe request await server.sendLoggingMessage( @@ -67,13 +63,13 @@ export const setSubscriptionHandlers = (server: McpServer) => { // Set the unsubscription handler server.server.setRequestHandler( - UnsubscribeRequestSchema, - async (request, extra) => { + "resources/unsubscribe", + async (request, ctx) => { // Get the URI to subscribe to const { uri } = request.params; // Get the session id (can be undefined for stdio) - const sessionId = extra.sessionId as string; + const sessionId = ctx.sessionId as string; // Acknowledge the subscribe request await server.sendLoggingMessage( diff --git a/src/everything/resources/templates.ts b/src/everything/resources/templates.ts index ea1c484e8e..7a97aa0a48 100644 --- a/src/everything/resources/templates.ts +++ b/src/everything/resources/templates.ts @@ -3,8 +3,8 @@ import { CompleteResourceTemplateCallback, McpServer, ResourceTemplate, -} from "@modelcontextprotocol/sdk/server/mcp.js"; -import { completable } from "@modelcontextprotocol/sdk/server/completable.js"; + completable, +} from "@modelcontextprotocol/server"; // Resource types export const RESOURCE_TYPE_TEXT = "Text" as const; diff --git a/src/everything/server/index.ts b/src/everything/server/index.ts index 658077e3be..fcc0526aea 100644 --- a/src/everything/server/index.ts +++ b/src/everything/server/index.ts @@ -1,8 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { - InMemoryTaskStore, - InMemoryTaskMessageQueue, -} from "@modelcontextprotocol/sdk/experimental/tasks"; +import { McpServer } from "@modelcontextprotocol/server"; import { setSubscriptionHandlers, stopSimulatedResourceUpdates, @@ -38,10 +34,6 @@ export const createServer: () => ServerFactoryResponse = () => { // Read the server instructions const instructions = readInstructions(); - // Create task store and message queue for task support - const taskStore = new InMemoryTaskStore(); - const taskMessageQueue = new InMemoryTaskMessageQueue(); - let initializeTimeout: NodeJS.Timeout | null = null; // Create the server @@ -64,19 +56,11 @@ export const createServer: () => ServerFactoryResponse = () => { listChanged: true, }, logging: {}, - tasks: { - list: {}, - cancel: {}, - requests: { - tools: { - call: {}, - }, - }, - }, + // No `tasks` capability: SDK v2 removed the experimental tasks layer + // (SEP-2663). Part 5 (#4852) brings tasks back on the + // `io.modelcontextprotocol/tasks` extension. }, instructions, - taskStore, - taskMessageQueue, }, ); @@ -113,8 +97,6 @@ export const createServer: () => ServerFactoryResponse = () => { stopSimulatedLogging(sessionId); stopSimulatedResourceUpdates(sessionId); removeSubscriber(sessionId); - // Clean up task store timers - taskStore.cleanup(); if (initializeTimeout) clearTimeout(initializeTimeout); }, } satisfies ServerFactoryResponse; diff --git a/src/everything/server/logging.ts b/src/everything/server/logging.ts index 75d801c0f0..3415912982 100644 --- a/src/everything/server/logging.ts +++ b/src/everything/server/logging.ts @@ -1,5 +1,4 @@ -import { LoggingLevel } from "@modelcontextprotocol/sdk/types.js"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { LoggingLevel, McpServer } from "@modelcontextprotocol/server"; // Map session ID to the interval for sending logging messages to the client const logsUpdateIntervals: Map = diff --git a/src/everything/server/roots.ts b/src/everything/server/roots.ts index 83c616c35c..cd4824c030 100644 --- a/src/everything/server/roots.ts +++ b/src/everything/server/roots.ts @@ -1,8 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { - Root, - RootsListChangedNotificationSchema, -} from "@modelcontextprotocol/sdk/types.js"; +import { McpServer, Root } from "@modelcontextprotocol/server"; // Track roots by session id export const roots: Map = new Map< @@ -66,7 +62,7 @@ export const syncRoots = async (server: McpServer, sessionId?: string) => { } catch (error) { console.error( `Failed to request roots from client ${sessionId}: ${ - /* v8 ignore start -- the SDK always rejects listRoots() with an Error (McpError) */ + /* v8 ignore start -- the SDK always rejects listRoots() with an Error (ProtocolError or SdkError) */ error instanceof Error ? error.message : String(error) /* v8 ignore stop */ }`, @@ -79,7 +75,7 @@ export const syncRoots = async (server: McpServer, sessionId?: string) => { if (!roots.has(sessionId)) { // Set the list changed notification handler server.server.setNotificationHandler( - RootsListChangedNotificationSchema, + "notifications/roots/list_changed", requestRoots, ); diff --git a/src/everything/tools/echo.ts b/src/everything/tools/echo.ts index 0658e83195..8ae7943ad5 100644 --- a/src/everything/tools/echo.ts +++ b/src/everything/tools/echo.ts @@ -1,5 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { McpServer, CallToolResult } from "@modelcontextprotocol/server"; import { z } from "zod"; // Tool input schema diff --git a/src/everything/tools/get-annotated-message.ts b/src/everything/tools/get-annotated-message.ts index eaff07b196..b339998286 100644 --- a/src/everything/tools/get-annotated-message.ts +++ b/src/everything/tools/get-annotated-message.ts @@ -1,5 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { McpServer, CallToolResult } from "@modelcontextprotocol/server"; import { z } from "zod"; import { MCP_TINY_IMAGE } from "./get-tiny-image.js"; diff --git a/src/everything/tools/get-env.ts b/src/everything/tools/get-env.ts index cd67ab6720..4d16a2a954 100644 --- a/src/everything/tools/get-env.ts +++ b/src/everything/tools/get-env.ts @@ -1,5 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { McpServer, CallToolResult } from "@modelcontextprotocol/server"; // Tool configuration const name = "get-env"; diff --git a/src/everything/tools/get-resource-links.ts b/src/everything/tools/get-resource-links.ts index 7684cb64a5..e35bf83631 100644 --- a/src/everything/tools/get-resource-links.ts +++ b/src/everything/tools/get-resource-links.ts @@ -1,6 +1,5 @@ import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { McpServer, CallToolResult } from "@modelcontextprotocol/server"; import { textResource, textResourceUri, diff --git a/src/everything/tools/get-resource-reference.ts b/src/everything/tools/get-resource-reference.ts index 5300aaf34a..f88cb5d887 100644 --- a/src/everything/tools/get-resource-reference.ts +++ b/src/everything/tools/get-resource-reference.ts @@ -1,6 +1,5 @@ import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { McpServer, CallToolResult } from "@modelcontextprotocol/server"; import { textResource, textResourceUri, diff --git a/src/everything/tools/get-roots-list.ts b/src/everything/tools/get-roots-list.ts index d7ad7fc46d..a18d208143 100644 --- a/src/everything/tools/get-roots-list.ts +++ b/src/everything/tools/get-roots-list.ts @@ -1,5 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { McpServer, CallToolResult } from "@modelcontextprotocol/server"; import { syncRoots } from "../server/roots.js"; // Tool configuration @@ -44,9 +43,9 @@ export const registerGetRootsListTool = (server: McpServer) => { server.registerTool( name, config, - async (args, extra): Promise => { + async (args, ctx): Promise => { // Get the current rootsFetch the current roots list from the client if need be - const currentRoots = await syncRoots(server, extra.sessionId); + const currentRoots = await syncRoots(server, ctx.sessionId); // Respond if client supports roots but doesn't have any configured if ( diff --git a/src/everything/tools/get-structured-content.ts b/src/everything/tools/get-structured-content.ts index f9bde5c9e4..11f323e14b 100644 --- a/src/everything/tools/get-structured-content.ts +++ b/src/everything/tools/get-structured-content.ts @@ -1,9 +1,9 @@ import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { + McpServer, CallToolResult, ContentBlock, -} from "@modelcontextprotocol/sdk/types.js"; +} from "@modelcontextprotocol/server"; // Tool input schema const GetStructuredContentInputSchema = { diff --git a/src/everything/tools/get-sum.ts b/src/everything/tools/get-sum.ts index 470293bf6f..d8bd04234e 100644 --- a/src/everything/tools/get-sum.ts +++ b/src/everything/tools/get-sum.ts @@ -1,6 +1,5 @@ import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { McpServer, CallToolResult } from "@modelcontextprotocol/server"; // Tool input schema const GetSumSchema = z.object({ diff --git a/src/everything/tools/get-tiny-image.ts b/src/everything/tools/get-tiny-image.ts index f0f2cf517e..cb931cf941 100644 --- a/src/everything/tools/get-tiny-image.ts +++ b/src/everything/tools/get-tiny-image.ts @@ -1,5 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { McpServer, CallToolResult } from "@modelcontextprotocol/server"; // A tiny encoded MCP logo image export const MCP_TINY_IMAGE = diff --git a/src/everything/tools/gzip-file-as-resource.ts b/src/everything/tools/gzip-file-as-resource.ts index c0cc0c56f8..99c9da1bb9 100644 --- a/src/everything/tools/gzip-file-as-resource.ts +++ b/src/everything/tools/gzip-file-as-resource.ts @@ -1,6 +1,9 @@ import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult, Resource } from "@modelcontextprotocol/sdk/types.js"; +import { + McpServer, + CallToolResult, + Resource, +} from "@modelcontextprotocol/server"; import { gzipSync } from "node:zlib"; import { getSessionResourceURI, diff --git a/src/everything/tools/index.ts b/src/everything/tools/index.ts index 0272126eea..3e71f17740 100644 --- a/src/everything/tools/index.ts +++ b/src/everything/tools/index.ts @@ -1,4 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "@modelcontextprotocol/server"; import { registerGetAnnotatedMessageTool } from "./get-annotated-message.js"; import { registerEchoTool } from "./echo.js"; import { registerGetEnvTool } from "./get-env.js"; @@ -14,9 +14,6 @@ import { registerToggleSubscriberUpdatesTool } from "./toggle-subscriber-updates import { registerTriggerElicitationRequestTool } from "./trigger-elicitation-request.js"; import { registerTriggerLongRunningOperationTool } from "./trigger-long-running-operation.js"; import { registerTriggerSamplingRequestTool } from "./trigger-sampling-request.js"; -import { registerTriggerSamplingRequestAsyncTool } from "./trigger-sampling-request-async.js"; -import { registerTriggerElicitationRequestAsyncTool } from "./trigger-elicitation-request-async.js"; -import { registerSimulateResearchQueryTool } from "./simulate-research-query.js"; import { registerTriggerUrlElicitationTool } from "./trigger-url-elicitation.js"; /** @@ -47,9 +44,8 @@ export const registerConditionalTools = (server: McpServer) => { registerTriggerElicitationRequestTool(server); registerTriggerUrlElicitationTool(server); registerTriggerSamplingRequestTool(server); - // Task-based research tool (uses experimental tasks API) - registerSimulateResearchQueryTool(server); - // Bidirectional task tools - server sends requests that client executes as tasks - registerTriggerSamplingRequestAsyncTool(server); - registerTriggerElicitationRequestAsyncTool(server); + // The task tools (simulate-research-query, trigger-sampling-request-async, + // trigger-elicitation-request-async) were removed with SDK v2, which drops + // the experimental tasks layer (SEP-2663). Part 5 (#4852) re-implements them + // on the `io.modelcontextprotocol/tasks` extension. }; diff --git a/src/everything/tools/simulate-research-query.ts b/src/everything/tools/simulate-research-query.ts deleted file mode 100644 index 7055d3db5e..0000000000 --- a/src/everything/tools/simulate-research-query.ts +++ /dev/null @@ -1,348 +0,0 @@ -import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { - CallToolResult, - GetTaskResult, - Task, - ElicitResult, - ElicitResultSchema, -} from "@modelcontextprotocol/sdk/types.js"; -import { CreateTaskResult } from "@modelcontextprotocol/sdk/experimental/tasks"; - -// Tool input schema -const SimulateResearchQuerySchema = z.object({ - topic: z.string().describe("The research topic to investigate"), - ambiguous: z - .boolean() - .default(false) - .describe( - "Simulate an ambiguous query that requires clarification (triggers input_required status)", - ), -}); - -// Research stages -const STAGES = [ - "Gathering sources", - "Analyzing content", - "Synthesizing findings", - "Generating report", -]; - -// Duration per stage in milliseconds -const STAGE_DURATION = 1000; - -// Internal state for tracking research tasks -interface ResearchState { - topic: string; - ambiguous: boolean; - currentStage: number; - clarification?: string; - completed: boolean; - result?: CallToolResult; -} - -// Map to store research state per task -const researchStates = new Map(); - -/** - * Runs the background research process. - * Updates task status as it progresses through stages. - * If clarification is needed, sends elicitation via sendRequest with relatedTask, - * which queues the request in the task message queue. The SDK delivers it through - * the tasks/result stream when the client calls tasks/result (per spec input_required flow). - * This works on all transports (STDIO, SSE, Streamable HTTP). - */ -async function runResearchProcess( - taskId: string, - args: z.infer, - taskStore: { - updateTaskStatus: ( - taskId: string, - status: Task["status"], - message?: string, - ) => Promise; - storeTaskResult: ( - taskId: string, - status: "completed" | "failed", - result: CallToolResult, - ) => Promise; - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sendRequest: any, -): Promise { - const state = researchStates.get(taskId); - /* v8 ignore next -- createTask stores the state immediately before starting this process */ - if (!state) return; - - // Process each stage - for (let i = state.currentStage; i < STAGES.length; i++) { - state.currentStage = i; - - // Check if task was cancelled externally - if (state.completed) return; - - // Update status message for current stage - await taskStore.updateTaskStatus(taskId, "working", `${STAGES[i]}...`); - - // At synthesis stage (index 2), check if clarification is needed - if (i === 2 && state.ambiguous && !state.clarification) { - // Update status to show we're requesting input (spec SHOULD) - await taskStore.updateTaskStatus( - taskId, - "input_required", - `Found multiple interpretations for "${state.topic}". Requesting clarification...`, - ); - - try { - // relatedTask queues elicitation via task message queue → delivered through tasks/result on all transports - const elicitResult: ElicitResult = await sendRequest( - { - method: "elicitation/create", - params: { - message: `The research query "${state.topic}" could have multiple interpretations. Please clarify what you're looking for:`, - requestedSchema: { - type: "object", - properties: { - interpretation: { - type: "string", - title: "Clarification", - description: - "Which interpretation of the topic do you mean?", - oneOf: getInterpretationsForTopic(state.topic), - }, - }, - required: ["interpretation"], - }, - }, - }, - ElicitResultSchema, - { relatedTask: { taskId } }, - ); - - // Process elicitation response - if (elicitResult.action === "accept" && elicitResult.content) { - state.clarification = - (elicitResult.content as { interpretation?: string }) - .interpretation || "User accepted without selection"; - } else if (elicitResult.action === "decline") { - state.clarification = "User declined - using default interpretation"; - } else { - state.clarification = "User cancelled - using default interpretation"; - } - } catch (error) { - // Elicitation failed - use default interpretation and continue - console.warn( - `Elicitation failed for task ${taskId}:`, - error instanceof Error ? error.message : String(error), - ); - state.clarification = "technical (default - elicitation unavailable)"; - } - - // Resume with working status (spec SHOULD) - await taskStore.updateTaskStatus( - taskId, - "working", - `Continuing with interpretation: "${state.clarification}"...`, - ); - - // Continue processing (no return - just keep going through the loop) - } - - // Simulate work for this stage - await new Promise((resolve) => setTimeout(resolve, STAGE_DURATION)); - } - - // All stages complete - generate result - state.completed = true; - const result = generateResearchReport(state); - state.result = result; - - await taskStore.storeTaskResult(taskId, "completed", result); -} - -/** - * Generates the final research report with educational content about tasks. - */ -function generateResearchReport(state: ResearchState): CallToolResult { - const topic = state.clarification - ? `${state.topic} (${state.clarification})` - : state.topic; - - const report = `# Research Report: ${topic} - -## Research Parameters -- **Topic**: ${state.topic} -${state.clarification ? `- **Clarification**: ${state.clarification}` : ""} - -## Synthesis -This research query was processed through ${STAGES.length} stages: -${STAGES.map((s, i) => `- Stage ${i + 1}: ${s} ✓`).join("\n")} - ---- - -## About This Demo (SEP-1686: Tasks) - -This tool demonstrates MCP's task-based execution pattern for long-running operations: - -**Task Lifecycle Demonstrated:** -1. \`tools/call\` with \`task\` parameter → Server returns \`CreateTaskResult\` (not the final result) -2. Client polls \`tasks/get\` → Server returns current status and \`statusMessage\` -3. Status progressed: \`working\` → ${ - state.clarification ? `\`input_required\` → \`working\` → ` : "" - }\`completed\` -4. Client calls \`tasks/result\` → Server returns this final result - -${ - state.clarification - ? `**Elicitation Flow:** -When the query was ambiguous, the server sent an \`elicitation/create\` request -to the client. The task status changed to \`input_required\` while awaiting user input. -${ - state.clarification.includes("unavailable") - ? `**Note:** Elicitation failed and a default interpretation was used.` - : `After receiving clarification ("${state.clarification}"), the task resumed processing and completed.` -} -` - : "" -} -**Key Concepts:** -- Tasks enable "call now, fetch later" patterns -- \`statusMessage\` provides human-readable progress updates -- Tasks have TTL (time-to-live) for automatic cleanup -- \`pollInterval\` suggests how often to check status -- Elicitation requests use \`relatedTask\` to queue via tasks/result (works on all transports) - -*This is a simulated research report from the Everything MCP Server.* -`; - - return { - content: [ - { - type: "text", - text: report, - }, - ], - }; -} - -/** - * Registers the 'simulate-research-query' tool as a task-based tool. - * - * This tool demonstrates the MCP Tasks feature (SEP-1686) with a real-world scenario: - * a research tool that gathers and synthesizes information from multiple sources. - * If the query is ambiguous, it pauses to ask for clarification before completing. - * - * @param {McpServer} server - The McpServer instance where the tool will be registered. - */ -export const registerSimulateResearchQueryTool = (server: McpServer) => { - // Check if client supports elicitation (needed for input_required flow) - const clientCapabilities = server.server.getClientCapabilities() || {}; - const clientSupportsElicitation: boolean = - clientCapabilities.elicitation !== undefined; - - server.experimental.tasks.registerToolTask( - "simulate-research-query", - { - title: "Simulate Research Query", - description: - "Simulates a deep research operation that gathers, analyzes, and synthesizes information. " + - "Demonstrates MCP task-based operations with progress through multiple stages. " + - "If 'ambiguous' is true and client supports elicitation, sends an elicitation request for clarification.", - inputSchema: SimulateResearchQuerySchema, - execution: { taskSupport: "required" }, - annotations: { - readOnlyHint: false, - destructiveHint: false, - idempotentHint: false, - openWorldHint: false, - }, - }, - { - /** - * Creates a new research task and starts background processing. - */ - createTask: async (args, extra): Promise => { - const validatedArgs = SimulateResearchQuerySchema.parse(args); - - // Create the task in the store - const task = await extra.taskStore.createTask({ - ttl: 300000, // 5 minutes - pollInterval: 1000, - }); - - // Initialize research state - const state: ResearchState = { - topic: validatedArgs.topic, - ambiguous: validatedArgs.ambiguous && clientSupportsElicitation, - currentStage: 0, - completed: false, - }; - researchStates.set(task.taskId, state); - - // Start background research (don't await - runs asynchronously) - // Pass sendRequest for elicitation (queued via task message queue, works on all transports) - runResearchProcess( - task.taskId, - validatedArgs, - extra.taskStore, - extra.sendRequest, - ).catch((error) => { - console.error(`Research task ${task.taskId} failed:`, error); - extra.taskStore - .updateTaskStatus(task.taskId, "failed", String(error)) - .catch(console.error); - }); - - return { task }; - }, - - /* v8 ignore start -- SDK 1.x never calls a tool task's getTask/getTaskResult: tasks/get and tasks/result are answered from the task store (Protocol), so these handlers are unreachable over the wire */ - /** - * Returns the current status of the research task. - */ - getTask: async (args, extra): Promise => { - return await extra.taskStore.getTask(extra.taskId); - }, - - /** - * Returns the task result. - * Elicitation is now handled directly in the background process. - */ - getTaskResult: async (args, extra): Promise => { - // Return the stored result - const result = await extra.taskStore.getTaskResult(extra.taskId); - - // Clean up state - researchStates.delete(extra.taskId); - - return result as CallToolResult; - }, - /* v8 ignore stop */ - }, - ); -}; - -/** - * Returns contextual interpretation options based on the topic. - */ -function getInterpretationsForTopic( - topic: string, -): Array<{ const: string; title: string }> { - const lowerTopic = topic.toLowerCase(); - - // Example: contextual interpretations for "python" - if (lowerTopic.includes("python")) { - return [ - { const: "programming", title: "Python programming language" }, - { const: "snake", title: "Python snake species" }, - { const: "comedy", title: "Monty Python comedy group" }, - ]; - } - - // Default generic interpretations - return [ - { const: "technical", title: "Technical/scientific perspective" }, - { const: "historical", title: "Historical perspective" }, - { const: "current", title: "Current events/news perspective" }, - ]; -} diff --git a/src/everything/tools/toggle-simulated-logging.ts b/src/everything/tools/toggle-simulated-logging.ts index 7551da0967..a847e6459a 100644 --- a/src/everything/tools/toggle-simulated-logging.ts +++ b/src/everything/tools/toggle-simulated-logging.ts @@ -1,5 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { McpServer, CallToolResult } from "@modelcontextprotocol/server"; import { beginSimulatedLogging, stopSimulatedLogging, @@ -38,8 +37,8 @@ export const registerToggleSimulatedLoggingTool = (server: McpServer) => { server.registerTool( name, config, - async (_args, extra): Promise => { - const sessionId = extra?.sessionId; + async (_args, ctx): Promise => { + const sessionId = ctx?.sessionId; let response: string; if (clients.has(sessionId)) { diff --git a/src/everything/tools/toggle-subscriber-updates.ts b/src/everything/tools/toggle-subscriber-updates.ts index 4b7f3b1c1c..2b53fded57 100644 --- a/src/everything/tools/toggle-subscriber-updates.ts +++ b/src/everything/tools/toggle-subscriber-updates.ts @@ -1,5 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { McpServer, CallToolResult } from "@modelcontextprotocol/server"; import { beginSimulatedResourceUpdates, stopSimulatedResourceUpdates, @@ -41,8 +40,8 @@ export const registerToggleSubscriberUpdatesTool = (server: McpServer) => { server.registerTool( name, config, - async (_args, extra): Promise => { - const sessionId = extra?.sessionId; + async (_args, ctx): Promise => { + const sessionId = ctx?.sessionId; let response: string; if (clients.has(sessionId)) { diff --git a/src/everything/tools/trigger-elicitation-request-async.ts b/src/everything/tools/trigger-elicitation-request-async.ts deleted file mode 100644 index 9578007d9f..0000000000 --- a/src/everything/tools/trigger-elicitation-request-async.ts +++ /dev/null @@ -1,295 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { z } from "zod"; - -// Tool configuration -const name = "trigger-elicitation-request-async"; -const config = { - title: "Trigger Async Elicitation Request Tool", - description: - "Trigger an async elicitation request that the CLIENT executes as a background task. " + - "Demonstrates bidirectional MCP tasks where the server sends an elicitation request and " + - "the client handles user input asynchronously, allowing the server to poll for completion.", - inputSchema: {}, - annotations: { - readOnlyHint: false, - destructiveHint: false, - idempotentHint: false, - openWorldHint: false, - }, -}; - -// Poll interval in milliseconds -const POLL_INTERVAL = 1000; - -// Time-to-live requested for the client's task (10 minutes for user input) -const TASK_TTL = 600000; - -// Stop polling this long before the TTL runs out, so the last tasks/get -// reaches the client while it still holds the task -const TTL_SAFETY_MARGIN = 5000; - -/** - * Registers the 'trigger-elicitation-request-async' tool. - * - * This tool demonstrates bidirectional MCP tasks for elicitation: - * - Server sends elicitation request to client with task metadata - * - Client creates a task and returns CreateTaskResult - * - Client prompts user for input (task status: input_required) - * - Server polls client's tasks/get endpoint for status - * - Server fetches final result from client's tasks/result endpoint - * - * @param {McpServer} server - The McpServer instance where the tool will be registered. - */ -export const registerTriggerElicitationRequestAsyncTool = ( - server: McpServer, -) => { - // Check client capabilities - const clientCapabilities = server.server.getClientCapabilities() || {}; - - // Client must support form-mode elicitation AND tasks.requests.elicitation. - // The request is form mode, so a URL-only client cannot answer it (#4985): - // form mode is `elicitation.form`, or `elicitation` with neither mode. - const elicitation = clientCapabilities.elicitation; - const clientSupportsElicitation = - elicitation !== undefined && - (elicitation.form !== undefined || elicitation.url === undefined); - const clientTasksCapability = clientCapabilities.tasks as - | { - requests?: { elicitation?: { create?: object } }; - } - | undefined; - const clientSupportsAsyncElicitation = - clientTasksCapability?.requests?.elicitation?.create !== undefined; - - if (clientSupportsElicitation && clientSupportsAsyncElicitation) { - server.registerTool( - name, - config, - async (args, extra): Promise => { - // Polling must end before the client may expire the task. The client - // starts the TTL when it creates the task, after this point, so a - // deadline measured from here is conservative. performance.now() is - // monotonic, so a wall-clock adjustment cannot stretch the deadline. - const pollDeadline = performance.now() + TASK_TTL - TTL_SAFETY_MARGIN; - - // Create the elicitation request WITH task metadata - // Using z.any() schema to avoid complex type matching with _meta - const request = { - method: "elicitation/create" as const, - params: { - task: { - ttl: TASK_TTL, // 10 minutes (user input may take a while) - }, - message: - "Please provide inputs for the following fields (async task demo):", - requestedSchema: { - type: "object" as const, - properties: { - name: { - title: "Your Name", - type: "string" as const, - description: "Your full name", - }, - favoriteColor: { - title: "Favorite Color", - type: "string" as const, - description: "What is your favorite color?", - enum: ["Red", "Blue", "Green", "Yellow", "Purple"], - }, - agreeToTerms: { - title: "Terms Agreement", - type: "boolean" as const, - description: "Do you agree to the terms and conditions?", - }, - }, - required: ["name"], - }, - }, - }; - - // Send the elicitation request - // Client may return either: - // - ElicitResult (synchronous execution) - // - CreateTaskResult (task-based execution with { task } object) - const elicitResponse = await extra.sendRequest( - request as Parameters[0], - z.union([ - // CreateTaskResult - client created a task - z.object({ - task: z.object({ - taskId: z.string(), - status: z.string(), - pollInterval: z.number().optional(), - statusMessage: z.string().optional(), - }), - }), - // ElicitResult - synchronous execution - z.object({ - action: z.string(), - content: z.any().optional(), - }), - ]), - ); - - // Check if client returned CreateTaskResult (has task object) - const isTaskResult = "task" in elicitResponse && elicitResponse.task; - if (!isTaskResult) { - // Client executed synchronously - return the direct response - return { - content: [ - { - type: "text", - text: `[SYNC] Client executed synchronously:\n${JSON.stringify( - elicitResponse, - null, - 2, - )}`, - }, - ], - }; - } - - const taskId = elicitResponse.task.taskId; - const statusMessages: string[] = []; - statusMessages.push(`Task created: ${taskId}`); - - // Poll for task completion - let attempts = 0; - let taskStatus = elicitResponse.task.status; - // A task that is already finished when created is never polled, so - // its status message has to come from the CreateTaskResult - let taskStatusMessage = elicitResponse.task.statusMessage; - let timedOut = false; - - while ( - taskStatus !== "completed" && - taskStatus !== "failed" && - taskStatus !== "cancelled" - ) { - // Wait before polling - await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL)); - - // Give up rather than poll past the TTL the task was created with. - // Checked after the wait, so a timer that fires late cannot slip a - // poll in after the deadline. - if (performance.now() > pollDeadline) { - timedOut = true; - break; - } - attempts++; - - // Get task status from client - const pollResult = await extra.sendRequest( - { - method: "tasks/get", - params: { taskId }, - }, - z.looseObject({ - status: z.string(), - statusMessage: z.string().optional(), - }), - ); - - taskStatus = pollResult.status; - taskStatusMessage = pollResult.statusMessage; - - // Only log status changes or every 10 polls to avoid spam - if ( - attempts === 1 || - attempts % 10 === 0 || - taskStatus !== "input_required" - ) { - statusMessages.push( - `Poll ${attempts}: ${taskStatus}${ - taskStatusMessage ? ` - ${taskStatusMessage}` : "" - }`, - ); - } - } - - // Check for timeout - if (timedOut) { - return { - content: [ - { - type: "text", - text: `[TIMEOUT] Task timed out after ${attempts} poll attempts, before its ${ - TASK_TTL / 60000 - }-minute TTL expired\n\nProgress:\n${statusMessages.join( - "\n", - )}`, - }, - ], - }; - } - - // Check for failure/cancellation - if (taskStatus === "failed" || taskStatus === "cancelled") { - return { - content: [ - { - type: "text", - text: `[${taskStatus.toUpperCase()}] ${ - taskStatusMessage || "No message" - }\n\nProgress:\n${statusMessages.join("\n")}`, - }, - ], - }; - } - - // Fetch the final result - const result = await extra.sendRequest( - { - method: "tasks/result", - params: { taskId }, - }, - z.any(), - ); - - // Format the elicitation result - const content: CallToolResult["content"] = []; - - if (result.action === "accept" && result.content) { - content.push({ - type: "text", - text: `[COMPLETED] User provided the requested information!`, - }); - - const userData = result.content as Record; - const lines = []; - if (userData.name) lines.push(`- Name: ${userData.name}`); - if (userData.favoriteColor) - lines.push(`- Favorite Color: ${userData.favoriteColor}`); - if (userData.agreeToTerms !== undefined) - lines.push(`- Agreed to terms: ${userData.agreeToTerms}`); - - content.push({ - type: "text", - text: `User inputs:\n${lines.join("\n")}`, - }); - } else if (result.action === "decline") { - content.push({ - type: "text", - text: `[DECLINED] User declined to provide the requested information.`, - }); - } else if (result.action === "cancel") { - content.push({ - type: "text", - text: `[CANCELLED] User cancelled the elicitation dialog.`, - }); - } - - // Include progress and raw result for debugging - content.push({ - type: "text", - text: `\nProgress:\n${statusMessages.join( - "\n", - )}\n\nRaw result: ${JSON.stringify(result, null, 2)}`, - }); - - return { content }; - }, - ); - } -}; diff --git a/src/everything/tools/trigger-elicitation-request.ts b/src/everything/tools/trigger-elicitation-request.ts index 23854db1b9..2df4500d93 100644 --- a/src/everything/tools/trigger-elicitation-request.ts +++ b/src/everything/tools/trigger-elicitation-request.ts @@ -1,8 +1,4 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { - ElicitResultSchema, - CallToolResult, -} from "@modelcontextprotocol/sdk/types.js"; +import { McpServer, CallToolResult } from "@modelcontextprotocol/server"; // Tool configuration const name = "trigger-elicitation-request"; @@ -53,8 +49,8 @@ export const registerTriggerElicitationRequestTool = (server: McpServer) => { server.registerTool( name, config, - async (args, extra): Promise => { - const elicitationResult = await extra.sendRequest( + async (args, ctx): Promise => { + const elicitationResult = await ctx.mcpReq.send( { method: "elicitation/create", params: { @@ -179,7 +175,6 @@ export const registerTriggerElicitationRequestTool = (server: McpServer) => { }, }, }, - ElicitResultSchema, { timeout: 10 * 60 * 1000 /* 10 minutes */ }, ); diff --git a/src/everything/tools/trigger-long-running-operation.ts b/src/everything/tools/trigger-long-running-operation.ts index 5bbb8fc517..b1151d25c2 100644 --- a/src/everything/tools/trigger-long-running-operation.ts +++ b/src/everything/tools/trigger-long-running-operation.ts @@ -1,6 +1,5 @@ import { z } from "zod"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { McpServer, CallToolResult } from "@modelcontextprotocol/server"; // Tool input schema const TriggerLongRunningOperationSchema = z.object({ @@ -43,11 +42,11 @@ export const registerTriggerLongRunningOperationTool = (server: McpServer) => { server.registerTool( name, config, - async (args, extra): Promise => { + async (args, ctx): Promise => { const validatedArgs = TriggerLongRunningOperationSchema.parse(args); const { duration, steps } = validatedArgs; const stepDuration = duration / steps; - const progressToken = extra._meta?.progressToken; + const progressToken = ctx.mcpReq._meta?.progressToken; for (let i = 1; i < steps + 1; i++) { await new Promise((resolve) => @@ -64,7 +63,7 @@ export const registerTriggerLongRunningOperationTool = (server: McpServer) => { progressToken, }, }, - { relatedRequestId: extra.requestId }, + { relatedRequestId: ctx.mcpReq.id }, ); } } diff --git a/src/everything/tools/trigger-sampling-request-async.ts b/src/everything/tools/trigger-sampling-request-async.ts deleted file mode 100644 index 4236328209..0000000000 --- a/src/everything/tools/trigger-sampling-request-async.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { - CallToolResult, - CreateMessageRequest, -} from "@modelcontextprotocol/sdk/types.js"; -import { z } from "zod"; - -// Tool input schema -const TriggerSamplingRequestAsyncSchema = z.object({ - prompt: z.string().describe("The prompt to send to the LLM"), - maxTokens: z - .number() - .default(100) - .describe("Maximum number of tokens to generate"), -}); - -// Tool configuration -const name = "trigger-sampling-request-async"; -const config = { - title: "Trigger Async Sampling Request Tool", - description: - "Trigger an async sampling request that the CLIENT executes as a background task. " + - "Demonstrates bidirectional MCP tasks where the server sends a request and the client " + - "executes it asynchronously, allowing the server to poll for progress and results.", - inputSchema: TriggerSamplingRequestAsyncSchema, - annotations: { - readOnlyHint: false, - destructiveHint: false, - idempotentHint: false, - openWorldHint: true, - }, -}; - -// Poll interval in milliseconds -const POLL_INTERVAL = 1000; - -// Maximum poll attempts before timeout -const MAX_POLL_ATTEMPTS = 60; - -/** - * Registers the 'trigger-sampling-request-async' tool. - * - * This tool demonstrates bidirectional MCP tasks: - * - Server sends sampling request to client with task metadata - * - Client creates a task and returns CreateTaskResult - * - Server polls client's tasks/get endpoint for status - * - Server fetches final result from client's tasks/result endpoint - * - * @param {McpServer} server - The McpServer instance where the tool will be registered. - */ -export const registerTriggerSamplingRequestAsyncTool = (server: McpServer) => { - // Check client capabilities - const clientCapabilities = server.server.getClientCapabilities() || {}; - - // Client must support sampling AND tasks.requests.sampling - const clientSupportsSampling = clientCapabilities.sampling !== undefined; - const clientTasksCapability = clientCapabilities.tasks as - | { - requests?: { sampling?: { createMessage?: object } }; - } - | undefined; - const clientSupportsAsyncSampling = - clientTasksCapability?.requests?.sampling?.createMessage !== undefined; - - if (clientSupportsSampling && clientSupportsAsyncSampling) { - server.registerTool( - name, - config, - async (args, extra): Promise => { - const validatedArgs = TriggerSamplingRequestAsyncSchema.parse(args); - const { prompt, maxTokens } = validatedArgs; - - // Create the sampling request WITH task metadata - // The params.task field signals to the client that this should be executed as a task - const request: CreateMessageRequest & { - params: { task?: { ttl: number } }; - } = { - method: "sampling/createMessage", - params: { - task: { - ttl: 300000, // 5 minutes - }, - messages: [ - { - role: "user", - content: { - type: "text", - text: `Resource ${name} context: ${prompt}`, - }, - }, - ], - systemPrompt: "You are a helpful test server.", - maxTokens, - temperature: 0.7, - }, - }; - - // Send the sampling request - // Client may return either: - // - CreateMessageResult (synchronous execution) - // - CreateTaskResult (task-based execution with { task } object) - const samplingResponse = await extra.sendRequest( - request, - z.union([ - // CreateTaskResult - client created a task - z.object({ - task: z.object({ - taskId: z.string(), - status: z.string(), - pollInterval: z.number().optional(), - statusMessage: z.string().optional(), - }), - }), - // CreateMessageResult - synchronous execution - z.object({ - role: z.string(), - content: z.any(), - model: z.string(), - stopReason: z.string().optional(), - }), - ]), - ); - - // Check if client returned CreateTaskResult (has task object) - const isTaskResult = - "task" in samplingResponse && samplingResponse.task; - if (!isTaskResult) { - // Client executed synchronously - return the direct response - return { - content: [ - { - type: "text", - text: `[SYNC] Client executed synchronously:\n${JSON.stringify( - samplingResponse, - null, - 2, - )}`, - }, - ], - }; - } - - const taskId = samplingResponse.task.taskId; - const statusMessages: string[] = []; - statusMessages.push(`Task created: ${taskId}`); - - // Poll for task completion - let attempts = 0; - let taskStatus = samplingResponse.task.status; - // A task that is already finished when created is never polled, so - // its status message has to come from the CreateTaskResult - let taskStatusMessage = samplingResponse.task.statusMessage; - - while ( - taskStatus !== "completed" && - taskStatus !== "failed" && - taskStatus !== "cancelled" && - attempts < MAX_POLL_ATTEMPTS - ) { - // Wait before polling - await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL)); - attempts++; - - // Get task status from client - const pollResult = await extra.sendRequest( - { - method: "tasks/get", - params: { taskId }, - }, - z.looseObject({ - status: z.string(), - statusMessage: z.string().optional(), - }), - ); - - taskStatus = pollResult.status; - taskStatusMessage = pollResult.statusMessage; - statusMessages.push( - `Poll ${attempts}: ${taskStatus}${ - taskStatusMessage ? ` - ${taskStatusMessage}` : "" - }`, - ); - } - - // Check for timeout - if (attempts >= MAX_POLL_ATTEMPTS) { - return { - content: [ - { - type: "text", - text: `[TIMEOUT] Task timed out after ${MAX_POLL_ATTEMPTS} poll attempts\n\nProgress:\n${statusMessages.join( - "\n", - )}`, - }, - ], - }; - } - - // Check for failure/cancellation - if (taskStatus === "failed" || taskStatus === "cancelled") { - return { - content: [ - { - type: "text", - text: `[${taskStatus.toUpperCase()}] ${ - taskStatusMessage || "No message" - }\n\nProgress:\n${statusMessages.join("\n")}`, - }, - ], - }; - } - - // Fetch the final result - const result = await extra.sendRequest( - { - method: "tasks/result", - params: { taskId }, - }, - z.any(), - ); - - // Return the result with status history - return { - content: [ - { - type: "text", - text: `[COMPLETED] Async sampling completed!\n\n**Progress:**\n${statusMessages.join( - "\n", - )}\n\n**Result:**\n${JSON.stringify(result, null, 2)}`, - }, - ], - }; - }, - ); - } -}; diff --git a/src/everything/tools/trigger-sampling-request.ts b/src/everything/tools/trigger-sampling-request.ts index e67a1ff0b7..28ed5fb4bd 100644 --- a/src/everything/tools/trigger-sampling-request.ts +++ b/src/everything/tools/trigger-sampling-request.ts @@ -1,9 +1,8 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { + McpServer, CallToolResult, CreateMessageRequest, - CreateMessageResultSchema, -} from "@modelcontextprotocol/sdk/types.js"; +} from "@modelcontextprotocol/server"; import { z } from "zod"; // Tool input schema @@ -53,7 +52,7 @@ export const registerTriggerSamplingRequestTool = (server: McpServer) => { server.registerTool( name, config, - async (args, extra): Promise => { + async (args, ctx): Promise => { const validatedArgs = TriggerSamplingRequestSchema.parse(args); const { prompt, maxTokens } = validatedArgs; @@ -77,10 +76,7 @@ export const registerTriggerSamplingRequestTool = (server: McpServer) => { }; // Send the sampling request to the client - const result = await extra.sendRequest( - request, - CreateMessageResultSchema, - ); + const result = await ctx.mcpReq.send(request); // Return the result to the client return { diff --git a/src/everything/tools/trigger-url-elicitation.ts b/src/everything/tools/trigger-url-elicitation.ts index cc3c4cf789..9faa1e557d 100644 --- a/src/everything/tools/trigger-url-elicitation.ts +++ b/src/everything/tools/trigger-url-elicitation.ts @@ -1,11 +1,10 @@ import { randomUUID } from "node:crypto"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { + McpServer, CallToolResult, ElicitRequestURLParams, - ElicitResultSchema, UrlElicitationRequiredError, -} from "@modelcontextprotocol/sdk/types.js"; +} from "@modelcontextprotocol/server"; import { z } from "zod"; // Tool input schema @@ -107,7 +106,7 @@ export const registerTriggerUrlElicitationTool = (server: McpServer) => { server.registerTool( name, config, - async (args, extra): Promise => { + async (args, ctx): Promise => { const { url, message, @@ -116,7 +115,7 @@ export const registerTriggerUrlElicitationTool = (server: McpServer) => { } = args; const elicitationId = requestedElicitationId ?? randomUUID(); - const sessionId = extra.sessionId ?? "default"; + const sessionId = ctx.sessionId ?? "default"; // Key the one-shot error-path marker on inputs the client resends // verbatim when it retries the original tool call. A real client retries @@ -170,12 +169,11 @@ export const registerTriggerUrlElicitationTool = (server: McpServer) => { } // Request path: send elicitation/create and await the user's response - const elicitationResult = await extra.sendRequest( + const elicitationResult = await ctx.mcpReq.send( { method: "elicitation/create", params: elicitationParams, }, - ElicitResultSchema, { timeout: 10 * 60 * 1000 /* 10 minutes */ }, ); diff --git a/src/everything/transports/in-memory-event-store.ts b/src/everything/transports/in-memory-event-store.ts index 88dec7b5be..80a270c58f 100644 --- a/src/everything/transports/in-memory-event-store.ts +++ b/src/everything/transports/in-memory-event-store.ts @@ -11,12 +11,12 @@ // tested directly. import { randomUUID } from "node:crypto"; -import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; import type { + JSONRPCMessage, EventId, EventStore, StreamId, -} from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +} from "@modelcontextprotocol/server"; export class InMemoryEventStore implements EventStore { private events: Map< diff --git a/src/everything/transports/sse.ts b/src/everything/transports/sse.ts index 1db484683a..a6a88761cb 100644 --- a/src/everything/transports/sse.ts +++ b/src/everything/transports/sse.ts @@ -5,7 +5,7 @@ // test can serve it on a free port and drive it with a real client, and each // app has its own session map. `startSseServer()` is what the launcher runs. -import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; +import { SSEServerTransport } from "@modelcontextprotocol/server-legacy/sse"; import express, { type Express, type Response } from "express"; import type { Server } from "node:http"; import { createServer } from "../server/index.js"; diff --git a/src/everything/transports/stdio.ts b/src/everything/transports/stdio.ts index 61a36d71d6..312a89c110 100644 --- a/src/everything/transports/stdio.ts +++ b/src/everything/transports/stdio.ts @@ -9,7 +9,7 @@ // launches the binary. import type { Readable, Writable } from "node:stream"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; import { createServer } from "../server/index.js"; /** What a started stdio server hands back: the server and its SIGINT handler. */ diff --git a/src/everything/transports/streamableHttp.ts b/src/everything/transports/streamableHttp.ts index 1057302a9e..9b475b3c20 100644 --- a/src/everything/transports/streamableHttp.ts +++ b/src/everything/transports/streamableHttp.ts @@ -7,8 +7,8 @@ // a real client, and call its shutdown handler directly; each app has its own // session map. `startStreamableHttpServer()` is what the launcher runs. -import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; -import { isJSONRPCRequest } from "@modelcontextprotocol/sdk/types.js"; +import { isJSONRPCRequest } from "@modelcontextprotocol/server"; +import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node"; import express, { Express, Request, Response } from "express"; import type { Server } from "node:http"; import { createServer } from "../server/index.js"; @@ -84,9 +84,9 @@ export function createApp(): StreamableHttpApp { ); // Map sessionId to server transport for each client - const transports: Map = new Map< + const transports: Map = new Map< string, - StreamableHTTPServerTransport + NodeStreamableHTTPServerTransport >(); // Handle POST requests for client messages @@ -96,7 +96,7 @@ export function createApp(): StreamableHttpApp { // Check for existing session ID const sessionId = req.headers["mcp-session-id"] as string | undefined; - let transport: StreamableHTTPServerTransport; + let transport: NodeStreamableHTTPServerTransport; if (sessionId && transports.has(sessionId)) { // Reuse existing transport @@ -106,7 +106,7 @@ export function createApp(): StreamableHttpApp { // New initialization request const eventStore = new InMemoryEventStore(); - transport = new StreamableHTTPServerTransport({ + transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), eventStore, // Enable resumability onsessioninitialized: (sessionId: string) => { diff --git a/src/filesystem/__tests__/bin-smoke.test.ts b/src/filesystem/__tests__/bin-smoke.test.ts index c16059188c..520cc0cc21 100644 --- a/src/filesystem/__tests__/bin-smoke.test.ts +++ b/src/filesystem/__tests__/bin-smoke.test.ts @@ -10,8 +10,8 @@ import { createRequire } from "node:module"; import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { Client } from "@modelcontextprotocol/client"; import { makeTempDir } from "./helpers.js"; const packageRoot = path.join(import.meta.dirname, ".."); diff --git a/src/filesystem/__tests__/helpers.ts b/src/filesystem/__tests__/helpers.ts index f251c1a426..5e7d6afff2 100644 --- a/src/filesystem/__tests__/helpers.ts +++ b/src/filesystem/__tests__/helpers.ts @@ -8,14 +8,12 @@ import fs from "fs/promises"; import os from "os"; import path from "path"; import { vi } from "vitest"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import { - ListRootsRequestSchema, - type CallToolResult, - type ClientCapabilities, - type Root, -} from "@modelcontextprotocol/sdk/types.js"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import type { + CallToolResult, + ClientCapabilities, + Root, +} from "@modelcontextprotocol/client"; import { createServer } from "../server.js"; export interface Connected { @@ -42,7 +40,7 @@ export async function connect( ); const { listRoots } = options; if (options.capabilities?.roots && listRoots) { - client.setRequestHandler(ListRootsRequestSchema, async () => ({ + client.setRequestHandler("roots/list", async () => ({ roots: await listRoots(), })); } diff --git a/src/filesystem/__tests__/server-paths.test.ts b/src/filesystem/__tests__/server-paths.test.ts index a3061d1640..869991de8b 100644 --- a/src/filesystem/__tests__/server-paths.test.ts +++ b/src/filesystem/__tests__/server-paths.test.ts @@ -8,7 +8,7 @@ import fs from "fs/promises"; import os from "os"; import path from "path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import type { Client } from "@modelcontextprotocol/client"; import { call, connect, diff --git a/src/filesystem/__tests__/server-roots.test.ts b/src/filesystem/__tests__/server-roots.test.ts index 0d1ee5301a..a35d81788c 100644 --- a/src/filesystem/__tests__/server-roots.test.ts +++ b/src/filesystem/__tests__/server-roots.test.ts @@ -10,7 +10,7 @@ import fs from "fs/promises"; import path from "path"; import { pathToFileURL } from "url"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { Root } from "@modelcontextprotocol/sdk/types.js"; +import type { Root } from "@modelcontextprotocol/server"; import { allowedDirectoriesOf, call, @@ -153,7 +153,7 @@ describe("initial roots", () => { await vi.waitFor(() => expect(stderr).toHaveBeenCalledWith( "Failed to request initial roots from client:", - "MCP error -32603: client has no roots today", + "client has no roots today", ), ); expect(await allowedDirectoriesOf(client)).toEqual([cliDir]); @@ -269,7 +269,7 @@ describe("roots/list_changed", () => { await vi.waitFor(() => expect(stderr).toHaveBeenCalledWith( "Failed to request roots from client:", - "MCP error -32603: gone", + "gone", ), ); expect(await allowedDirectoriesOf(client)).toEqual([rootDir]); diff --git a/src/filesystem/__tests__/server-tools.test.ts b/src/filesystem/__tests__/server-tools.test.ts index ff65d2f53b..9be4855672 100644 --- a/src/filesystem/__tests__/server-tools.test.ts +++ b/src/filesystem/__tests__/server-tools.test.ts @@ -1,7 +1,7 @@ // Characterization suite for every filesystem tool, driven through an SDK // Client over an in-memory transport (#4854). It pins what the server does -// today on SDK 1.x, quirks included, so the SDK v2 migration (#4856) can prove -// it changed nothing on the wire. A test that pins a known bug says so, with +// today, quirks included. It was written on SDK 1.x so the SDK v2 migration +// (#4856) could show what changed on the wire. A test that pins a known bug says so, with // the issue number, so the fix has a test to change. import fs from "fs/promises"; @@ -9,7 +9,7 @@ import path from "path"; import { fileURLToPath } from "url"; import { createRequire } from "node:module"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import type { Client } from "@modelcontextprotocol/client"; import { allowedDirectoriesOf, call, @@ -149,9 +149,9 @@ describe("server identity and tool list", () => { }); }); - // #4841: the SDK 1.x tools/list handler stamps draft-07 on every schema it - // renders, which validators that accept only JSON Schema 2020-12 reject. The - // server declares 2020-12 instead. + // #4841: the SDK 1.x tools/list handler stamped draft-07 on every schema it + // rendered, which validators that accept only JSON Schema 2020-12 reject. The + // server declares 2020-12 instead, as SDK v2 now does by default. it("declares the 2020-12 $schema on every input and output schema (#4841)", async () => { const { tools } = await client.listTools(); expect(tools).toHaveLength(TOOL_NAMES.length); @@ -307,19 +307,18 @@ describe("server identity and tool list", () => { } }); - it("reports an unknown tool as a tool error", async () => { - const result = await call(client, "no_such_tool"); - expect(result.isError).toBe(true); - expect(textOf(result)).toBe( - "MCP error -32602: Tool no_such_tool not found", - ); + it("rejects an unknown tool with -32602", async () => { + await expect(call(client, "no_such_tool")).rejects.toMatchObject({ + code: -32602, + message: "Tool no_such_tool not found", + }); }); it("reports schema-invalid arguments as a tool error", async () => { const result = await call(client, "read_text_file", { path: 42 }); expect(result.isError).toBe(true); expect(textOf(result)).toMatch( - /^MCP error -32602: Input validation error: Invalid arguments for tool read_text_file/, + /^Input validation error: Invalid arguments for tool read_text_file/, ); }); }); diff --git a/src/filesystem/__tests__/stdio.test.ts b/src/filesystem/__tests__/stdio.test.ts index e55b2595d3..9171adcf20 100644 --- a/src/filesystem/__tests__/stdio.test.ts +++ b/src/filesystem/__tests__/stdio.test.ts @@ -3,7 +3,7 @@ // usage text, the exit on no usable directory) and the real // StdioServerTransport over in-memory streams, writing raw JSON-RPC lines the // way a client process would. Absorbs the former startup-validation.test.ts, -// which spawned the build. Pins #4206, #4207 and #4195 as they stand on SDK 1.x. +// which spawned the build. Pins #4206, #4207 and #4195 as they stand today. import fs from "fs/promises"; import os from "os"; @@ -11,7 +11,7 @@ import path from "path"; import { PassThrough } from "node:stream"; import { pathToFileURL } from "url"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { McpServer } from "@modelcontextprotocol/server"; import { isEntryPoint, main, resolveAllowedDirectories } from "../index.js"; import { makeTempDir, quietStderr } from "./helpers.js"; @@ -203,7 +203,7 @@ describe("startup", () => { }); describe("the stdio transport", () => { - // #4195: SDK 1.x does not enforce the lifecycle, so a request before + // #4195: the SDK does not enforce the lifecycle, so a request before // initialize is answered normally. it("answers tools/list before initialize (#4195)", async () => { await session.start([dir]); diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 0a8edced6a..c112d8f23e 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -12,8 +12,8 @@ import { realpathSync } from "fs"; import fs from "fs/promises"; import path from "path"; import { fileURLToPath } from "url"; -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; +import type { McpServer } from "@modelcontextprotocol/server"; import { normalizePath, expandHome } from "./path-utils.js"; import { createServer } from "./server.js"; diff --git a/src/filesystem/package.json b/src/filesystem/package.json index 5ee6d9498f..2badd52383 100644 --- a/src/filesystem/package.json +++ b/src/filesystem/package.json @@ -12,6 +12,9 @@ "url": "https://github.com/modelcontextprotocol/servers.git" }, "type": "module", + "engines": { + "node": ">=20" + }, "bin": { "mcp-server-filesystem": "dist/index.js" }, @@ -31,13 +34,14 @@ "validate": "npm run format:check && npm run lint && npm run typecheck && npm run build && npm run test" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.30.0", + "@modelcontextprotocol/server": "^2.3.1", "diff": "^8.0.3", "glob": "^13.0.6", "minimatch": "^10.0.1", - "zod": "^4.0.0" + "zod": "^4.2.0" }, "devDependencies": { + "@modelcontextprotocol/client": "^2.3.1", "@types/diff": "^5.0.9", "@types/minimatch": "^5.1.2", "@types/node": "^22", diff --git a/src/filesystem/roots-utils.ts b/src/filesystem/roots-utils.ts index eda0796620..e70da4fa01 100644 --- a/src/filesystem/roots-utils.ts +++ b/src/filesystem/roots-utils.ts @@ -2,7 +2,7 @@ import { promises as fs, type Stats } from "fs"; import path from "path"; import os from "os"; import { normalizePath } from "./path-utils.js"; -import type { Root } from "@modelcontextprotocol/sdk/types.js"; +import type { Root } from "@modelcontextprotocol/server"; import { fileURLToPath } from "url"; /** diff --git a/src/filesystem/server.ts b/src/filesystem/server.ts index 2a0327d80d..a5aadf2e07 100644 --- a/src/filesystem/server.ts +++ b/src/filesystem/server.ts @@ -4,12 +4,8 @@ // lets the tests drive a real Client against it in-process over an in-memory // transport (#4854), and holding the allow-list in this closure rather than in // a module global lets two instances coexist in one process. - -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { - RootsListChangedNotificationSchema, - type Root, -} from "@modelcontextprotocol/sdk/types.js"; +import { McpServer } from "@modelcontextprotocol/server"; +import type { Root } from "@modelcontextprotocol/server"; import fs from "fs/promises"; import { createReadStream } from "fs"; import path from "path"; @@ -32,10 +28,11 @@ import { import { SERVER_VERSION } from "./version.js"; // The JSON Schema dialect every advertised tool schema declares (#4841). The -// SDK 1.x tools/list handler renders zod schemas with zod's draft-07 target and -// stamps "$schema": draft-07 on each one, which validators that accept only +// SDK 1.x tools/list handler rendered zod schemas with zod's draft-07 target and +// stamped "$schema": draft-07 on each one, which validators that accept only // 2020-12 (the dialect MCP assumes for tool schemas) reject outright. Setting -// $schema in the root object's zod metadata overrides that stamp. The schemas +// $schema in the root object's zod metadata overrode that stamp. SDK v2 stamps +// 2020-12 itself, so the label is now the SDK's default as well. The schemas // below use nothing whose draft-07 and 2020-12 renderings differ (no tuples), // so the label is the only change; server-tools.test.ts fails if a draft-07-only // keyword ever appears under it. @@ -773,7 +770,7 @@ export function createServer( // Handles dynamic roots updates during runtime, when client sends "roots/list_changed" notification, server fetches the updated roots and replaces all allowed directories with the new roots. server.server.setNotificationHandler( - RootsListChangedNotificationSchema, + "notifications/roots/list_changed", async () => { try { // Request the updated roots list from the client diff --git a/src/memory/__tests__/entry-point.test.ts b/src/memory/__tests__/entry-point.test.ts index 63722256c6..5593f0d64b 100644 --- a/src/memory/__tests__/entry-point.test.ts +++ b/src/memory/__tests__/entry-point.test.ts @@ -7,8 +7,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { promises as fs } from "fs"; import path from "path"; import { pathToFileURL } from "url"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { isMainModule, main } from "../index.js"; import { call, makeTempGraph, readFileText } from "./helpers.js"; diff --git a/src/memory/__tests__/helpers.ts b/src/memory/__tests__/helpers.ts index a801a015a5..4be893f7c4 100644 --- a/src/memory/__tests__/helpers.ts +++ b/src/memory/__tests__/helpers.ts @@ -6,10 +6,9 @@ import { promises as fs } from "fs"; import os from "os"; import path from "path"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { InMemoryTransport } from "@modelcontextprotocol/server"; +import type { McpServer, CallToolResult } from "@modelcontextprotocol/server"; +import { Client } from "@modelcontextprotocol/client"; import { createServer } from "../index.js"; export interface Connection { diff --git a/src/memory/__tests__/resource.test.ts b/src/memory/__tests__/resource.test.ts index 9f85bd12a7..0e9d119177 100644 --- a/src/memory/__tests__/resource.test.ts +++ b/src/memory/__tests__/resource.test.ts @@ -4,9 +4,7 @@ // that mutation tools send (notifyGraphUpdated). They replace the earlier // tests that called the register functions with a mocked McpServer. import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import type { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; -import { ResourceUpdatedNotificationSchema } from "@modelcontextprotocol/sdk/types.js"; +import type { Client, JSONRPCMessage } from "@modelcontextprotocol/client"; import { call, connect, makeTempGraph } from "./helpers.js"; import type { Connection } from "./helpers.js"; @@ -31,7 +29,7 @@ describe("knowledge-graph resource over the protocol", () => { client = conn.client; updates = []; client.setNotificationHandler( - ResourceUpdatedNotificationSchema, + "notifications/resources/updated", (notification) => { updates.push(notification.params); }, @@ -102,7 +100,7 @@ describe("knowledge-graph resource over the protocol", () => { it("rejects a URI it does not serve", async () => { await expect( client.readResource({ uri: "memory://other" }), - ).rejects.toThrow("Resource memory://other not found"); + ).rejects.toThrow("Resource not found: memory://other"); }); }); diff --git a/src/memory/__tests__/stdio-smoke.test.ts b/src/memory/__tests__/stdio-smoke.test.ts index ab47e65b40..81cecdf2cf 100644 --- a/src/memory/__tests__/stdio-smoke.test.ts +++ b/src/memory/__tests__/stdio-smoke.test.ts @@ -8,8 +8,8 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { promises as fs } from "fs"; import path from "path"; import { fileURLToPath } from "url"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { Client } from "@modelcontextprotocol/client"; import { call, makeTempGraph, readFileText } from "./helpers.js"; const distIndexPath = path.join( diff --git a/src/memory/__tests__/tools.test.ts b/src/memory/__tests__/tools.test.ts index cea031eb93..537fa3b4f8 100644 --- a/src/memory/__tests__/tools.test.ts +++ b/src/memory/__tests__/tools.test.ts @@ -1,14 +1,14 @@ // Characterization tests for the memory server's nine tools, driven through // an SDK Client over an in-memory transport (#4854). They pin what a client -// sees today on SDK 1.x: the advertised tool list, each tool's text and +// sees today: the advertised tool list, each tool's text and // structured results, and the error results. A test that pins a known bug // cites its issue, so the PR that fixes it has a test to change. import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import type { Client } from "@modelcontextprotocol/client"; import { call, connect, makeTempGraph, textOf } from "./helpers.js"; import type { Connection } from "./helpers.js"; -const DRAFT_07 = "http://json-schema.org/draft-07/schema#"; +const DRAFT_07 = "https://json-schema.org/draft/2020-12/schema"; // JSON Schema fragments as the SDK emits them. Output schemas close every // object with additionalProperties: false; input schemas leave them open. @@ -86,7 +86,10 @@ const readAnnotations = { idempotentHint: true, openWorldHint: false, }; -const execution = { taskSupport: "forbidden" }; +// SDK 1.x advertised `execution: { taskSupport: "forbidden" }` on every tool; +// SDK v2 removed the experimental tasks layer and no longer sends it. Part 5 +// (#4852) decides what a tool advertises under the tasks extension. +const execution = undefined; const expectedTools = [ { @@ -386,19 +389,17 @@ describe("memory tools over the protocol", () => { }); it("offers no prompts", async () => { - await expect(client.listPrompts()).rejects.toThrow(/Method not found/); + // A raw request: SDK v2's listPrompts() answers an empty list without + // asking a server that does not advertise prompts. + await expect( + client.request({ method: "prompts/list", params: {} }), + ).rejects.toThrow(/Method not found/); }); - it("reports an unknown tool as a tool error", async () => { - const result = await call(client, "no_such_tool"); - expect(result).toEqual({ - content: [ - { - type: "text", - text: "MCP error -32602: Tool no_such_tool not found", - }, - ], - isError: true, + it("rejects an unknown tool with -32602", async () => { + await expect(call(client, "no_such_tool")).rejects.toMatchObject({ + code: -32602, + message: "Tool no_such_tool not found", }); }); }); @@ -499,7 +500,7 @@ describe("memory tools over the protocol", () => { content: [ { type: "text", - text: "MCP error -32602: Input validation error: Invalid arguments for tool create_entities: Invalid input: expected array, received undefined at entities", + text: "Input validation error: Invalid arguments for tool create_entities: entities: Invalid input: expected array, received undefined", }, ], isError: true, @@ -873,7 +874,7 @@ describe("memory tools over the protocol", () => { content: [ { type: "text", - text: "MCP error -32602: Input validation error: Invalid arguments for tool search_nodes: Invalid input: expected string, received number at query", + text: "Input validation error: Invalid arguments for tool search_nodes: query: Invalid input: expected string, received number", }, ], isError: true, @@ -888,7 +889,7 @@ describe("memory tools over the protocol", () => { content: [ { type: "text", - text: "MCP error -32602: Input validation error: Invalid arguments for tool search_nodes: Too big: expected string to have <=2048 characters at query", + text: "Input validation error: Invalid arguments for tool search_nodes: query: Too big: expected string to have <=2048 characters", }, ], isError: true, diff --git a/src/memory/index.ts b/src/memory/index.ts index eab385a9fa..0e6bd9ffdc 100644 --- a/src/memory/index.ts +++ b/src/memory/index.ts @@ -5,14 +5,9 @@ // subscribable resource. createServer() builds a server over a graph file so // tests can run it in-process; main() connects it to stdio, and runs only // when this file is the process entry point, so importing it starts nothing. - -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; -import { - SubscribeRequestSchema, - UnsubscribeRequestSchema, -} from "@modelcontextprotocol/sdk/types.js"; +import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; +import { McpServer } from "@modelcontextprotocol/server"; +import type { Transport } from "@modelcontextprotocol/server"; import { z } from "zod"; import { promises as fs, realpathSync } from "fs"; import type { FileHandle } from "fs/promises"; @@ -773,14 +768,14 @@ export function createServer(memoryFilePath: string): McpServer { title: "Create Entities", description: "Create multiple new entities in the knowledge graph. An entity whose name already exists, or repeats an earlier entity in the same call, is skipped and its observations are not added; the result lists the skipped names in `skipped`. Use add_observations to add observations to an existing entity.", - inputSchema: { + inputSchema: z.object({ entities: z.array(EntitySchema), - }, - outputSchema: { + }), + outputSchema: z.object({ entities: z.array(EntitySchema), // Present only when at least one requested entity was skipped. skipped: z.array(z.string()).optional(), - }, + }), annotations: { readOnlyHint: false, destructiveHint: false, @@ -820,12 +815,12 @@ export function createServer(memoryFilePath: string): McpServer { title: "Create Relations", description: "Create multiple new relations between entities in the knowledge graph. Relations should be in active voice", - inputSchema: { + inputSchema: z.object({ relations: z.array(RelationSchema), - }, - outputSchema: { + }), + outputSchema: z.object({ relations: z.array(RelationSchema), - }, + }), annotations: { readOnlyHint: false, destructiveHint: false, @@ -852,7 +847,7 @@ export function createServer(memoryFilePath: string): McpServer { title: "Add Observations", description: "Add new observations to existing entities in the knowledge graph", - inputSchema: { + inputSchema: z.object({ observations: z.array( z.object({ entityName: z @@ -863,15 +858,15 @@ export function createServer(memoryFilePath: string): McpServer { .describe("An array of observation contents to add"), }), ), - }, - outputSchema: { + }), + outputSchema: z.object({ results: z.array( z.object({ entityName: z.string(), addedObservations: z.array(z.string()), }), ), - }, + }), annotations: { readOnlyHint: false, destructiveHint: false, @@ -898,15 +893,15 @@ export function createServer(memoryFilePath: string): McpServer { title: "Delete Entities", description: "Delete multiple entities and their associated relations from the knowledge graph", - inputSchema: { + inputSchema: z.object({ entityNames: z .array(z.string()) .describe("An array of entity names to delete"), - }, - outputSchema: { + }), + outputSchema: z.object({ success: z.boolean(), message: z.string(), - }, + }), annotations: { readOnlyHint: false, destructiveHint: true, @@ -936,7 +931,7 @@ export function createServer(memoryFilePath: string): McpServer { title: "Delete Observations", description: "Delete specific observations from entities in the knowledge graph", - inputSchema: { + inputSchema: z.object({ deletions: z.array( z.object({ entityName: z @@ -947,11 +942,11 @@ export function createServer(memoryFilePath: string): McpServer { .describe("An array of observations to delete"), }), ), - }, - outputSchema: { + }), + outputSchema: z.object({ success: z.boolean(), message: z.string(), - }, + }), annotations: { readOnlyHint: false, destructiveHint: true, @@ -987,15 +982,15 @@ export function createServer(memoryFilePath: string): McpServer { { title: "Delete Relations", description: "Delete multiple relations from the knowledge graph", - inputSchema: { + inputSchema: z.object({ relations: z .array(RelationSchema) .describe("An array of relations to delete"), - }, - outputSchema: { + }), + outputSchema: z.object({ success: z.boolean(), message: z.string(), - }, + }), annotations: { readOnlyHint: false, destructiveHint: true, @@ -1024,11 +1019,11 @@ export function createServer(memoryFilePath: string): McpServer { { title: "Read Graph", description: "Read the entire knowledge graph", - inputSchema: {}, - outputSchema: { + inputSchema: z.object({}), + outputSchema: z.object({ entities: z.array(EntitySchema), relations: z.array(RelationSchema), - }, + }), annotations: { readOnlyHint: true, destructiveHint: false, @@ -1053,13 +1048,13 @@ export function createServer(memoryFilePath: string): McpServer { { title: "Search Nodes", description: "Search for nodes in the knowledge graph based on a query", - inputSchema: { + inputSchema: z.object({ query: SearchNodesQuerySchema, - }, - outputSchema: { + }), + outputSchema: z.object({ entities: z.array(EntitySchema), relations: z.array(RelationSchema), - }, + }), annotations: { readOnlyHint: true, destructiveHint: false, @@ -1084,15 +1079,15 @@ export function createServer(memoryFilePath: string): McpServer { { title: "Open Nodes", description: "Open specific nodes in the knowledge graph by their names", - inputSchema: { + inputSchema: z.object({ names: z .array(z.string()) .describe("An array of entity names to retrieve"), - }, - outputSchema: { + }), + outputSchema: z.object({ entities: z.array(EntitySchema), relations: z.array(RelationSchema), - }, + }), annotations: { readOnlyHint: true, destructiveHint: false, @@ -1151,11 +1146,11 @@ export function registerKnowledgeGraphSubscriptions( resourceSubscribers: Set, ) { server.server.registerCapabilities({ resources: { subscribe: true } }); - server.server.setRequestHandler(SubscribeRequestSchema, async (request) => { + server.server.setRequestHandler("resources/subscribe", async (request) => { resourceSubscribers.add(request.params.uri); return {}; }); - server.server.setRequestHandler(UnsubscribeRequestSchema, async (request) => { + server.server.setRequestHandler("resources/unsubscribe", async (request) => { resourceSubscribers.delete(request.params.uri); return {}; }); diff --git a/src/memory/package.json b/src/memory/package.json index 3ce170f295..3d5fb93444 100644 --- a/src/memory/package.json +++ b/src/memory/package.json @@ -12,6 +12,9 @@ "url": "https://github.com/modelcontextprotocol/servers.git" }, "type": "module", + "engines": { + "node": ">=20" + }, "bin": { "mcp-server-memory": "dist/index.js" }, @@ -31,10 +34,11 @@ "validate": "npm run format:check && npm run lint && npm run typecheck && npm run build && npm run test" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.30.0", - "zod": "^4.0.0" + "@modelcontextprotocol/server": "^2.3.1", + "zod": "^4.2.0" }, "devDependencies": { + "@modelcontextprotocol/client": "^2.3.1", "@types/node": "^22", "@vitest/coverage-v8": "^4.1.8", "shx": "^0.4.0", diff --git a/src/sequentialthinking/__tests__/entry-point.test.ts b/src/sequentialthinking/__tests__/entry-point.test.ts index 1b763a02d1..4ac351e738 100644 --- a/src/sequentialthinking/__tests__/entry-point.test.ts +++ b/src/sequentialthinking/__tests__/entry-point.test.ts @@ -17,8 +17,7 @@ import { mkdtempSync, rmSync, symlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { isEntryPoint, main } from "../index.js"; const indexUrl = new URL("../index.ts", import.meta.url).href; diff --git a/src/sequentialthinking/__tests__/helpers.ts b/src/sequentialthinking/__tests__/helpers.ts index 1d0d8940f3..d36f8e45e7 100644 --- a/src/sequentialthinking/__tests__/helpers.ts +++ b/src/sequentialthinking/__tests__/helpers.ts @@ -7,8 +7,7 @@ // and type changes alone. import { expect } from "vitest"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { createServer } from "../index.js"; export const TOOL_NAME = "sequentialthinking"; diff --git a/src/sequentialthinking/__tests__/input-schema.test.ts b/src/sequentialthinking/__tests__/input-schema.test.ts index f5ff488b90..8df78f6a4e 100644 --- a/src/sequentialthinking/__tests__/input-schema.test.ts +++ b/src/sequentialthinking/__tests__/input-schema.test.ts @@ -3,7 +3,7 @@ // error a client gets back for each kind of bad input. Originally the #4651 // regression test, which spawned dist/index.js and was skipped when no build // existed; ported to the in-process client (#4854) so it always runs and -// always tests the source. The error texts are the SDK 1.x mapping of a Zod +// always tests the source. The error texts are the SDK's mapping of a Zod // failure and are pinned verbatim: a change to them is a wire change. import { describe, it, expect, beforeEach, afterEach } from "vitest"; @@ -16,7 +16,7 @@ import { } from "./helpers.js"; const INVALID = - "MCP error -32602: Input validation error: Invalid arguments for tool sequentialthinking: "; + "Input validation error: Invalid arguments for tool sequentialthinking: "; describe("sequentialthinking input schema", () => { let conn: Connected; @@ -43,7 +43,7 @@ describe("sequentialthinking input schema", () => { it("rejects a call that omits nextThoughtNeeded", async () => { await expectInvalid( { thought: "t", thoughtNumber: 1, totalThoughts: 1 }, - "Invalid input at nextThoughtNeeded", + "nextThoughtNeeded: Invalid input", ); }); @@ -51,16 +51,16 @@ describe("sequentialthinking input schema", () => { const { thought: _omitted, ...args } = thought(); await expectInvalid( args, - "Invalid input: expected string, received undefined at thought", + "thought: Invalid input: expected string, received undefined", ); }); it("reports every missing numeric field, coerced to NaN", async () => { await expectInvalid( { thought: "t" }, - "Invalid input at nextThoughtNeeded\n" + - "Invalid input: expected number, received NaN at thoughtNumber\n" + - "Invalid input: expected number, received NaN at totalThoughts", + "nextThoughtNeeded: Invalid input, " + + "thoughtNumber: Invalid input: expected number, received NaN, " + + "totalThoughts: Invalid input: expected number, received NaN", ); }); @@ -95,7 +95,7 @@ describe("sequentialthinking input schema", () => { async (value) => { await expectInvalid( thought({ nextThoughtNeeded: value }), - `Expected boolean or "true"/"false" string, received "${value}" at nextThoughtNeeded`, + `nextThoughtNeeded: Expected boolean or "true"/"false" string, received "${value}"`, ); }, ); @@ -103,7 +103,7 @@ describe("sequentialthinking input schema", () => { it.each([1, 0, null])("rejects the non-string %j", async (value) => { await expectInvalid( thought({ nextThoughtNeeded: value }), - "Invalid input at nextThoughtNeeded", + "nextThoughtNeeded: Invalid input", ); }); @@ -117,11 +117,11 @@ describe("sequentialthinking input schema", () => { ).toBeFalsy(); await expectInvalid( thought({ isRevision: "maybe" }), - 'Expected boolean or "true"/"false" string, received "maybe" at isRevision', + 'isRevision: Expected boolean or "true"/"false" string, received "maybe"', ); await expectInvalid( thought({ needsMoreThoughts: "nope" }), - 'Expected boolean or "true"/"false" string, received "nope" at needsMoreThoughts', + 'needsMoreThoughts: Expected boolean or "true"/"false" string, received "nope"', ); }); }); @@ -156,13 +156,13 @@ describe("sequentialthinking input schema", () => { "Invalid input: expected number, received NaN", ], ])("rejects %s = %j", async (field, value, detail) => { - await expectInvalid(thought({ [field]: value }), `${detail} at ${field}`); + await expectInvalid(thought({ [field]: value }), `${field}: ${detail}`); }); it("rejects a non-string branchId", async () => { await expectInvalid( thought({ branchFromThought: 1, branchId: 7 }), - "Invalid input: expected string, received number at branchId", + "branchId: Invalid input: expected string, received number", ); }); }); diff --git a/src/sequentialthinking/__tests__/stdio-smoke.test.ts b/src/sequentialthinking/__tests__/stdio-smoke.test.ts index 9c44f30335..f7924198c1 100644 --- a/src/sequentialthinking/__tests__/stdio-smoke.test.ts +++ b/src/sequentialthinking/__tests__/stdio-smoke.test.ts @@ -10,8 +10,8 @@ import { existsSync, mkdtempSync, rmSync, symlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { Client } from "@modelcontextprotocol/client"; const packageJson = createRequire(import.meta.url)("../package.json") as { version: string; diff --git a/src/sequentialthinking/__tests__/tools-list.test.ts b/src/sequentialthinking/__tests__/tools-list.test.ts index d1a9380dba..69ef0455a4 100644 --- a/src/sequentialthinking/__tests__/tools-list.test.ts +++ b/src/sequentialthinking/__tests__/tools-list.test.ts @@ -1,6 +1,6 @@ // Pins what a client sees when it connects and lists tools: the server's // identity and capabilities, and the one tool's name, title, description, -// annotations, input schema and output schema, exactly as SDK 1.x emits them +// annotations, input schema and output schema, exactly as the SDK emits them // today. These are characterization tests. The annotations assert the values // fixed in #4721 (the tool is stateful, so neither read-only nor idempotent). // The 2781-character description (#799) is pinned as current design: the @@ -64,7 +64,9 @@ describe("sequentialthinking: initialize and tools/list", () => { }); }); - it("forbids task-augmented execution", () => { + // SDK v2 removed the experimental tasks layer and no longer advertises + // `execution.taskSupport`; Part 5 (#4852) restores task semantics. + it.skip("forbids task-augmented execution", () => { expect(tool.execution).toEqual({ taskSupport: "forbidden" }); }); @@ -80,9 +82,9 @@ describe("sequentialthinking: initialize and tools/list", () => { ); }); - it("advertises the input schema as draft-07 JSON Schema", () => { + it("advertises the input schema as JSON Schema 2020-12", () => { expect(tool.inputSchema).toEqual({ - $schema: "http://json-schema.org/draft-07/schema#", + $schema: "https://json-schema.org/draft/2020-12/schema", type: "object", properties: { thought: { type: "string", description: "Your current thinking step" }, @@ -136,7 +138,7 @@ describe("sequentialthinking: initialize and tools/list", () => { it("advertises a closed output schema with all five fields required", () => { expect(tool.outputSchema).toEqual({ - $schema: "http://json-schema.org/draft-07/schema#", + $schema: "https://json-schema.org/draft/2020-12/schema", type: "object", properties: { thoughtNumber: { type: "number" }, @@ -161,25 +163,22 @@ describe("sequentialthinking: initialize and tools/list", () => { }); it("offers no prompts or resources", async () => { - await expect(conn.client.listPrompts()).rejects.toThrow(/Method not found/); - await expect(conn.client.listResources()).rejects.toThrow( - /Method not found/, - ); + // Raw requests: SDK v2's listPrompts() and listResources() answer empty + // lists without asking a server that does not advertise them. + await expect( + conn.client.request({ method: "prompts/list", params: {} }), + ).rejects.toThrow(/Method not found/); + await expect( + conn.client.request({ method: "resources/list", params: {} }), + ).rejects.toThrow(/Method not found/); }); - it("reports an unknown tool as a tool error", async () => { - const result = await conn.client.callTool({ - name: "no_such_tool", - arguments: {}, - }); - expect(result).toEqual({ - content: [ - { - type: "text", - text: "MCP error -32602: Tool no_such_tool not found", - }, - ], - isError: true, + it("rejects an unknown tool with -32602", async () => { + await expect( + conn.client.callTool({ name: "no_such_tool", arguments: {} }), + ).rejects.toMatchObject({ + code: -32602, + message: "Tool no_such_tool not found", }); }); }); diff --git a/src/sequentialthinking/index.ts b/src/sequentialthinking/index.ts index 5bc02e4602..a03d27f469 100644 --- a/src/sequentialthinking/index.ts +++ b/src/sequentialthinking/index.ts @@ -8,9 +8,9 @@ import { realpathSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; +import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; +import { McpServer } from "@modelcontextprotocol/server"; +import type { Transport } from "@modelcontextprotocol/server"; import { z } from "zod"; import { SequentialThinkingServer } from "./lib.js"; import { SERVER_VERSION } from "./version.js"; @@ -100,7 +100,7 @@ You should: 9. Repeat the process until satisfied with the solution 10. Provide a single, ideally correct answer as the final output 11. Only set nextThoughtNeeded to false when truly done and a satisfactory answer is reached`, - inputSchema: { + inputSchema: z.object({ thought: z.string().describe("Your current thinking step"), nextThoughtNeeded: coercedBoolean.describe( "Whether another thought step is needed", @@ -136,7 +136,7 @@ You should: needsMoreThoughts: coercedBoolean .optional() .describe("If more thoughts are needed"), - }, + }), annotations: { // Every call appends to the server's in-memory thought history, and a // call with both branchFromThought and branchId also appends to that @@ -146,13 +146,13 @@ You should: idempotentHint: false, openWorldHint: false, }, - outputSchema: { + outputSchema: z.object({ thoughtNumber: z.number(), totalThoughts: z.number(), nextThoughtNeeded: z.boolean(), branches: z.array(z.string()), thoughtHistoryLength: z.number(), - }, + }), }, async (args) => { const result = thinkingServer.processThought(args); diff --git a/src/sequentialthinking/package.json b/src/sequentialthinking/package.json index 91814c7de7..42fa282ed9 100644 --- a/src/sequentialthinking/package.json +++ b/src/sequentialthinking/package.json @@ -12,6 +12,9 @@ "url": "https://github.com/modelcontextprotocol/servers.git" }, "type": "module", + "engines": { + "node": ">=20" + }, "bin": { "mcp-server-sequential-thinking": "dist/index.js" }, @@ -31,12 +34,13 @@ "validate": "npm run format:check && npm run lint && npm run typecheck && npm run build && npm run test" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.30.0", + "@modelcontextprotocol/server": "^2.3.1", "chalk": "^5.3.0", "yargs": "^17.7.2", - "zod": "^4.0.0" + "zod": "^4.2.0" }, "devDependencies": { + "@modelcontextprotocol/client": "^2.3.1", "@types/node": "^22", "@types/yargs": "^17.0.32", "@vitest/coverage-v8": "^4.1.8", From ccab68c44a7c29d176c9d52752dc4531a99edba5 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 7 Oct 2026 12:28:29 -0400 Subject: [PATCH 003/117] docs(skills): the TypeScript servers are on SDK v2 but still legacy-era only (#4856) Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .claude/skills/client-smoke/SKILL.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.claude/skills/client-smoke/SKILL.md b/.claude/skills/client-smoke/SKILL.md index df76bc1b1d..a650902175 100644 --- a/.claude/skills/client-smoke/SKILL.md +++ b/.claude/skills/client-smoke/SKILL.md @@ -172,9 +172,10 @@ CLI) or **Server Settings → Protocol Era** (the web UI): | `modern` | Pins 2026-07-28 through `server/discover`, with no fallback | | `auto` | Probes `server/discover`, and falls back to `initialize` | -⚠️ **Only the legacy era can be exercised against these servers today.** All -seven are on a v1 SDK (`@modelcontextprotocol/sdk` 1.x, `mcp` 1.x), whose -newest protocol version is 2025-11-25. None implements `server/discover`. So: +⚠️ **Only the legacy era can be exercised against these servers today.** The +Python servers are on `mcp` 1.x, whose newest protocol version is 2025-11-25. +The TypeScript servers are on the TS SDK v2 packages (#4856) but still serve +only the `initialize` handshake. None implements `server/discover`. So: - `--protocol-era modern` fails, by design, with `Version negotiation failed: the server did not offer pinned protocol version 2026-07-28 via @@ -259,7 +260,7 @@ Claude Code, run this way, has no era switch and does not print the protocol version it negotiated. `/pr-flow` asks for the LLM client in both eras as well; where the client gives no way to choose, record the client and its version and say that the era could not be selected, rather than labelling the run with an -era it was not shown to use. Against a v1-SDK server the connection is legacy +era it was not shown to use. Against a server without modern-era support the connection is legacy whatever the client prefers, for the reason given under Protocol eras. ## What to write down From d13408dcdc0845d4ce1d323c7a05adf309852e31 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 7 Oct 2026 12:34:45 -0400 Subject: [PATCH 004/117] Serve only the legacy era from the Python servers SDK v2's Server.run() drives a dual-era loop, so the port also answered server/discover and 2026-07-28 per-request envelopes. That changes the wire, and adopting 2026-07-28 is #4853, not #4851. Each server now runs the SDK's handshake-only serve_loop (what run() uses minus era routing) inside the server lifespan, so an enveloped request gets "Method not found" as it did on SDK v1. Each suite gains a protocol test pinning that refusal; git's raise_exceptions test (#4658) now patches serve_loop instead of Server.run. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/fetch/src/mcp_server_fetch/server.py | 14 +++++- src/fetch/tests/test_protocol.py | 61 ++++++++++++++++++++++- src/git/src/mcp_server_git/server.py | 14 +++++- src/git/tests/test_protocol.py | 62 +++++++++++++++++++++++- src/git/tests/test_server.py | 8 ++- src/time/src/mcp_server_time/server.py | 14 +++++- src/time/tests/test_protocol.py | 54 +++++++++++++++++++++ 7 files changed, 220 insertions(+), 7 deletions(-) diff --git a/src/fetch/src/mcp_server_fetch/server.py b/src/fetch/src/mcp_server_fetch/server.py index 2adb74dcaf..ede3bd31b0 100644 --- a/src/fetch/src/mcp_server_fetch/server.py +++ b/src/fetch/src/mcp_server_fetch/server.py @@ -12,6 +12,7 @@ import readabilipy.simple_json from mcp.shared.exceptions import MCPError from mcp.server import Server, ServerRequestContext +from mcp.server.runner import serve_loop from mcp.server.stdio import stdio_server from mcp.types import ( CallToolRequestParams, @@ -536,4 +537,15 @@ async def get_prompt( options = server.create_initialization_options() async with stdio_server() as (read_stream, write_stream): - await server.run(read_stream, write_stream, options, raise_exceptions=False) + # Legacy era only. Server.run() would also serve 2026-07-28 (its + # dual-era loop answers server/discover and per-request envelopes); + # adopting that era is #4853, so this port (#4851) serves the + # handshake loop alone and keeps the wire unchanged. + async with server.lifespan(server) as lifespan_state: + await serve_loop( + server, + read_stream, + write_stream, + lifespan_state=lifespan_state, + init_options=options, + ) diff --git a/src/fetch/tests/test_protocol.py b/src/fetch/tests/test_protocol.py index b24f739025..935e3b018e 100644 --- a/src/fetch/tests/test_protocol.py +++ b/src/fetch/tests/test_protocol.py @@ -16,18 +16,25 @@ from __future__ import annotations import os +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager from importlib.metadata import version from typing import Any +from unittest.mock import patch +import anyio import httpx import pytest from mcp import ClientSession from mcp.shared.exceptions import MCPError -from mcp.types import INVALID_PARAMS, CallToolResult +from mcp.shared.memory import create_client_server_memory_streams +from mcp.shared.message import SessionMessage +from mcp.types import INVALID_PARAMS, METHOD_NOT_FOUND, CallToolResult, JSONRPCRequest from mcp_server_fetch.server import ( DEFAULT_USER_AGENT_AUTONOMOUS, DEFAULT_USER_AGENT_MANUAL, + serve, ) from .harness import FakeWeb, connect @@ -998,3 +1005,55 @@ async def test_get_prompt_does_not_validate_the_url( assert data["messages"][0]["content"]["text"].startswith( "Failed to fetch not a url: UnsupportedProtocol(" ) + + +# --------------------------------------------------------------------------- +# Protocol era +# --------------------------------------------------------------------------- + + +async def test_modern_era_is_not_served() -> None: + # #4851: the SDK v2 port serves the 2025-11-25 handshake era only. + # Server.run() would also open a 2026-07-28 connection for a request that + # carries the per-request `_meta` envelope; adopting that era is #4853. + # Until then such a request is refused as it was on SDK v1. + discover = JSONRPCRequest( + jsonrpc="2.0", + id=1, + method="server/discover", + params={ + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": {"name": "test", "version": "0"}, + "io.modelcontextprotocol/clientCapabilities": {}, + } + }, + ) + response: SessionMessage | Exception | None = None + async with create_client_server_memory_streams() as ( + (client_read, client_write), + server_streams, + ): + + @asynccontextmanager + async def fake_stdio_server() -> AsyncIterator[Any]: + yield server_streams + + with patch("mcp_server_fetch.server.stdio_server", fake_stdio_server): + async with anyio.create_task_group() as tg: + tg.start_soon(serve) + await client_write.send(SessionMessage(discover)) + with anyio.fail_after(5): + response = await client_read.receive() + tg.cancel_scope.cancel() + + assert isinstance(response, SessionMessage) + assert response.message.model_dump(by_alias=True, exclude_none=True) == { + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": METHOD_NOT_FOUND, + "message": "Method not found", + "data": "server/discover", + }, + } diff --git a/src/git/src/mcp_server_git/server.py b/src/git/src/mcp_server_git/server.py index a43ad717e7..34716ec9b8 100644 --- a/src/git/src/mcp_server_git/server.py +++ b/src/git/src/mcp_server_git/server.py @@ -4,6 +4,7 @@ from typing import Any, Optional import jsonschema from mcp.server import Server, ServerRequestContext +from mcp.server.runner import serve_loop from mcp.server.stdio import stdio_server from mcp.types import ( CallToolRequestParams, @@ -760,4 +761,15 @@ async def call_tool( ) options = server.create_initialization_options() async with stdio_server() as (read_stream, write_stream): - await server.run(read_stream, write_stream, options) + # Legacy era only. Server.run() would also serve 2026-07-28 (its + # dual-era loop answers server/discover and per-request envelopes); + # adopting that era is #4853, so this port (#4851) serves the + # handshake loop alone and keeps the wire unchanged. + async with server.lifespan(server) as lifespan_state: + await serve_loop( + server, + read_stream, + write_stream, + lifespan_state=lifespan_state, + init_options=options, + ) diff --git a/src/git/tests/test_protocol.py b/src/git/tests/test_protocol.py index 39c0bc749f..77a054b2a9 100644 --- a/src/git/tests/test_protocol.py +++ b/src/git/tests/test_protocol.py @@ -10,17 +10,25 @@ import json import re +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager from importlib.metadata import version from pathlib import Path from typing import Any from unittest import mock +from unittest.mock import patch +import anyio import git import pytest from mcp.client.session import ClientRequestContext -from mcp.types import ListRootsResult, Root +from mcp.shared.memory import create_client_server_memory_streams +from mcp.shared.message import SessionMessage +from mcp.types import METHOD_NOT_FOUND, JSONRPCRequest, ListRootsResult, Root from pydantic import FileUrl +from mcp_server_git.server import serve + from conftest import connect, make_repo, text_result, wire SNAPSHOTS = Path(__file__).parent / "snapshots" @@ -1146,3 +1154,55 @@ async def test_server_without_client_roots_behaves_the_same(repo: git.Repo): await session.call_tool("git_status", {"repo_path": str(root_of(repo))}) ) assert result["isError"] is False + + +# --------------------------------------------------------------------------- +# Protocol era +# --------------------------------------------------------------------------- + + +async def test_modern_era_is_not_served() -> None: + # #4851: the SDK v2 port serves the 2025-11-25 handshake era only. + # Server.run() would also open a 2026-07-28 connection for a request that + # carries the per-request `_meta` envelope; adopting that era is #4853. + # Until then such a request is refused as it was on SDK v1. + discover = JSONRPCRequest( + jsonrpc="2.0", + id=1, + method="server/discover", + params={ + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": {"name": "test", "version": "0"}, + "io.modelcontextprotocol/clientCapabilities": {}, + } + }, + ) + response: SessionMessage | Exception | None = None + async with create_client_server_memory_streams() as ( + (client_read, client_write), + server_streams, + ): + + @asynccontextmanager + async def fake_stdio_server() -> AsyncIterator[Any]: + yield server_streams + + with patch("mcp_server_git.server.stdio_server", fake_stdio_server): + async with anyio.create_task_group() as tg: + tg.start_soon(serve, None) + await client_write.send(SessionMessage(discover)) + with anyio.fail_after(5): + response = await client_read.receive() + tg.cancel_scope.cancel() + + assert isinstance(response, SessionMessage) + assert response.message.model_dump(by_alias=True, exclude_none=True) == { + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": METHOD_NOT_FOUND, + "message": "Method not found", + "data": "server/discover", + }, + } diff --git a/src/git/tests/test_server.py b/src/git/tests/test_server.py index b827ee001a..9270d52ae0 100644 --- a/src/git/tests/test_server.py +++ b/src/git/tests/test_server.py @@ -853,7 +853,11 @@ def test_git_log_date_filtering(test_repository): async def test_serve_run_does_not_raise_exceptions(tmp_path: Path): - """Verify that serve() runs server.run without raise_exceptions=True.""" + """Verify that serve() runs the server loop without raise_exceptions=True. + + serve() drives SDK v2's legacy-only serve_loop rather than Server.run + (#4851), so that is what is patched. + """ repo_path = tmp_path / "serve_test_repo" git.Repo.init(repo_path).close() @@ -864,7 +868,7 @@ async def test_serve_run_does_not_raise_exceptions(tmp_path: Path): mock_stdio.return_value.__aexit__.return_value = None with mock.patch( - "mcp_server_git.server.Server.run", new_callable=mock.AsyncMock + "mcp_server_git.server.serve_loop", new_callable=mock.AsyncMock ) as mock_run: await serve(repo_path) mock_run.assert_awaited_once() diff --git a/src/time/src/mcp_server_time/server.py b/src/time/src/mcp_server_time/server.py index 57fb2763df..3bbc8690b5 100644 --- a/src/time/src/mcp_server_time/server.py +++ b/src/time/src/mcp_server_time/server.py @@ -10,6 +10,7 @@ import jsonschema from mcp.server import Server, ServerRequestContext +from mcp.server.runner import serve_loop from mcp.server.stdio import stdio_server from mcp.types import ( CallToolRequestParams, @@ -292,4 +293,15 @@ async def call_tool( ) options = server.create_initialization_options() async with stdio_server() as (read_stream, write_stream): - await server.run(read_stream, write_stream, options) + # Legacy era only. Server.run() would also serve 2026-07-28 (its + # dual-era loop answers server/discover and per-request envelopes); + # adopting that era is #4853, so this port (#4851) serves the + # handshake loop alone and keeps the wire unchanged. + async with server.lifespan(server) as lifespan_state: + await serve_loop( + server, + read_stream, + write_stream, + lifespan_state=lifespan_state, + init_options=options, + ) diff --git a/src/time/tests/test_protocol.py b/src/time/tests/test_protocol.py index e3f99f6d9e..de5b7aa649 100644 --- a/src/time/tests/test_protocol.py +++ b/src/time/tests/test_protocol.py @@ -33,6 +33,8 @@ import pytest from mcp import ClientSession from mcp.shared.memory import create_client_server_memory_streams +from mcp.shared.message import SessionMessage +from mcp.types import METHOD_NOT_FOUND, JSONRPCRequest from pydantic import BaseModel from mcp_server_time.server import serve @@ -920,3 +922,55 @@ async def test_session_survives_errors() -> None: await call(session, "get_current_time", {"timezone": "Bad/Zone"}) result = await call(session, "get_current_time", {"timezone": "UTC"}) assert result["isError"] is False + + +# --------------------------------------------------------------------------- +# Protocol era +# --------------------------------------------------------------------------- + + +async def test_modern_era_is_not_served() -> None: + # #4851: the SDK v2 port serves the 2025-11-25 handshake era only. + # Server.run() would also open a 2026-07-28 connection for a request that + # carries the per-request `_meta` envelope; adopting that era is #4853. + # Until then such a request is refused as it was on SDK v1. + discover = JSONRPCRequest( + jsonrpc="2.0", + id=1, + method="server/discover", + params={ + "_meta": { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": {"name": "test", "version": "0"}, + "io.modelcontextprotocol/clientCapabilities": {}, + } + }, + ) + response: SessionMessage | Exception | None = None + async with create_client_server_memory_streams() as ( + (client_read, client_write), + server_streams, + ): + + @asynccontextmanager + async def fake_stdio_server() -> AsyncIterator[Any]: + yield server_streams + + with patch("mcp_server_time.server.stdio_server", fake_stdio_server): + async with anyio.create_task_group() as tg: + tg.start_soon(serve, "UTC") + await client_write.send(SessionMessage(discover)) + with anyio.fail_after(5): + response = await client_read.receive() + tg.cancel_scope.cancel() + + assert isinstance(response, SessionMessage) + assert response.message.model_dump(by_alias=True, exclude_none=True) == { + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": METHOD_NOT_FOUND, + "message": "Method not found", + "data": "server/discover", + }, + } From b614ecc260b9e1d3f0f592a947919bb163034285 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 7 Oct 2026 12:36:22 -0400 Subject: [PATCH 005/117] fix: address Copilot round 1 on #5063 Drop the stale taskStore references from the testing skill, fix a stray asterisk in tasks.test.ts's header, and rename memory's DRAFT_07 schema constant to JSON_SCHEMA_2020_12 now that it holds the 2020-12 URI. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .claude/skills/testing/SKILL.md | 6 ++--- src/everything/__tests__/tasks.test.ts | 3 ++- src/memory/__tests__/tools.test.ts | 34 ++++++++++++++------------ 3 files changed, 24 insertions(+), 19 deletions(-) diff --git a/.claude/skills/testing/SKILL.md b/.claude/skills/testing/SKILL.md index bfaecb72d8..1527dbce82 100644 --- a/.claude/skills/testing/SKILL.md +++ b/.claude/skills/testing/SKILL.md @@ -72,7 +72,7 @@ per-session setup and teardown the server needs. | Server | Factory | Harness | `connect(…)` takes | | --- | --- | --- | --- | -| `everything` | `createServer()` in `server/index.ts`, returns `{ server, cleanup }` | `__tests__/harness.ts` | `{ capabilities, taskStore, setup, sessionId }`, all optional | +| `everything` | `createServer()` in `server/index.ts`, returns `{ server, cleanup }` | `__tests__/harness.ts` | `{ capabilities, setup, sessionId }`, all optional | | `filesystem` | `createServer(allowedDirectories)` in `server.ts` | `__tests__/helpers.ts` | the allowed directories, then `{ capabilities, listRoots }` | | `memory` | `createServer(memoryFilePath)` in `index.ts` | `__tests__/helpers.ts` | the graph file's path (`makeTempGraph()` makes one) | | `sequentialthinking` | `createServer()` in `index.ts` | `__tests__/helpers.ts` | `{ disableThoughtLogging }`, defaulting to `"true"` | @@ -123,12 +123,12 @@ What `everything`'s harness does that a hand-rolled connection would miss: next test. `connect()` sets a fresh `sessionId` on the server transport (`sessionId: null` keeps it undefined, as stdio does), and `close()` runs the server's `cleanup(sessionId)`, which also stops the timers `createServer()` - starts (the roots sync, the task store). + starts (the roots sync). - **Capabilities.** A tool `everything` registers in `registerConditionalTools` appears only when the client declares the matching capability: pass `capabilities` (`ALL_CAPABILITIES` declares every one), with `setup` to install the client's handlers for the requests the server sends back - (sampling, elicitation, roots) and `taskStore` for the task-augmented ones. + (sampling, elicitation, roots). - **Notifications.** `session.notifications` collects every notification no specific handler consumed; `ofMethod(notifications, method)` filters it. diff --git a/src/everything/__tests__/tasks.test.ts b/src/everything/__tests__/tasks.test.ts index 1d8318b395..d90c266f3f 100644 --- a/src/everything/__tests__/tasks.test.ts +++ b/src/everything/__tests__/tasks.test.ts @@ -11,7 +11,8 @@ * capabilities and keeps its tasks in an `InMemoryTaskStore`. * * Every wait in these tools is a `setTimeout` (one-second stages and polls), - * so the tests fake `setTimeout` and advance the clock. * + * so the tests fake `setTimeout` and advance the clock. + * * SDK v2 removed the experimental tasks layer these tools and tests were * built on (SEP-2663), so the three tools are gone and every test here is * skipped until Part 5 (#4852) re-implements them on the diff --git a/src/memory/__tests__/tools.test.ts b/src/memory/__tests__/tools.test.ts index 537fa3b4f8..170d5933e5 100644 --- a/src/memory/__tests__/tools.test.ts +++ b/src/memory/__tests__/tools.test.ts @@ -8,7 +8,7 @@ import type { Client } from "@modelcontextprotocol/client"; import { call, connect, makeTempGraph, textOf } from "./helpers.js"; import type { Connection } from "./helpers.js"; -const DRAFT_07 = "https://json-schema.org/draft/2020-12/schema"; +const JSON_SCHEMA_2020_12 = "https://json-schema.org/draft/2020-12/schema"; // JSON Schema fragments as the SDK emits them. Output schemas close every // object with additionalProperties: false; input schemas leave them open. @@ -56,7 +56,7 @@ const graphOutputSchema = { relations: { type: "array", items: relationItem(true) }, }, required: ["entities", "relations"], - $schema: DRAFT_07, + $schema: JSON_SCHEMA_2020_12, additionalProperties: false, }; @@ -64,7 +64,7 @@ const statusOutputSchema = { type: "object", properties: { success: { type: "boolean" }, message: { type: "string" } }, required: ["success", "message"], - $schema: DRAFT_07, + $schema: JSON_SCHEMA_2020_12, additionalProperties: false, }; @@ -101,7 +101,7 @@ const expectedTools = [ type: "object", properties: { entities: { type: "array", items: entityItem(false) } }, required: ["entities"], - $schema: DRAFT_07, + $schema: JSON_SCHEMA_2020_12, }, outputSchema: { type: "object", @@ -110,7 +110,7 @@ const expectedTools = [ skipped: { type: "array", items: { type: "string" } }, }, required: ["entities"], - $schema: DRAFT_07, + $schema: JSON_SCHEMA_2020_12, additionalProperties: false, }, annotations: writeAnnotations, @@ -125,13 +125,13 @@ const expectedTools = [ type: "object", properties: { relations: { type: "array", items: relationItem(false) } }, required: ["relations"], - $schema: DRAFT_07, + $schema: JSON_SCHEMA_2020_12, }, outputSchema: { type: "object", properties: { relations: { type: "array", items: relationItem(true) } }, required: ["relations"], - $schema: DRAFT_07, + $schema: JSON_SCHEMA_2020_12, additionalProperties: false, }, annotations: writeAnnotations, @@ -166,7 +166,7 @@ const expectedTools = [ }, }, required: ["observations"], - $schema: DRAFT_07, + $schema: JSON_SCHEMA_2020_12, }, outputSchema: { type: "object", @@ -185,7 +185,7 @@ const expectedTools = [ }, }, required: ["results"], - $schema: DRAFT_07, + $schema: JSON_SCHEMA_2020_12, additionalProperties: false, }, annotations: writeAnnotations, @@ -206,7 +206,7 @@ const expectedTools = [ }, }, required: ["entityNames"], - $schema: DRAFT_07, + $schema: JSON_SCHEMA_2020_12, }, outputSchema: statusOutputSchema, annotations: deleteAnnotations, @@ -241,7 +241,7 @@ const expectedTools = [ }, }, required: ["deletions"], - $schema: DRAFT_07, + $schema: JSON_SCHEMA_2020_12, }, outputSchema: statusOutputSchema, annotations: deleteAnnotations, @@ -261,7 +261,7 @@ const expectedTools = [ }, }, required: ["relations"], - $schema: DRAFT_07, + $schema: JSON_SCHEMA_2020_12, }, outputSchema: statusOutputSchema, annotations: deleteAnnotations, @@ -271,7 +271,11 @@ const expectedTools = [ name: "read_graph", title: "Read Graph", description: "Read the entire knowledge graph", - inputSchema: { type: "object", properties: {}, $schema: DRAFT_07 }, + inputSchema: { + type: "object", + properties: {}, + $schema: JSON_SCHEMA_2020_12, + }, outputSchema: graphOutputSchema, annotations: readAnnotations, execution, @@ -291,7 +295,7 @@ const expectedTools = [ }, }, required: ["query"], - $schema: DRAFT_07, + $schema: JSON_SCHEMA_2020_12, }, outputSchema: graphOutputSchema, annotations: readAnnotations, @@ -311,7 +315,7 @@ const expectedTools = [ }, }, required: ["names"], - $schema: DRAFT_07, + $schema: JSON_SCHEMA_2020_12, }, outputSchema: graphOutputSchema, annotations: readAnnotations, From 019d40cdb797c36ffd75ffc2c9205c265b4990c1 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 7 Oct 2026 12:42:34 -0400 Subject: [PATCH 006/117] Add purpose headers and rename two tests after the SDK v2 port Each server.py's handler and transport layer was rewritten for SDK v2, so it now opens with a purpose header stating what the file is for and the legacy-wire constraint. Two test names that pointed at the old component now name the one they exercise: call_tool's schema check, and serve_loop. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/fetch/src/mcp_server_fetch/server.py | 12 ++++++++++++ src/fetch/tests/test_protocol.py | 2 +- src/git/src/mcp_server_git/server.py | 11 +++++++++++ src/git/tests/test_server.py | 2 +- src/time/src/mcp_server_time/server.py | 10 ++++++++++ 5 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/fetch/src/mcp_server_fetch/server.py b/src/fetch/src/mcp_server_fetch/server.py index ede3bd31b0..d91a9efd17 100644 --- a/src/fetch/src/mcp_server_fetch/server.py +++ b/src/fetch/src/mcp_server_fetch/server.py @@ -1,3 +1,15 @@ +# The fetch MCP server: one `fetch` tool and one `fetch` prompt that retrieve a +# URL and convert HTML to Markdown for a model. The network policy lives here +# beside the handlers because every request path (tool, prompt and robots.txt +# check) must go through it: the private-address guard on each request and +# redirect hop, robots.txt for autonomous fetches, and proxy normalization. +# +# Built on the MCP Python SDK v2's low-level `Server` (#4851). That SDK no +# longer validates tool arguments or folds tool exceptions into `isError` +# results, so `call_tool` does both with SDK v1's messages, and `serve()` runs +# the legacy (2025-11-25) handshake loop only, keeping the wire as it was on v1. +# Serving 2026-07-28 is #4853. + import asyncio import ipaddress import os diff --git a/src/fetch/tests/test_protocol.py b/src/fetch/tests/test_protocol.py index 935e3b018e..fcb92dff6b 100644 --- a/src/fetch/tests/test_protocol.py +++ b/src/fetch/tests/test_protocol.py @@ -202,7 +202,7 @@ async def test_call_with_only_url_applies_defaults(web: FakeWeb) -> None: ({"url": PAGE, "raw": "yes"}, "'yes'"), ], ) -async def test_schema_violations_are_rejected_by_the_sdk( +async def test_schema_violations_are_rejected_by_call_tool( web: FakeWeb, arguments: dict[str, Any], offending: str ) -> None: # call_tool validates against inputSchema before the tool runs. The rest diff --git a/src/git/src/mcp_server_git/server.py b/src/git/src/mcp_server_git/server.py index 34716ec9b8..5b75260e4f 100644 --- a/src/git/src/mcp_server_git/server.py +++ b/src/git/src/mcp_server_git/server.py @@ -1,3 +1,14 @@ +# The git MCP server: tools that read and change a Git repository through +# GitPython. Repository confinement (`--repository`, `validate_repo_path`) and +# the flag-injection guards live beside the handlers because every tool call +# must pass them before any git command runs. +# +# Built on the MCP Python SDK v2's low-level `Server` (#4851). That SDK no +# longer validates tool arguments or folds tool exceptions into `isError` +# results, so `call_tool` does both with SDK v1's messages, and `serve()` runs +# the legacy (2025-11-25) handshake loop only, keeping the wire as it was on v1. +# Serving 2026-07-28 is #4853. + import logging from importlib.metadata import version from pathlib import Path diff --git a/src/git/tests/test_server.py b/src/git/tests/test_server.py index 9270d52ae0..0a6ddb0e08 100644 --- a/src/git/tests/test_server.py +++ b/src/git/tests/test_server.py @@ -852,7 +852,7 @@ def test_git_log_date_filtering(test_repository): assert len(valid_result) == 1 -async def test_serve_run_does_not_raise_exceptions(tmp_path: Path): +async def test_serve_loop_does_not_raise_exceptions(tmp_path: Path): """Verify that serve() runs the server loop without raise_exceptions=True. serve() drives SDK v2's legacy-only serve_loop rather than Server.run diff --git a/src/time/src/mcp_server_time/server.py b/src/time/src/mcp_server_time/server.py index 3bbc8690b5..8f80717cac 100644 --- a/src/time/src/mcp_server_time/server.py +++ b/src/time/src/mcp_server_time/server.py @@ -1,3 +1,13 @@ +# The time MCP server: `get_current_time` and `convert_time` over IANA +# timezones, with the local timezone detected (or set with --local-timezone) +# and named in the tool descriptions so a model can default to it. +# +# Built on the MCP Python SDK v2's low-level `Server` (#4851). That SDK no +# longer validates tool arguments or folds tool exceptions into `isError` +# results, so `call_tool` does both with SDK v1's messages, and `serve()` runs +# the legacy (2025-11-25) handshake loop only, keeping the wire as it was on v1. +# Serving 2026-07-28 is #4853. + from datetime import datetime, timedelta from importlib.metadata import version from enum import Enum From 2ca5de703ae1167adb52c9003229adf6a69f007c Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 7 Oct 2026 12:43:40 -0400 Subject: [PATCH 007/117] chore(changeset): drop pending notes for the task tools #4856 removes everything-async-task-tools.md described fixes to two tools this release no longer ships, and everything-conditional-tools-4792.md named one of them; keep only what consumers receive (Copilot round 2 on #5063). Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .changeset/everything-async-task-tools.md | 5 ----- .changeset/everything-conditional-tools-4792.md | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) delete mode 100644 .changeset/everything-async-task-tools.md diff --git a/.changeset/everything-async-task-tools.md b/.changeset/everything-async-task-tools.md deleted file mode 100644 index ed0f3bc84a..0000000000 --- a/.changeset/everything-async-task-tools.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-everything": patch ---- - -Fix the async task tools: `trigger-elicitation-request-async` now stops polling before the 10-minute TTL it requests for the client's task runs out, instead of polling past it and failing on a task the client has expired (#4986); and `trigger-sampling-request-async` reports the status message of a client task that is already finished when it is created, instead of "No message" (#4987). `trigger-elicitation-request-async` had the same flaw and gets the same fix. diff --git a/.changeset/everything-conditional-tools-4792.md b/.changeset/everything-conditional-tools-4792.md index 7e9a6c5656..86d7a5c556 100644 --- a/.changeset/everything-conditional-tools-4792.md +++ b/.changeset/everything-conditional-tools-4792.md @@ -2,4 +2,4 @@ "@modelcontextprotocol/server-everything": patch --- -Server instructions no longer tell every client to use tools that are registered only for clients declaring a capability; they list those tools and the capability each needs (#4792, ported from #4835). `trigger-elicitation-request` and `trigger-elicitation-request-async` are now registered only for clients that support form-mode elicitation, so a client that declared only URL-mode elicitation no longer sees tools it cannot answer (#4985). +Server instructions no longer tell every client to use tools that are registered only for clients declaring a capability; they list those tools and the capability each needs (#4792, ported from #4835). `trigger-elicitation-request` is now registered only for clients that support form-mode elicitation, so a client that declared only URL-mode elicitation no longer sees tools it cannot answer (#4985). From 92eca9c72b54fbf4a02e0bf7251d55c101af67a2 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 7 Oct 2026 12:43:50 -0400 Subject: [PATCH 008/117] chore(changeset): match the conditional-tools note to its one remaining tool Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .changeset/everything-conditional-tools-4792.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/everything-conditional-tools-4792.md b/.changeset/everything-conditional-tools-4792.md index 86d7a5c556..0b07bc7238 100644 --- a/.changeset/everything-conditional-tools-4792.md +++ b/.changeset/everything-conditional-tools-4792.md @@ -2,4 +2,4 @@ "@modelcontextprotocol/server-everything": patch --- -Server instructions no longer tell every client to use tools that are registered only for clients declaring a capability; they list those tools and the capability each needs (#4792, ported from #4835). `trigger-elicitation-request` is now registered only for clients that support form-mode elicitation, so a client that declared only URL-mode elicitation no longer sees tools it cannot answer (#4985). +Server instructions no longer tell every client to use tools that are registered only for clients declaring a capability; they list those tools and the capability each needs (#4792, ported from #4835). `trigger-elicitation-request` is now registered only for clients that support form-mode elicitation, so a client that declared only URL-mode elicitation no longer sees a tool it cannot answer (#4985). From 623a8dfd436fa1a2b54c791902527fc22a742962 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 7 Oct 2026 12:49:09 -0400 Subject: [PATCH 009/117] Call out the unknown-method error code change from SDK v2 SDK v1 answered every unknown method with -32602 "Invalid request parameters" (its request union failed to parse); SDK v2 answers -32601 "Method not found", the code JSON-RPC defines. The era tests' comments claimed v1 parity, and the server headers and serve() comments claimed an unchanged wire. Correct them to say what holds (tool results are unchanged) and record the error code as a behavior change. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/fetch/src/mcp_server_fetch/server.py | 10 +++++----- src/fetch/tests/test_protocol.py | 7 ++++++- src/git/src/mcp_server_git/server.py | 10 +++++----- src/git/tests/test_protocol.py | 7 ++++++- src/time/src/mcp_server_time/server.py | 10 +++++----- src/time/tests/test_protocol.py | 7 ++++++- 6 files changed, 33 insertions(+), 18 deletions(-) diff --git a/src/fetch/src/mcp_server_fetch/server.py b/src/fetch/src/mcp_server_fetch/server.py index d91a9efd17..7c338f839e 100644 --- a/src/fetch/src/mcp_server_fetch/server.py +++ b/src/fetch/src/mcp_server_fetch/server.py @@ -6,9 +6,9 @@ # # Built on the MCP Python SDK v2's low-level `Server` (#4851). That SDK no # longer validates tool arguments or folds tool exceptions into `isError` -# results, so `call_tool` does both with SDK v1's messages, and `serve()` runs -# the legacy (2025-11-25) handshake loop only, keeping the wire as it was on v1. -# Serving 2026-07-28 is #4853. +# results, so `call_tool` does both with SDK v1's messages, keeping tool +# results as they were on v1. `serve()` runs the legacy (2025-11-25) handshake +# loop only; serving 2026-07-28 is #4853. import asyncio import ipaddress @@ -488,7 +488,7 @@ async def call_tool( ) -> CallToolResult: # SDK v2's low-level server neither validates arguments nor turns a # handler exception into an isError result, both of which SDK v1 did. - # Both are done here, with v1's messages, so the wire is unchanged. + # Both are done here, with v1's messages, so tool results are unchanged. arguments = params.arguments or {} if params.name == tool.name: try: @@ -552,7 +552,7 @@ async def get_prompt( # Legacy era only. Server.run() would also serve 2026-07-28 (its # dual-era loop answers server/discover and per-request envelopes); # adopting that era is #4853, so this port (#4851) serves the - # handshake loop alone and keeps the wire unchanged. + # handshake loop alone, the only era it served on SDK v1. async with server.lifespan(server) as lifespan_state: await serve_loop( server, diff --git a/src/fetch/tests/test_protocol.py b/src/fetch/tests/test_protocol.py index fcb92dff6b..0a2734a19b 100644 --- a/src/fetch/tests/test_protocol.py +++ b/src/fetch/tests/test_protocol.py @@ -1016,7 +1016,12 @@ async def test_modern_era_is_not_served() -> None: # #4851: the SDK v2 port serves the 2025-11-25 handshake era only. # Server.run() would also open a 2026-07-28 connection for a request that # carries the per-request `_meta` envelope; adopting that era is #4853. - # Until then such a request is refused as it was on SDK v1. + # Until then such a request is refused as an unknown method. + # + # Behavior change in the SDK v2 port: SDK v1 answered every unknown + # method with -32602 "Invalid request parameters" (its request union + # failed to parse); SDK v2 answers -32601 "Method not found", the code + # JSON-RPC defines for it. discover = JSONRPCRequest( jsonrpc="2.0", id=1, diff --git a/src/git/src/mcp_server_git/server.py b/src/git/src/mcp_server_git/server.py index 5b75260e4f..90262e8b55 100644 --- a/src/git/src/mcp_server_git/server.py +++ b/src/git/src/mcp_server_git/server.py @@ -5,9 +5,9 @@ # # Built on the MCP Python SDK v2's low-level `Server` (#4851). That SDK no # longer validates tool arguments or folds tool exceptions into `isError` -# results, so `call_tool` does both with SDK v1's messages, and `serve()` runs -# the legacy (2025-11-25) handshake loop only, keeping the wire as it was on v1. -# Serving 2026-07-28 is #4853. +# results, so `call_tool` does both with SDK v1's messages, keeping tool +# results as they were on v1. `serve()` runs the legacy (2025-11-25) handshake +# loop only; serving 2026-07-28 is #4853. import logging from importlib.metadata import version @@ -751,7 +751,7 @@ async def call_tool( ) -> CallToolResult: # SDK v2's low-level server neither validates arguments nor turns a # handler exception into an isError result, both of which SDK v1 did. - # Both are done here, with v1's messages, so the wire is unchanged. + # Both are done here, with v1's messages, so tool results are unchanged. arguments = params.arguments or {} schema = schemas.get(params.name) if schema is not None: @@ -775,7 +775,7 @@ async def call_tool( # Legacy era only. Server.run() would also serve 2026-07-28 (its # dual-era loop answers server/discover and per-request envelopes); # adopting that era is #4853, so this port (#4851) serves the - # handshake loop alone and keeps the wire unchanged. + # handshake loop alone, the only era it served on SDK v1. async with server.lifespan(server) as lifespan_state: await serve_loop( server, diff --git a/src/git/tests/test_protocol.py b/src/git/tests/test_protocol.py index 77a054b2a9..c9b8e5b3e6 100644 --- a/src/git/tests/test_protocol.py +++ b/src/git/tests/test_protocol.py @@ -1165,7 +1165,12 @@ async def test_modern_era_is_not_served() -> None: # #4851: the SDK v2 port serves the 2025-11-25 handshake era only. # Server.run() would also open a 2026-07-28 connection for a request that # carries the per-request `_meta` envelope; adopting that era is #4853. - # Until then such a request is refused as it was on SDK v1. + # Until then such a request is refused as an unknown method. + # + # Behavior change in the SDK v2 port: SDK v1 answered every unknown + # method with -32602 "Invalid request parameters" (its request union + # failed to parse); SDK v2 answers -32601 "Method not found", the code + # JSON-RPC defines for it. discover = JSONRPCRequest( jsonrpc="2.0", id=1, diff --git a/src/time/src/mcp_server_time/server.py b/src/time/src/mcp_server_time/server.py index 8f80717cac..9f56bb28fa 100644 --- a/src/time/src/mcp_server_time/server.py +++ b/src/time/src/mcp_server_time/server.py @@ -4,9 +4,9 @@ # # Built on the MCP Python SDK v2's low-level `Server` (#4851). That SDK no # longer validates tool arguments or folds tool exceptions into `isError` -# results, so `call_tool` does both with SDK v1's messages, and `serve()` runs -# the legacy (2025-11-25) handshake loop only, keeping the wire as it was on v1. -# Serving 2026-07-28 is #4853. +# results, so `call_tool` does both with SDK v1's messages, keeping tool +# results as they were on v1. `serve()` runs the legacy (2025-11-25) handshake +# loop only; serving 2026-07-28 is #4853. from datetime import datetime, timedelta from importlib.metadata import version @@ -282,7 +282,7 @@ async def call_tool( ) -> CallToolResult: # SDK v2's low-level server neither validates arguments nor turns a # handler exception into an isError result, both of which SDK v1 did. - # Both are done here, with v1's messages, so the wire is unchanged. + # Both are done here, with v1's messages, so tool results are unchanged. arguments = params.arguments or {} schema = schemas.get(params.name) if schema is not None: @@ -306,7 +306,7 @@ async def call_tool( # Legacy era only. Server.run() would also serve 2026-07-28 (its # dual-era loop answers server/discover and per-request envelopes); # adopting that era is #4853, so this port (#4851) serves the - # handshake loop alone and keeps the wire unchanged. + # handshake loop alone, the only era it served on SDK v1. async with server.lifespan(server) as lifespan_state: await serve_loop( server, diff --git a/src/time/tests/test_protocol.py b/src/time/tests/test_protocol.py index de5b7aa649..e2ab1d6384 100644 --- a/src/time/tests/test_protocol.py +++ b/src/time/tests/test_protocol.py @@ -933,7 +933,12 @@ async def test_modern_era_is_not_served() -> None: # #4851: the SDK v2 port serves the 2025-11-25 handshake era only. # Server.run() would also open a 2026-07-28 connection for a request that # carries the per-request `_meta` envelope; adopting that era is #4853. - # Until then such a request is refused as it was on SDK v1. + # Until then such a request is refused as an unknown method. + # + # Behavior change in the SDK v2 port: SDK v1 answered every unknown + # method with -32602 "Invalid request parameters" (its request union + # failed to parse); SDK v2 answers -32601 "Method not found", the code + # JSON-RPC defines for it. discover = JSONRPCRequest( jsonrpc="2.0", id=1, From 4f39a5456ad2176d0bdd790019154302da53a676 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 7 Oct 2026 12:52:46 -0400 Subject: [PATCH 010/117] fix: address Copilot round 3 on #5063 State the Node.js 20 floor in each server's README and note in everything's that the task tools are out until #4852. Assert that sequentialthinking advertises no execution metadata rather than skipping the test, and correct a stale v1 comment in sampling-elicitation.test.ts. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/everything/README.md | 6 ++++++ src/everything/__tests__/sampling-elicitation.test.ts | 3 ++- src/filesystem/README.md | 4 ++++ src/memory/README.md | 4 ++++ src/sequentialthinking/README.md | 4 ++++ src/sequentialthinking/__tests__/tools-list.test.ts | 10 ++++++---- 6 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/everything/README.md b/src/everything/README.md index f061e946a2..8fa2d2ebdd 100644 --- a/src/everything/README.md +++ b/src/everything/README.md @@ -13,6 +13,12 @@ This MCP server attempts to exercise all the features of the MCP protocol. It is A complete list of the registered MCP primitives and other protocol features demonstrated can be found in the [Server Features](docs/features.md) document. +The task tools (`simulate-research-query`, `trigger-sampling-request-async` and `trigger-elicitation-request-async`) are not available for now: they were built on the TypeScript SDK's experimental tasks layer, which SDK v2 removed. They return on the MCP tasks extension in [#4852](https://github.com/modelcontextprotocol/servers/issues/4852). + +## Requirements + +Node.js 20 or later, when the server runs through `npx` or from source. + ## Usage with Claude Desktop (uses [stdio Transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#stdio)) Add to your `claude_desktop_config.json`: diff --git a/src/everything/__tests__/sampling-elicitation.test.ts b/src/everything/__tests__/sampling-elicitation.test.ts index 4acbc272e1..cbb46e370c 100644 --- a/src/everything/__tests__/sampling-elicitation.test.ts +++ b/src/everything/__tests__/sampling-elicitation.test.ts @@ -479,7 +479,8 @@ describe("trigger-url-elicitation", () => { expect(error).toBeInstanceOf(ProtocolError); const mcpError = error as ProtocolError; expect(mcpError.code).toBe(-32042); - // The SDK prefixes the code twice on the client side. + // SDK v2 passes the server's message through verbatim (SDK 1.x prefixed + // "MCP error -32042: " twice on the client side). expect(mcpError.message).toBe( "This request requires browser-based authorization.", ); diff --git a/src/filesystem/README.md b/src/filesystem/README.md index d00dc9abce..8b705087dc 100644 --- a/src/filesystem/README.md +++ b/src/filesystem/README.md @@ -216,6 +216,10 @@ The mapping for filesystem tools is: > Note: `idempotentHint` and `destructiveHint` are meaningful only when `readOnlyHint` is `false`, as defined by the MCP spec. Every tool also sets `openWorldHint: false` — this server only accesses the local filesystem within its allowed directories, never an open or external world. +## Requirements + +Node.js 20 or later, when the server runs through `npx` or from source. + ## Usage with Claude Desktop Add this to your `claude_desktop_config.json`: diff --git a/src/memory/README.md b/src/memory/README.md index e5ac259fbe..ab911d4885 100644 --- a/src/memory/README.md +++ b/src/memory/README.md @@ -137,6 +137,10 @@ Example: - Returns the same shape as `read_graph` (entities and relations) - Mutation tools (`create_entities`, `create_relations`, `add_observations`, `delete_entities`, `delete_observations`, `delete_relations`) emit `notifications/resources/updated` for this URI, so subscribed clients see live changes +## Requirements + +Node.js 20 or later, when the server runs through `npx` or from source. + # Usage with Claude Desktop ### Setup diff --git a/src/sequentialthinking/README.md b/src/sequentialthinking/README.md index b6b853aa56..cbb15f7329 100644 --- a/src/sequentialthinking/README.md +++ b/src/sequentialthinking/README.md @@ -69,6 +69,10 @@ After installing the server in your MCP host: 3. Ask the host to solve a non-trivial problem in a step-by-step way. 4. Verify that the host invokes the tool multiple times instead of returning a one-shot answer. +## Requirements + +Node.js 20 or later, when the server runs through `npx` or from source. + ## Configuration ### Usage with Claude Desktop diff --git a/src/sequentialthinking/__tests__/tools-list.test.ts b/src/sequentialthinking/__tests__/tools-list.test.ts index 69ef0455a4..38d0b2da9e 100644 --- a/src/sequentialthinking/__tests__/tools-list.test.ts +++ b/src/sequentialthinking/__tests__/tools-list.test.ts @@ -64,10 +64,12 @@ describe("sequentialthinking: initialize and tools/list", () => { }); }); - // SDK v2 removed the experimental tasks layer and no longer advertises - // `execution.taskSupport`; Part 5 (#4852) restores task semantics. - it.skip("forbids task-augmented execution", () => { - expect(tool.execution).toEqual({ taskSupport: "forbidden" }); + // SDK 1.x advertised `execution: { taskSupport: "forbidden" }`. SDK v2 + // removed the experimental tasks layer and advertises no `execution` at + // all; Part 5 (#4852) decides what the tool advertises under the tasks + // extension. + it("advertises no task-execution metadata", () => { + expect(tool.execution).toBeUndefined(); }); // #799: some clients cap a tool description at 1024 characters. This one is From 701c2aa8eeb3b511ce60369d6d36083fa0ca990d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 7 Oct 2026 13:09:00 -0400 Subject: [PATCH 011/117] chore(changeset): record SDK v2's 10 MiB stdio message cap (#4856) Copilot round 4 on #5063: v2's StdioServerTransport closes the connection on a single message over 10 MiB, where 1.x buffered without limit. Keep the SDK default and list it among the breaking changes. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .changeset/everything-sdk-v2.md | 2 +- .changeset/filesystem-sdk-v2.md | 2 +- .changeset/memory-sdk-v2.md | 2 +- .changeset/sequentialthinking-sdk-v2.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.changeset/everything-sdk-v2.md b/.changeset/everything-sdk-v2.md index 39558eefc6..07fe4f38ae 100644 --- a/.changeset/everything-sdk-v2.md +++ b/.changeset/everything-sdk-v2.md @@ -2,4 +2,4 @@ "@modelcontextprotocol/server-everything": major --- -Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`, `@modelcontextprotocol/node`, and `@modelcontextprotocol/server-legacy` for the deprecated HTTP+SSE transport) from `@modelcontextprotocol/sdk` 1.x (#4856). The task tools `simulate-research-query`, `trigger-sampling-request-async` and `trigger-elicitation-request-async` are removed, and the server no longer advertises the `tasks` capability, because SDK v2 dropped the experimental tasks layer they were built on; they return on the tasks extension in #4852. Breaking for clients: the server now requires Node.js 20 or later; an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); tool schemas in `tools/list` declare JSON Schema 2020-12 instead of draft-07; and tools no longer advertise `execution.taskSupport`. The optional `state` argument of `args-prompt` now carries its description. +Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`, `@modelcontextprotocol/node`, and `@modelcontextprotocol/server-legacy` for the deprecated HTTP+SSE transport) from `@modelcontextprotocol/sdk` 1.x (#4856). The task tools `simulate-research-query`, `trigger-sampling-request-async` and `trigger-elicitation-request-async` are removed, and the server no longer advertises the `tasks` capability, because SDK v2 dropped the experimental tasks layer they were built on; they return on the tasks extension in #4852. Breaking for clients: the server now requires Node.js 20 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); tool schemas in `tools/list` declare JSON Schema 2020-12 instead of draft-07; and tools no longer advertise `execution.taskSupport`. The optional `state` argument of `args-prompt` now carries its description. diff --git a/.changeset/filesystem-sdk-v2.md b/.changeset/filesystem-sdk-v2.md index c39ccdf732..523a805d57 100644 --- a/.changeset/filesystem-sdk-v2.md +++ b/.changeset/filesystem-sdk-v2.md @@ -2,4 +2,4 @@ "@modelcontextprotocol/server-filesystem": major --- -Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`) from `@modelcontextprotocol/sdk` 1.x (#4856). Breaking for clients: the server now requires Node.js 20 or later; an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); and tools no longer advertise `execution.taskSupport`. +Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`) from `@modelcontextprotocol/sdk` 1.x (#4856). Breaking for clients: the server now requires Node.js 20 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); and tools no longer advertise `execution.taskSupport`. diff --git a/.changeset/memory-sdk-v2.md b/.changeset/memory-sdk-v2.md index 9d0b41c03b..e1ab5b57df 100644 --- a/.changeset/memory-sdk-v2.md +++ b/.changeset/memory-sdk-v2.md @@ -2,4 +2,4 @@ "@modelcontextprotocol/server-memory": major --- -Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`) from `@modelcontextprotocol/sdk` 1.x (#4856). Breaking for clients: the server now requires Node.js 20 or later; an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); tool schemas in `tools/list` declare JSON Schema 2020-12 instead of draft-07; and tools no longer advertise `execution.taskSupport`. +Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`) from `@modelcontextprotocol/sdk` 1.x (#4856). Breaking for clients: the server now requires Node.js 20 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); tool schemas in `tools/list` declare JSON Schema 2020-12 instead of draft-07; and tools no longer advertise `execution.taskSupport`. diff --git a/.changeset/sequentialthinking-sdk-v2.md b/.changeset/sequentialthinking-sdk-v2.md index 4965463dd1..9e76a69e93 100644 --- a/.changeset/sequentialthinking-sdk-v2.md +++ b/.changeset/sequentialthinking-sdk-v2.md @@ -2,4 +2,4 @@ "@modelcontextprotocol/server-sequential-thinking": major --- -Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`) from `@modelcontextprotocol/sdk` 1.x (#4856). Breaking for clients: the server now requires Node.js 20 or later; an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); tool schemas in `tools/list` declare JSON Schema 2020-12 instead of draft-07; and tools no longer advertise `execution.taskSupport`. +Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`) from `@modelcontextprotocol/sdk` 1.x (#4856). Breaking for clients: the server now requires Node.js 20 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); tool schemas in `tools/list` declare JSON Schema 2020-12 instead of draft-07; and tools no longer advertise `execution.taskSupport`. From ff6d4e19312b11017b7be653445980e19b87e6d1 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 7 Oct 2026 13:16:17 -0400 Subject: [PATCH 012/117] docs(everything): name the v2 transports in structure.md (#4856) Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/everything/docs/structure.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/everything/docs/structure.md b/src/everything/docs/structure.md index 9aac31847d..2464b1c7c8 100644 --- a/src/everything/docs/structure.md +++ b/src/everything/docs/structure.md @@ -194,10 +194,10 @@ src/everything - `GET /sse` to establish an SSE connection per session. - `POST /message` for client messages. - Manages multiple connected clients via a transport map. - - Starts an `SSEServerTransport`, created the server via `createServer()`, and connects it to a new transport. + - Starts an `SSEServerTransport` (from the frozen `@modelcontextprotocol/server-legacy/sse` bridge), created the server via `createServer()`, and connects it to a new transport. - On server disconnect, calls `cleanup()` to remove any live intervals. - `streamableHttp.ts` - - Express server exposing a single `/mcp` endpoint for POST (JSON‑RPC), GET (SSE stream), and DELETE (session termination) using `StreamableHTTPServerTransport`. + - Express server exposing a single `/mcp` endpoint for POST (JSON‑RPC), GET (SSE stream), and DELETE (session termination) using `NodeStreamableHTTPServerTransport` from `@modelcontextprotocol/node`. - Uses an `InMemoryEventStore` for resumable sessions and tracks transports by `sessionId`. - Answers a request for an unknown or ended `Mcp-Session-Id` with `404 Not Found`, and closes every open session on `SIGINT`. - Connects a fresh server instance on initialization POST and reuses the transport for subsequent requests. From d0d81b1123533459bc5c792fb3d8576b4ee203dd Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 28 Jan 2026 17:56:25 +0100 Subject: [PATCH 013/117] ci: add MCP interface diff workflow for Everything server Adds a GitHub Actions workflow that tracks public interface changes to the Everything MCP server using mcp-server-diff. Features: - Runs on PRs and pushes affecting src/everything/ - Auto-compares against merge-base (PRs) or previous state (pushes) - Manual workflow_dispatch for comparing any two refs - Generates diff reports showing tool, resource, prompt, and capability changes This helps catch unintended interface changes and provides clear visibility into how the reference server evolves over time. Related: https://github.com/modelcontextprotocol/inspector/issues/1034 Signed-off-by: cliffhall --- .github/workflows/everything-mcp-diff.yml | 70 +++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 .github/workflows/everything-mcp-diff.yml diff --git a/.github/workflows/everything-mcp-diff.yml b/.github/workflows/everything-mcp-diff.yml new file mode 100644 index 0000000000..6dd2db00e5 --- /dev/null +++ b/.github/workflows/everything-mcp-diff.yml @@ -0,0 +1,70 @@ +# Tracks public interface changes to the Everything MCP server. +# The Everything server is a reference implementation demonstrating all MCP features. +# This workflow helps reviewers see how tools, resources, prompts, and capabilities +# evolve over time - useful for SDK compliance validation and catching regressions. +# +# See: https://github.com/modelcontextprotocol/inspector/issues/1034 +name: Everything Server MCP Diff + +on: + push: + branches: [main] + paths: + - 'src/everything/**' + pull_request: + paths: + - 'src/everything/**' + # Manual trigger for comparing any two refs (commits, tags, branches) + workflow_dispatch: + inputs: + compare_ref: + description: 'Base ref to compare against (commit SHA, tag, or branch)' + required: false + default: '' + target_ref: + description: 'Target ref to compare (defaults to current branch if empty)' + required: false + default: '' + +permissions: + contents: read + +jobs: + mcp-diff: + name: Diff Everything Server Interface + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + ref: ${{ github.event.inputs.target_ref || github.ref }} + + - name: Run MCP Server Diff + uses: SamMorrowDrums/mcp-server-diff@4ddeb31dd8b3f98f2c20d24d60d4d9b2b9aa6e3b # v2.1.1 + with: + setup_node: 'true' + node_version: '22' + install_command: npm ci + build_command: npm run build + start_command: node dist/index.js stdio + compare_ref: ${{ github.event.inputs.compare_ref || '' }} + server_timeout: '15' + working-directory: src/everything + + - name: Add summary context + if: always() + run: | + echo "" >> $GITHUB_STEP_SUMMARY + echo "---" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "ℹ️ **Interpreting Results**" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "The Everything server is a reference implementation that exercises all MCP features." >> $GITHUB_STEP_SUMMARY + echo "Interface changes here may indicate:" >> $GITHUB_STEP_SUMMARY + echo "- New MCP protocol features being demonstrated" >> $GITHUB_STEP_SUMMARY + echo "- Updated tool schemas or descriptions" >> $GITHUB_STEP_SUMMARY + echo "- New resources, prompts, or capabilities" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Review changes to ensure they align with the intended protocol updates." >> $GITHUB_STEP_SUMMARY From 4948cdc8a0faa90b832aec39cedb9b887d577e91 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 28 Jan 2026 18:09:02 +0100 Subject: [PATCH 014/117] fix: update mcp-server-diff to v2.3.5 Signed-off-by: cliffhall --- .github/workflows/everything-mcp-diff.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/everything-mcp-diff.yml b/.github/workflows/everything-mcp-diff.yml index 6dd2db00e5..3473f664a9 100644 --- a/.github/workflows/everything-mcp-diff.yml +++ b/.github/workflows/everything-mcp-diff.yml @@ -42,7 +42,7 @@ jobs: ref: ${{ github.event.inputs.target_ref || github.ref }} - name: Run MCP Server Diff - uses: SamMorrowDrums/mcp-server-diff@4ddeb31dd8b3f98f2c20d24d60d4d9b2b9aa6e3b # v2.1.1 + uses: SamMorrowDrums/mcp-server-diff@a5555e85d68eaa014a334ae7d12b73787f2c49cc # v2.3.5 with: setup_node: 'true' node_version: '22' From e245576e30bd100bac484b54fd1cb5b17878c581 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 28 Jan 2026 18:17:34 +0100 Subject: [PATCH 015/117] fix: use full paths instead of working-directory Composite actions don't support working-directory at step level. Use full paths in commands instead. Signed-off-by: cliffhall --- .github/workflows/everything-mcp-diff.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/everything-mcp-diff.yml b/.github/workflows/everything-mcp-diff.yml index 3473f664a9..16168537c3 100644 --- a/.github/workflows/everything-mcp-diff.yml +++ b/.github/workflows/everything-mcp-diff.yml @@ -46,12 +46,11 @@ jobs: with: setup_node: 'true' node_version: '22' - install_command: npm ci - build_command: npm run build - start_command: node dist/index.js stdio + install_command: cd src/everything && npm ci + build_command: cd src/everything && npm run build + start_command: node src/everything/dist/index.js stdio compare_ref: ${{ github.event.inputs.compare_ref || '' }} server_timeout: '15' - working-directory: src/everything - name: Add summary context if: always() From 9f05f499add22661dad6ab86a2a3266c5d7bda50 Mon Sep 17 00:00:00 2001 From: SamMorrowDrums <48113580+SamMorrowDrums@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:43:02 +0200 Subject: [PATCH 016/117] ci: add concurrency group and fix mcp-server-diff SHA pin Address review feedback: - Add concurrency group so superseded runs on the same ref are cancelled - Repin SamMorrowDrums/mcp-server-diff to the correct v2.3.5 commit SHA (the previous SHA did not exist in the action's repo) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: cliffhall --- .github/workflows/everything-mcp-diff.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/everything-mcp-diff.yml b/.github/workflows/everything-mcp-diff.yml index 16168537c3..49dfee3064 100644 --- a/.github/workflows/everything-mcp-diff.yml +++ b/.github/workflows/everything-mcp-diff.yml @@ -29,6 +29,10 @@ on: permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: mcp-diff: name: Diff Everything Server Interface @@ -42,7 +46,7 @@ jobs: ref: ${{ github.event.inputs.target_ref || github.ref }} - name: Run MCP Server Diff - uses: SamMorrowDrums/mcp-server-diff@a5555e85d68eaa014a334ae7d12b73787f2c49cc # v2.3.5 + uses: SamMorrowDrums/mcp-server-diff@f7e5e58a4b0c4f68a5827adad2292953bb1ab9ef # v2.3.5 with: setup_node: 'true' node_version: '22' From 124b28ff1409f32a86484525b892bdaa6e3e0efc Mon Sep 17 00:00:00 2001 From: SamMorrowDrums <48113580+SamMorrowDrums@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:04:00 +0200 Subject: [PATCH 017/117] ci: post sticky PR comment with MCP diff report Surface the Everything server interface diff directly on the PR instead of requiring reviewers to dig into the Actions tab. - Add 'pull-requests: write' permission - Capture mcp-server-diff status output via step id - Build a comment body that summarises the status and includes the full report in a collapsed
block - Post via marocchino/sticky-pull-request-comment (SHA-pinned to v3.0.4) using a stable header so subsequent pushes update the same comment - Guarded to same-repo PRs only; fork PRs still get the summary and uploaded artifact Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: cliffhall --- .github/workflows/everything-mcp-diff.yml | 42 +++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/.github/workflows/everything-mcp-diff.yml b/.github/workflows/everything-mcp-diff.yml index 49dfee3064..0eabe2264e 100644 --- a/.github/workflows/everything-mcp-diff.yml +++ b/.github/workflows/everything-mcp-diff.yml @@ -28,6 +28,7 @@ on: permissions: contents: read + pull-requests: write concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -46,6 +47,7 @@ jobs: ref: ${{ github.event.inputs.target_ref || github.ref }} - name: Run MCP Server Diff + id: mcp_diff uses: SamMorrowDrums/mcp-server-diff@f7e5e58a4b0c4f68a5827adad2292953bb1ab9ef # v2.3.5 with: setup_node: 'true' @@ -71,3 +73,43 @@ jobs: echo "- New resources, prompts, or capabilities" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "Review changes to ensure they align with the intended protocol updates." >> $GITHUB_STEP_SUMMARY + + # Post (or update) a sticky PR comment so reviewers can see the diff without + # navigating to the Actions tab. The same comment is updated on subsequent + # pushes via the `header` key. Skipped for PRs from forks where GITHUB_TOKEN + # is read-only — the report is still available in the workflow summary and + # as an uploaded artifact. + - name: Build PR comment body + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + id: comment_body + run: | + { + echo 'body<Full report' + echo '' + tail -n +5 conformance-report/CONFORMANCE_REPORT.md + echo '' + echo '
' + else + echo '_Report file not found — see the workflow logs for details._' + fi + echo '' + echo "Updated by [\`${{ github.workflow }}\`](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) · commit \`${{ github.event.pull_request.head.sha }}\`" + echo 'MCP_DIFF_EOF' + } >> "$GITHUB_OUTPUT" + + - name: Post / update sticky PR comment + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 + with: + header: everything-server-mcp-diff + message: ${{ steps.comment_body.outputs.body }} From afc07115bd6883c72613fb020cd8877d18ba51ce Mon Sep 17 00:00:00 2001 From: SamMorrowDrums <48113580+SamMorrowDrums@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:16:35 +0200 Subject: [PATCH 018/117] ci: also run MCP diff on TypeScript release tags Mirrors github/github-mcp-server's pattern of pushing on tags so each release surfaces its cumulative interface delta. The everything server ships as part of the typescript-servers monorepo bundle, so we trigger on typescript-servers-* tags and let mcp-server-diff auto-compare against the previous matching tag. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: cliffhall --- .github/workflows/everything-mcp-diff.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/everything-mcp-diff.yml b/.github/workflows/everything-mcp-diff.yml index 0eabe2264e..798367fe8e 100644 --- a/.github/workflows/everything-mcp-diff.yml +++ b/.github/workflows/everything-mcp-diff.yml @@ -11,6 +11,11 @@ on: branches: [main] paths: - 'src/everything/**' + # TypeScript release tags also trigger a run, comparing against the + # previous typescript-servers-* tag (auto-detected by mcp-server-diff). + # This surfaces the cumulative interface delta for each release. + tags: + - 'typescript-servers-*' pull_request: paths: - 'src/everything/**' From e5ca90c71eed9ecdd791a7e541730011b3412ac5 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 4 Oct 2026 02:13:33 -0400 Subject: [PATCH 019/117] ci(everything): run the interface diff as an npm script on v2/main (#4860) Adapt #3260's workflow for v2/main: - Run the check as `npm run interface-diff` (scripts/interface-diff.mjs), driving the mcp-server-diff 3.0.0 CLI as an exact-pinned root devDependency instead of the SHA-pinned v2.3.5 action. The same script is a new last stage of local:gate, which workflow-gate.test.mjs now requires, so the gate keeps running every check CI runs. - Choose the base per event (the PR's base, the commit before a push, the previous release tag, or a dispatch input) instead of the tool's default merge-base with origin/main, which is wrong for v2/main PRs. - Trigger pushes on main and v2/main, PRs on any base, and published Releases instead of the retired typescript-servers-* tags. - Run the PR's code only in a read-only job with no persisted credentials; post the sticky comment from a separate job that holds pull-requests: write, runs no PR code, and only runs for same-repo PRs. Fork PRs keep the job summary and the report artifact. - Give every job a timeout. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .github/workflows/everything-mcp-diff.yml | 255 +++++++++++----- package-lock.json | 93 ++++++ package.json | 4 +- scripts/interface-diff.mjs | 345 ++++++++++++++++++++++ scripts/interface-diff.test.mjs | 127 ++++++++ scripts/lib/workflow-gate.test.mjs | 1 + 6 files changed, 749 insertions(+), 76 deletions(-) create mode 100644 scripts/interface-diff.mjs create mode 100644 scripts/interface-diff.test.mjs diff --git a/.github/workflows/everything-mcp-diff.yml b/.github/workflows/everything-mcp-diff.yml index 798367fe8e..30f556c8e6 100644 --- a/.github/workflows/everything-mcp-diff.yml +++ b/.github/workflows/everything-mcp-diff.yml @@ -3,118 +3,223 @@ # This workflow helps reviewers see how tools, resources, prompts, and capabilities # evolve over time - useful for SDK compliance validation and catching regressions. # +# Contributed by @SamMorrowDrums in #3260 and adapted for v2/main in #4860: +# +# - The check is `npm run interface-diff` (scripts/interface-diff.mjs), which +# drives the `mcp-server-diff` CLI, an exact-pinned root devDependency. The +# same script is a `local:gate` stage, and `scripts/lib/workflow-gate.test.mjs` +# requires every npm script a workflow runs to be one, so CI and the gate run +# the same check. No third-party action runs here. +# - The base is chosen per event below rather than by the tool, whose default +# (the merge-base with origin/main) is the wrong base for PRs to v2/main. +# - Untrusted code (the PR's install, build and server) runs only in `diff`, +# which holds a read-only token and no persisted credentials. The sticky +# comment is posted by `comment`, which holds `pull-requests: write` and runs +# none of the PR's code, and only for PRs from this repository: a fork PR's +# token is read-only, so there the report is in the job summary and the +# `mcp-diff-report` artifact. `pull_request_target` is deliberately not used, +# since it would run the fork's build next to a write token. If fork PRs need +# the comment too, the way is a separate `workflow_run` workflow that posts +# the artifact without checking anything out. +# +# A changed interface does not fail the run: the diff is information for the +# reviewer. A diff that cannot run (a base that does not build, a server that +# cannot be probed) does. +# # See: https://github.com/modelcontextprotocol/inspector/issues/1034 name: Everything Server MCP Diff on: push: - branches: [main] + branches: [main, v2/main] paths: - - 'src/everything/**' - # TypeScript release tags also trigger a run, comparing against the - # previous typescript-servers-* tag (auto-detected by mcp-server-diff). - # This surfaces the cumulative interface delta for each release. - tags: - - 'typescript-servers-*' + - "src/everything/**" + - "package.json" + - "package-lock.json" + - "scripts/interface-diff.mjs" + - ".github/workflows/everything-mcp-diff.yml" + # No branch filter: PRs to v2/main and to main both run, and so does a PR + # stacked on another PR's branch. The base is the PR's own base either way. pull_request: paths: - - 'src/everything/**' + - "src/everything/**" + - "package.json" + - "package-lock.json" + - "scripts/interface-diff.mjs" + - ".github/workflows/everything-mcp-diff.yml" + # A published Release (a milestone, tagged vX.Y.Z on main; see RELEASING.md) + # surfaces the cumulative interface delta since the previous release's tag. + release: + types: [published] # Manual trigger for comparing any two refs (commits, tags, branches) workflow_dispatch: inputs: compare_ref: - description: 'Base ref to compare against (commit SHA, tag, or branch)' + description: "Base ref to compare against (commit SHA, tag, or branch; defaults to the merge-base with v2/main)" required: false - default: '' + default: "" target_ref: - description: 'Target ref to compare (defaults to current branch if empty)' + description: "Target ref to compare (defaults to the ref the workflow runs on)" required: false - default: '' + default: "" permissions: contents: read - pull-requests: write +# Superseded runs on the same PR are cancelled; pushes and releases each keep +# their own run. concurrency: group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: - mcp-diff: + diff: name: Diff Everything Server Interface + timeout-minutes: 15 runs-on: ubuntu-latest - + permissions: + contents: read steps: - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@v6 with: + # The base is built from history, so all of it is needed, tags + # included (the release base is the previous tag). fetch-depth: 0 - ref: ${{ github.event.inputs.target_ref || github.ref }} + ref: ${{ inputs.target_ref || github.ref }} + persist-credentials: false - - name: Run MCP Server Diff - id: mcp_diff - uses: SamMorrowDrums/mcp-server-diff@f7e5e58a4b0c4f68a5827adad2292953bb1ab9ef # v2.3.5 + - uses: actions/setup-node@v7 with: - setup_node: 'true' - node_version: '22' - install_command: cd src/everything && npm ci - build_command: cd src/everything && npm run build - start_command: node src/everything/dist/index.js stdio - compare_ref: ${{ github.event.inputs.compare_ref || '' }} - server_timeout: '15' + node-version: 22 + cache: npm - - name: Add summary context - if: always() + # A root `npm ci` runs every workspace's `prepare`, which builds it, so + # the head build the diff probes exists after this step. + - name: Install dependencies and build + run: npm ci + + - name: Choose the base to compare against + id: base + env: + EVENT: ${{ github.event_name }} + PR_BASE: ${{ github.event.pull_request.base.sha }} + BEFORE: ${{ github.event.before }} + INPUT_BASE: ${{ inputs.compare_ref }} run: | - echo "" >> $GITHUB_STEP_SUMMARY - echo "---" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "ℹ️ **Interpreting Results**" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "The Everything server is a reference implementation that exercises all MCP features." >> $GITHUB_STEP_SUMMARY - echo "Interface changes here may indicate:" >> $GITHUB_STEP_SUMMARY - echo "- New MCP protocol features being demonstrated" >> $GITHUB_STEP_SUMMARY - echo "- Updated tool schemas or descriptions" >> $GITHUB_STEP_SUMMARY - echo "- New resources, prompts, or capabilities" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "Review changes to ensure they align with the intended protocol updates." >> $GITHUB_STEP_SUMMARY + case "$EVENT" in + # The checkout is the PR's merge commit, so its base tip is + # exactly what the PR changes. + pull_request) base="$PR_BASE" ;; + # The branch as it was before this push; a new branch has none. + push) + if [ -n "$BEFORE" ] && [ "$BEFORE" != 0000000000000000000000000000000000000000 ]; then + base="$BEFORE" + else + base=$(git rev-parse HEAD^) + fi ;; + release) base=$(git describe --tags --abbrev=0 HEAD^) ;; + workflow_dispatch) + if [ -n "$INPUT_BASE" ]; then + base="$INPUT_BASE" + else + base=$(git merge-base HEAD origin/v2/main) + fi ;; + *) echo "unexpected event: $EVENT" >&2; exit 1 ;; + esac + echo "Comparing against $base" + echo "ref=$base" >> "$GITHUB_OUTPUT" - # Post (or update) a sticky PR comment so reviewers can see the diff without - # navigating to the Actions tab. The same comment is updated on subsequent - # pushes via the `header` key. Skipped for PRs from forks where GITHUB_TOKEN - # is read-only — the report is still available in the workflow summary and - # as an uploaded artifact. - - name: Build PR comment body - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository - id: comment_body + - name: Diff the interface + env: + BASE: ${{ steps.base.outputs.ref }} + run: npm run interface-diff -- --base "$BASE" --report-dir mcp-diff-report + + - name: Write the job summary + if: always() run: | { - echo 'body<Full report' - echo '' - tail -n +5 conformance-report/CONFORMANCE_REPORT.md - echo '' - echo '' - else - echo '_Report file not found — see the workflow logs for details._' - fi - echo '' - echo "Updated by [\`${{ github.workflow }}\`](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) · commit \`${{ github.event.pull_request.head.sha }}\`" - echo 'MCP_DIFF_EOF' - } >> "$GITHUB_OUTPUT" + echo "" + echo "---" + echo "" + echo "ℹ️ **Interpreting Results**" + echo "" + echo "The Everything server is a reference implementation that exercises all MCP features." + echo "Interface changes here may indicate:" + echo "- New MCP protocol features being demonstrated" + echo "- Updated tool schemas or descriptions" + echo "- New resources, prompts, or capabilities" + echo "" + echo "Review changes to ensure they align with the intended protocol updates." + } >> "$GITHUB_STEP_SUMMARY" - - name: Post / update sticky PR comment - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository - uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4 + - name: Upload the report + if: always() + uses: actions/upload-artifact@v7 + with: + name: mcp-diff-report + path: mcp-diff-report/ + if-no-files-found: ignore + + # Post (or update) a sticky PR comment so reviewers can see the diff without + # navigating to the Actions tab. One comment per PR, found again by its + # marker and edited on each push. This job checks nothing out and runs none + # of the PR's code; it only reads the report the `diff` job uploaded. + comment: + name: Post the diff on the PR + needs: diff + if: >- + always() && + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository && + (needs.diff.result == 'success' || needs.diff.result == 'failure') + timeout-minutes: 5 + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - name: Download the report + uses: actions/download-artifact@v8 + continue-on-error: true with: - header: everything-server-mcp-diff - message: ${{ steps.comment_body.outputs.body }} + name: mcp-diff-report + path: mcp-diff-report + + - name: Post / update sticky PR comment + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR: ${{ github.event.pull_request.number }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + marker='' + { + echo "$marker" + if [ -f mcp-diff-report/report.md ]; then + # A comment holds at most 65536 characters. + head -c 60000 mcp-diff-report/report.md + if [ "$(wc -c < mcp-diff-report/report.md)" -gt 60000 ]; then + echo "" + echo "_The report is truncated here; the full report is in the run's \`mcp-diff-report\` artifact._" + fi + else + echo "## \`everything\`: MCP interface diff" + echo "" + echo "❌ The diff job produced no report; see the run's log." + fi + echo "" + echo "Updated by [this run]($RUN_URL) for \`$HEAD_SHA\`." + } > body.md + id=$(gh api --paginate "repos/$REPO/issues/$PR/comments" \ + --jq ".[] | select(.user.login == \"github-actions[bot]\" and (.body | startswith(\"$marker\"))) | .id" | head -n 1) + if [ -n "$id" ]; then + gh api -X PATCH "repos/$REPO/issues/comments/$id" -F body=@body.md > /dev/null + else + gh api "repos/$REPO/issues/$PR/comments" -F body=@body.md > /dev/null + fi diff --git a/package-lock.json b/package-lock.json index d1390cede6..6359f597db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@modelcontextprotocol/client": "^2.3.1", "eslint": "^10.11.0", "globals": "^17.12.0", + "mcp-server-diff": "3.0.0", "prettier": "3.8.4", "proper-lockfile": "^4.1.2", "semver": "^7.8.4", @@ -31,6 +32,50 @@ "yaml": "^2.9.0" } }, + "node_modules/@actions/core": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.1.tgz", + "integrity": "sha512-a6d/Nwahm9fliVGRhdhofo40HjHQasUPusmc7vBfyky+7Z+P2A1J68zyFVaNcEclc/Se+eO595oAr5nwEIoIUA==", + "dev": true, + "dependencies": { + "@actions/exec": "^3.0.0", + "@actions/http-client": "^4.0.0" + } + }, + "node_modules/@actions/exec": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@actions/exec/-/exec-3.0.0.tgz", + "integrity": "sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==", + "dev": true, + "dependencies": { + "@actions/io": "^3.0.2" + } + }, + "node_modules/@actions/http-client": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@actions/http-client/-/http-client-4.0.1.tgz", + "integrity": "sha512-+Nvd1ImaOZBSoPbsUtEhv+1z99H12xzncCkz0a3RuehINE81FZSe2QTj3uvAPTcJX/SCzUQHQ0D1GrPMbrPitg==", + "dev": true, + "dependencies": { + "tunnel": "^0.0.6", + "undici": "^6.23.0" + } + }, + "node_modules/@actions/http-client/node_modules/undici": { + "version": "6.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz", + "integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==", + "dev": true, + "engines": { + "node": ">=18.17" + } + }, + "node_modules/@actions/io": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@actions/io/-/io-3.0.2.tgz", + "integrity": "sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==", + "dev": true + }, "node_modules/@babel/helper-string-parser": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", @@ -3805,6 +3850,36 @@ "node": ">= 0.4" } }, + "node_modules/mcp-server-diff": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mcp-server-diff/-/mcp-server-diff-3.0.0.tgz", + "integrity": "sha512-vtbf+ZSfRFMZQipzIpQDP5l35qMhW/CtemsERMr7DjU5Z+uY6ZtL8gHBAamwZ2sS3VvsWtVqMg1Hk91HbavZrw==", + "dev": true, + "dependencies": { + "@actions/core": "^3.0.1", + "@actions/exec": "^3.0.0", + "@actions/io": "^3.0.2", + "@modelcontextprotocol/sdk": "^1.13.2", + "diff": "^9.0.0", + "undici": "^8.4.1", + "zod": "^4.4.3" + }, + "bin": { + "mcp-server-diff": "dist/cli/index.js" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/mcp-server-diff/node_modules/diff": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -4972,6 +5047,15 @@ "license": "0BSD", "optional": true }, + "node_modules/tunnel": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", + "integrity": "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==", + "dev": true, + "engines": { + "node": ">=0.6.11 <=0.7.0 || >=0.7.3" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -5054,6 +5138,15 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/undici": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", + "dev": true, + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/package.json b/package.json index a1d225897a..49ed4ba99e 100644 --- a/package.json +++ b/package.json @@ -43,10 +43,11 @@ "verify:action-pins": "node scripts/verify-action-pins.mjs", "verify:dco": "node scripts/verify-dco.mjs", "smoke": "node scripts/smoke-servers.mjs", + "interface-diff": "node scripts/interface-diff.mjs", "pack:verify": "node scripts/pack-and-verify.mjs", "release:notes": "node scripts/release-notes.mjs", "local:gate": "node scripts/gate-lease.mjs npm run local:gate:stages", - "local:gate:stages": "npm run verify:install-fresh && npm run verify:dco && npm run validate && npm run coverage && npm run validate:py && npm run coverage:py && npm run verify:skills:cli && npm run smoke" + "local:gate:stages": "npm run verify:install-fresh && npm run verify:dco && npm run validate && npm run coverage && npm run validate:py && npm run coverage:py && npm run verify:skills:cli && npm run smoke && npm run interface-diff" }, "dependencies": { "@modelcontextprotocol/server-everything": "*", @@ -61,6 +62,7 @@ "@modelcontextprotocol/client": "^2.3.1", "eslint": "^10.11.0", "globals": "^17.12.0", + "mcp-server-diff": "3.0.0", "prettier": "3.8.4", "proper-lockfile": "^4.1.2", "semver": "^7.8.4", diff --git a/scripts/interface-diff.mjs b/scripts/interface-diff.mjs new file mode 100644 index 0000000000..3b46b27bc0 --- /dev/null +++ b/scripts/interface-diff.mjs @@ -0,0 +1,345 @@ +#!/usr/bin/env node +// The `everything` server's MCP interface diff (#4860), adapted from +// @SamMorrowDrums's workflow in #3260. +// +// It snapshots the public interface of two builds of `everything` (the server +// capabilities from the handshake, and its tools, prompts, resources and +// resource templates) with `mcp-server-diff`, and reports what changed between +// them: the build in this checkout (the head) against the same server built +// from a base commit. The spec refactor (#4857) leans on it: an SDK migration +// that is meant to be transparent proves it with an empty diff, and each new +// 2026-07-28 feature shows up as a reviewable one. +// +// Why a script, and not the `mcp-server-diff` GitHub Action #3260 used: +// +// - `local:gate` must run every check CI runs, and +// `scripts/lib/workflow-gate.test.mjs` derives that from the npm scripts a +// workflow invokes. A check behind an action is invisible to it; a check +// behind `npm run interface-diff` is enforced by it. CI +// (`everything-mcp-diff.yml`) and the gate run this same file. +// - The tool comes in as an exact-pinned root devDependency, so it is held +// by `package-lock.json`'s integrity hash, the way every other npm +// dependency here is, and no third-party action runs in CI at all. +// - The action picks the base itself, as the merge-base with `origin/main`. +// PRs here target `v2/main`, so that base is wrong; the caller names it. +// +// The base is built from a plain export of the base commit's tree (no git +// worktree is registered) under `node_modules/.cache/`, which git, Prettier and +// ESLint all ignore, and removed afterwards. It sits inside the checkout rather +// than under the system temp directory because `mcp-server-diff` splits a +// server's start command on whitespace, and a relative path from the checkout +// cannot contain any, where a temp directory under a Windows profile can. +// +// The head is NOT built here: inside `local:gate` the `validate` stage has +// already built it, and CI's root `npm ci` builds it. A missing build is an +// error, as it is for the boot smoke. +// +// Outcome: +// - unchanged / changed: exit 0. A changed interface is information for the +// reviewer, not a failure (#3260 ran with `fail_on_diff: false` too). +// - error: exit 1. A server that cannot be probed, or a base that does not +// build, is a broken check, so it fails the gate and the CI job. +// +// Usage: +// npm run interface-diff # base: merge-base with origin/v2/main +// npm run interface-diff -- --base # any commit, tag or branch +// npm run interface-diff -- --report-dir # also write report.md and result.json + +import { spawnSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); + +/** The server this diff covers. Start with `everything` only (#4860). */ +export const SERVER_DIR = "src/everything"; + +/** The entry point a client launches, relative to a checkout's root. */ +const ENTRY = `${SERVER_DIR}/dist/index.js`; + +/** The base when none is named: where this branch left the develop line. */ +export const DEFAULT_BASE_BRANCH = "origin/v2/main"; + +/** + * Parse the command line. + * + * @param {string[]} argv + * @returns {{ base: string | null, reportDir: string | null }} + */ +export function parseArgs(argv) { + const opts = { base: null, reportDir: null }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + const [flag, inline] = arg.startsWith("--") ? arg.split(/=(.*)/s) : [arg]; + const value = () => { + const v = inline ?? argv[++i]; + if (v === undefined || v === "") throw new Error(`${flag} needs a value`); + return v; + }; + if (flag === "--base") opts.base = value(); + else if (flag === "--report-dir") opts.reportDir = value(); + else throw new Error(`unknown argument: ${arg}`); + } + return opts; +} + +/** + * Turn `mcp-server-diff`'s JSON output into this diff's verdict. + * + * The CLI exits 1 both when the interfaces differ and when a probe fails, so + * its exit code cannot tell a change from a broken check; the JSON can. + * + * @param {string} stdout what `mcp-server-diff -o json -q` printed + * @returns {{ status: "unchanged" | "changed" | "error", error?: string, + * diffs: { endpoint: string, diff: string }[], + * baseCounts?: object, headCounts?: object }} + */ +export function classify(stdout) { + let parsed; + try { + parsed = JSON.parse(stdout); + } catch { + return { + status: "error", + error: `mcp-server-diff printed no JSON report:\n${stdout.trim().slice(0, 2000)}`, + diffs: [], + }; + } + const result = parsed?.results?.[0]; + if (!result || typeof result !== "object") + return { + status: "error", + error: "mcp-server-diff reported no comparison", + diffs: [], + }; + const diffs = Array.isArray(result.diffs) ? result.diffs : []; + if (result.error) + return { status: "error", error: String(result.error), diffs }; + return { + status: result.hasDifferences ? "changed" : "unchanged", + diffs, + baseCounts: result.baseCounts, + headCounts: result.targetCounts, + }; +} + +/** A Markdown code fence longer than any run of backticks in `text`. */ +export function fenceFor(text) { + const longest = Math.max( + 0, + ...[...text.matchAll(/`+/g)].map((m) => m[0].length), + ); + return "`".repeat(Math.max(3, longest + 1)); +} + +/** + * The human-readable report: the verdict first, then the counts, then each + * part of the interface that changed as a diff. + * + * @param {ReturnType} verdict + * @param {{ base: string, head: string }} labels + */ +export function renderReport(verdict, { base, head }) { + const lines = ["## `everything`: MCP interface diff", ""]; + lines.push(`Base: ${base} `, `Head: ${head}`, ""); + if (verdict.status === "unchanged") + lines.push("✅ **No interface changes detected.**"); + else if (verdict.status === "changed") + lines.push( + `⚠️ **Interface changes detected** in ${verdict.diffs.length} part(s) of the interface. Review them below to confirm they are intended.`, + ); + else lines.push("❌ **The interface diff could not run.**"); + lines.push(""); + + if (verdict.status === "error") { + const fence = fenceFor(verdict.error ?? ""); + lines.push(fence, verdict.error ?? "", fence, ""); + return lines.join("\n"); + } + + const row = (name, c = {}) => + `| ${name} | ${c.tools ?? "?"} | ${c.prompts ?? "?"} | ${c.resources ?? "?"} | ${c.resourceTemplates ?? "?"} |`; + lines.push( + "| | Tools | Prompts | Resources | Resource templates |", + "| --- | --- | --- | --- | --- |", + row("Base", verdict.baseCounts), + row("Head", verdict.headCounts), + "", + ); + + for (const { endpoint, diff } of verdict.diffs) { + const fence = fenceFor(diff); + lines.push(`### ${endpoint}`, "", `${fence}diff`, diff, fence, ""); + } + return lines.join("\n"); +} + +/** Run a command to completion; throw with its output when it fails. */ +function run(command, args, cwd, env) { + const res = spawnSync(command, args, { + cwd, + env: env ?? process.env, + encoding: "utf8", + maxBuffer: 64 * 1024 * 1024, + shell: process.platform === "win32", + }); + if (res.error) throw res.error; + if (res.status !== 0) + throw new Error( + `\`${command} ${args.join(" ")}\` exited ${res.status}:\n${(res.stderr || res.stdout).trim().split("\n").slice(-25).join("\n")}`, + ); + return res.stdout; +} + +const git = (...args) => run("git", args, repoRoot).trim(); + +/** The commit to compare against, as a full SHA. */ +function resolveBase(base) { + if (base) return git("rev-parse", "--verify", `${base}^{commit}`); + try { + git("rev-parse", "--verify", DEFAULT_BASE_BRANCH); + } catch { + throw new Error( + `${DEFAULT_BASE_BRANCH} is not available to compare against. Run \`git fetch origin v2/main\`, or name a base with --base .`, + ); + } + return git("merge-base", "HEAD", DEFAULT_BASE_BRANCH); +} + +/** + * Build `everything` as it was at `sha` into `dir`: export the commit's tree + * through a throwaway index (no worktree registered, the real index + * untouched), install that workspace's locked dependencies, build it. + */ +function buildBase(sha, dir) { + const index = `${dir}.index`; + const env = { ...process.env, GIT_INDEX_FILE: index }; + try { + run("git", ["read-tree", sha], repoRoot, env); + run( + "git", + ["checkout-index", "--all", `--prefix=${dir}${path.sep}`], + repoRoot, + env, + ); + } finally { + rmSync(index, { force: true }); + } + if (!existsSync(path.join(dir, SERVER_DIR, "package.json"))) + throw new Error(`${SERVER_DIR} does not exist at the base ${sha}`); + run( + "npm", + [ + "ci", + "--workspace", + SERVER_DIR, + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--prefer-offline", + ], + dir, + ); + run("npm", ["run", "build", "--workspace", SERVER_DIR], dir); +} + +/** Where the pinned `mcp-server-diff` CLI lives. */ +function cliPath() { + const require = createRequire(import.meta.url); + const manifest = require.resolve("mcp-server-diff/package.json"); + const { bin } = require("mcp-server-diff/package.json"); + const rel = typeof bin === "string" ? bin : bin["mcp-server-diff"]; + return path.join(path.dirname(manifest), rel); +} + +/** A short, readable label for a commit. */ +function describe(sha) { + const subject = git("log", "-1", "--format=%s", sha); + return `\`${sha.slice(0, 12)}\` (${subject.replaceAll("`", "'")})`; +} + +export async function main(argv = process.argv.slice(2)) { + let opts; + try { + opts = parseArgs(argv); + } catch (err) { + console.error(`interface-diff: ${err.message}`); + return 2; + } + + let verdict; + let labels = { base: "(unresolved)", head: "(unresolved)" }; + const cacheRoot = path.join(repoRoot, "node_modules", ".cache"); + let work; + try { + if (!existsSync(path.join(repoRoot, ENTRY))) + throw new Error( + `${ENTRY} does not exist — build first (npm run build -w ${SERVER_DIR}).`, + ); + const sha = resolveBase(opts.base); + const headSha = git("rev-parse", "HEAD"); + labels = { + base: describe(sha), + head: `the build in this checkout, at ${describe(headSha)}`, + }; + console.error(`interface-diff: building the base ${sha.slice(0, 12)} …`); + + mkdirSync(cacheRoot, { recursive: true }); + work = mkdtempSync(path.join(cacheRoot, "servers-interface-diff-")); + buildBase(sha, work); + + // Both start commands are relative to the checkout, so they hold no + // whitespace for mcp-server-diff to split on (see the header). + const baseEntry = path + .relative(repoRoot, path.join(work, ENTRY)) + .split(path.sep) + .join("/"); + console.error("interface-diff: probing the base and the head …"); + const res = spawnSync( + process.execPath, + [ + cliPath(), + "--base", + `node ${baseEntry} stdio`, + "--target", + `node ${ENTRY} stdio`, + "--output", + "json", + "--quiet", + ], + { cwd: repoRoot, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }, + ); + if (res.error) throw res.error; + verdict = classify(res.stdout); + } catch (err) { + verdict = { status: "error", error: err.message, diffs: [] }; + } finally { + if (work) rmSync(work, { recursive: true, force: true }); + } + + const report = renderReport(verdict, labels); + console.log(report); + if (opts.reportDir) { + mkdirSync(opts.reportDir, { recursive: true }); + writeFileSync(path.join(opts.reportDir, "report.md"), `${report}\n`); + writeFileSync( + path.join(opts.reportDir, "result.json"), + `${JSON.stringify({ status: verdict.status, diffCount: verdict.diffs.length }, null, 2)}\n`, + ); + } + return verdict.status === "error" ? 1 : 0; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) + process.exitCode = await main(); diff --git a/scripts/interface-diff.test.mjs b/scripts/interface-diff.test.mjs new file mode 100644 index 0000000000..e30008c34e --- /dev/null +++ b/scripts/interface-diff.test.mjs @@ -0,0 +1,127 @@ +// Tests for the `everything` interface diff (#4860). The pure halves are +// exercised here: the argument parser, the verdict drawn from +// `mcp-server-diff`'s JSON (the one thing that decides whether the gate and +// the CI job fail), and the report. Building a base and probing two servers is +// what `npm run interface-diff` itself does as a gate stage, so it is not +// repeated here. Run via `npm run test:scripts`. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + classify, + fenceFor, + main, + parseArgs, + renderReport, +} from "./interface-diff.mjs"; + +const counts = { tools: 13, prompts: 4, resources: 7, resourceTemplates: 2 }; +const cliJson = (result) => + JSON.stringify({ timestamp: "t", results: [result], summary: {} }); + +test("parseArgs reads both flags, in either spelling", () => { + assert.deepEqual(parseArgs([]), { base: null, reportDir: null }); + assert.deepEqual(parseArgs(["--base", "abc", "--report-dir", "out"]), { + base: "abc", + reportDir: "out", + }); + assert.deepEqual(parseArgs(["--base=v1.0.0", "--report-dir=a=b"]), { + base: "v1.0.0", + reportDir: "a=b", + }); +}); + +test("parseArgs refuses an unknown flag or a missing value", () => { + assert.throws(() => parseArgs(["--fail-on-diff"]), /unknown argument/); + assert.throws(() => parseArgs(["--base"]), /--base needs a value/); + assert.throws(() => parseArgs(["--base="]), /--base needs a value/); +}); + +test("main exits 2 on a bad command line, before building anything", async () => { + assert.equal(await main(["--nope"]), 2); +}); + +test("an identical interface is unchanged", () => { + const v = classify( + cliJson({ + hasDifferences: false, + baseCounts: counts, + targetCounts: counts, + diffs: [], + }), + ); + assert.equal(v.status, "unchanged"); + assert.deepEqual(v.headCounts, counts); +}); + +test("a difference is a change, not an error", () => { + const diffs = [{ endpoint: "tools", diff: "- a\n+ b" }]; + const v = classify( + cliJson({ + hasDifferences: true, + baseCounts: counts, + targetCounts: counts, + diffs, + }), + ); + assert.equal(v.status, "changed"); + assert.deepEqual(v.diffs, diffs); +}); + +test("a probe failure is an error, though the CLI reports it as a difference", () => { + // mcp-server-diff sets hasDifferences on a failed probe too, and exits 1 + // for both; only the `error` field tells them apart. + const v = classify( + cliJson({ + hasDifferences: true, + error: "Target probe failed: boom", + diffs: [{ endpoint: "error", diff: "Target probe failed: boom" }], + }), + ); + assert.equal(v.status, "error"); + assert.match(v.error, /boom/); +}); + +test("output that is not the JSON report is an error, never a pass", () => { + assert.equal(classify("").status, "error"); + assert.equal(classify("Fatal error: x").status, "error"); + assert.equal(classify("{}").status, "error"); + assert.equal(classify(JSON.stringify({ results: [] })).status, "error"); +}); + +test("fenceFor outruns any backticks in the content", () => { + assert.equal(fenceFor("plain"), "```"); + assert.equal(fenceFor("a ``` b"), "````"); + assert.equal(fenceFor("`````"), "``````"); +}); + +test("the report leads with the verdict and shows each changed part", () => { + const labels = { base: "`base`", head: "`head`" }; + assert.match( + renderReport( + { + status: "unchanged", + diffs: [], + baseCounts: counts, + headCounts: counts, + }, + labels, + ), + /No interface changes detected[\s\S]*\| Head \| 13 \| 4 \| 7 \| 2 \|/, + ); + const changed = renderReport( + { + status: "changed", + diffs: [{ endpoint: "tools", diff: "- x ``` y" }], + baseCounts: counts, + headCounts: counts, + }, + labels, + ); + assert.match(changed, /Interface changes detected/); + assert.match(changed, /### tools\n\n````diff\n- x ``` y\n````/); + assert.match( + renderReport({ status: "error", error: "boom", diffs: [] }, labels), + /could not run[\s\S]*boom/, + ); +}); diff --git a/scripts/lib/workflow-gate.test.mjs b/scripts/lib/workflow-gate.test.mjs index 6dee0ca4fd..c08ae56186 100644 --- a/scripts/lib/workflow-gate.test.mjs +++ b/scripts/lib/workflow-gate.test.mjs @@ -392,6 +392,7 @@ describe("the gate's name", () => { "coverage:py", "verify:skills:cli", "smoke", + "interface-diff", ]) { it(`runs \`${stage}\``, () => { assert.ok( From dde6224267e814de530f67ee6def459329f41c5a Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 4 Oct 2026 02:13:33 -0400 Subject: [PATCH 020/117] docs: record the interface diff as a gate stage (#4860) Add `interface-diff` to the stage table in docs/quality-gate.md, to AGENTS.md's Before pushing and project tree, and a diagnosis section to the pre-push-gate skill. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .claude/skills/pre-push-gate/SKILL.md | 25 +++++++++++++++++++++++++ AGENTS.md | 17 +++++++++-------- docs/quality-gate.md | 18 +++++++++++++++--- 3 files changed, 49 insertions(+), 11 deletions(-) diff --git a/.claude/skills/pre-push-gate/SKILL.md b/.claude/skills/pre-push-gate/SKILL.md index c7198ad3a9..ddb54b47ca 100644 --- a/.claude/skills/pre-push-gate/SKILL.md +++ b/.claude/skills/pre-push-gate/SKILL.md @@ -273,6 +273,31 @@ of the server's stderr. Re-run a subset with - **A new server fails `test:scripts`** with "every server directory in the repo has a smoke entry": add it to `SERVERS` in `scripts/smoke-servers.mjs`. +### `interface-diff` + +The last stage diffs the `everything` server's MCP interface (capabilities, +tools, prompts, resources, resource templates) in this checkout's build +against the same server built from the merge-base with `origin/v2/main`, and +prints the report. **A changed interface does not fail it**: the report is +what the PR's sticky comment will show, so read it, and if a change is not +one you meant, that is the finding. It fails only when the diff cannot run, +with `❌ The interface diff could not run` and the reason: + +- **"origin/v2/main is not available"**: `git fetch origin v2/main`. +- **"src/everything/dist/index.js does not exist"**: you ran it on its own + before building: `npm run build -w src/everything`. +- **An `npm ci` or `npm run build` failure**: the _base_ did not install or + build. Offline with a cold npm cache is the usual cause; otherwise name a + base that builds with `npm run interface-diff -- --base `. +- **"Base server probe failed" / "Target probe failed"**: a server did not + answer the probe. A target failure is this checkout's server and is the + real defect; the smoke stage before it usually fails first. + +Re-run it on its own with `npm run interface-diff` (about ten seconds). What +it compares against in CI, and why it is a script rather than an action, is +in [`docs/quality-gate.md`](../../../docs/quality-gate.md) and the header of +`scripts/interface-diff.mjs`. + ## Waiting on the lease A gate that starts with diff --git a/AGENTS.md b/AGENTS.md index bba09c93db..7b4abbd327 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,7 @@ servers/ │ └── time/ Py mcp-server-time PyPI Time and timezone conversion ├── .claude/skills/ On-demand procedures (see the Skills index above) ├── .changeset/ Pending changesets for the TypeScript servers, and the changesets config -├── scripts/ The pre-push gate (gate-lease, smoke-servers, validate-py, coverage-py), its guards (verify-*), +├── scripts/ The pre-push gate (gate-lease, smoke-servers, validate-py, coverage-py, interface-diff), its guards (verify-*), │ the skills tooling, release tooling (npm-publish-guard, prepare-python-release, │ pack-and-verify, release-manifest, release-notes), and the issue-filing sweeps │ (dependency-refresh, dependabot-alerts, sdk-watch) @@ -61,8 +61,8 @@ servers/ │ overview of how the rules, skills, gates and sweeps fit together ├── .github/workflows/ typescript.yml, python.yml (per-package CI), release.yml (publishes on a GitHub Release), │ version-packages.yml (the changesets PR), prepare-python-release.yml (the CalVer PR), -│ claude.yml (@claude mentions), and the scheduled sweeps that file issues: -│ dependency-refresh.yml, dependabot-alerts.yml, sdk-watch.yml +│ claude.yml (@claude mentions), everything-mcp-diff.yml (everything's interface diff on PRs), +│ and the scheduled sweeps that file issues: dependency-refresh.yml, dependabot-alerts.yml, sdk-watch.yml ├── .github/ISSUE_TEMPLATE/ Bug and feature issue forms; config.yml routes security and new servers away ├── .github/pull_request_template.md The "issues, not PRs" banner that turns outside PRs away ├── RELEASING.md How packages are versioned and published, and how to recover a failed publish @@ -129,14 +129,15 @@ workspace's format check, lint, typecheck, build and tests), `coverage` (each TypeScript workspace's per-file coverage gate), `validate:py` (each Python server's locked sync, `ruff check`, `ruff format --check`, pyright, pytest and build), `coverage:py` (each Python server's per-file -coverage gate), `verify:skills:cli`, and `smoke` (every server -booted over each transport it implements). The stage-by-stage reference, and -what CI runs where, is [`docs/quality-gate.md`](./docs/quality-gate.md); +coverage gate), `verify:skills:cli`, `smoke` (every server booted over each +transport it implements), and `interface-diff` (the `everything` server's MCP +interface diffed against the base it branched from). The stage-by-stage +reference, and what CI runs where, is [`docs/quality-gate.md`](./docs/quality-gate.md); diagnosing a red stage is the `pre-push-gate` skill. - **`npm run validate` and the per-package commands above are the inner loop, - not a substitute.** They skip the other language, the pinned skills validator - and the boot smoke. + not a substitute.** They skip the other language, the pinned skills validator, + the boot smoke and the interface diff. - **Format before committing**: `npm run format` at the root (TypeScript), and `uv run --frozen ruff format .` in a Python server you changed. The gate only checks formatting; it never rewrites files. diff --git a/docs/quality-gate.md b/docs/quality-gate.md index 075b72de24..7c197a5b05 100644 --- a/docs/quality-gate.md +++ b/docs/quality-gate.md @@ -13,7 +13,7 @@ out; everything else points here. | --- | --- | --- | --- | | Inner loop | `npm run validate -w src/`, `npm run validate:py -- ` | Your machine | While iterating on one server | | Pre-push gate | `npm run local:gate` | Your machine, under a lease | Before every push | -| CI | `.github/workflows/typescript.yml`, `python.yml`; `dco.yml` | GitHub, on every push and pull request; `dco.yml` on pull requests only | Before merge | +| CI | `.github/workflows/typescript.yml`, `python.yml`, `everything-mcp-diff.yml`; `dco.yml` | GitHub, on every push and pull request (`everything-mcp-diff.yml`: when `everything` or what builds it changes); `dco.yml` on pull requests only | Before merge | **`npm run local:gate` runs every check CI runs.** CI splits the same checks into parallel jobs on a fresh install; the gate runs them in sequence on your @@ -49,6 +49,7 @@ in this order, and the first failure stops the run. | 7 | `coverage:py` | Per Python server: `uv sync --locked`, then `uv run --frozen pytest --cov --cov-report=term-missing --cov-report=json`; then **every file** in `coverage.json` must reach **90% on lines and 90% on branches** | `python.yml` → **Coverage \** (one leg each) | | 8 | `verify:skills:cli` | `claude plugin validate` on `.claude/skills`, at the pinned CLI version | `typescript.yml` → **Root guards** | | 9 | `smoke` | Every server boots over each transport it implements and answers one tool call | `typescript.yml` → **Boot smoke** | +| 10 | `interface-diff` | The `everything` server's MCP interface (capabilities, tools, prompts, resources, resource templates) in this checkout's build, diffed against the same server built from the base. It fails only when the diff cannot run; a changed interface is reported, not failed | `everything-mcp-diff.yml` → **Diff Everything Server Interface** (and the PR comment) | Notes on the stages: @@ -91,8 +92,9 @@ Notes on the stages: when a server's environment is missing or behind its lockfile, since `uv sync --locked` then downloads packages (and the pinned interpreter, if `uv` does not have it); `coverage:py` begins with the same sync, which is a - no-op once `validate:py` has run. With warm caches those are the only stages - that can fail offline. + no-op once `validate:py` has run; and `interface-diff` needs it for the + base's `npm ci`. With warm caches those are the only stages that can fail + offline. - **`smoke` launches what a user launches**: the built `dist/index.js` for a TypeScript server, the console script through `uv run --no-sync` for a Python one. stdio for all seven; HTTP+SSE and Streamable HTTP as well for @@ -102,6 +104,16 @@ Notes on the stages: than the default 3001. It runs after `validate` and `validate:py` because it needs the build and each Python server's synced environment; it creates neither. +- **`interface-diff` compares against the merge-base with `origin/v2/main`** + locally (`-- --base ` names another), and against the PR's base, the + commit before the push, or the previous release's tag in CI. It builds the + base from an export of that commit under `node_modules/.cache/` and removes + it afterwards; the head is the build `validate` made. It drives the + `mcp-server-diff` CLI, an exact-pinned root devDependency, which probes with + the 2026-07-28 `server/discover` first and falls back to the `initialize` + handshake, so it covers a server in either era. Its `npm ci` of the base + needs the network when the npm cache is cold, and it needs + `origin/v2/main` fetched. Only `everything` is diffed today (#4860). - `python.yml` also has a **Build \** job per server that re-runs pyright and `uv build` and uploads the built distribution. It checks nothing stage 6 does not. From b35e29de98458b5ca86f0d926b6e899401bb4ab3 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 4 Oct 2026 02:14:23 -0400 Subject: [PATCH 021/117] ci(everything): diff a Release against the previous Release (#4860) `git describe` finds the nearest ancestor tag, but the date-stamped releases tagged commits that never reached main, so from main it lands on typescript-servers-0.6.2 (2024). Take the previous published Release's tag from the Releases list instead. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .github/workflows/everything-mcp-diff.yml | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/.github/workflows/everything-mcp-diff.yml b/.github/workflows/everything-mcp-diff.yml index 30f556c8e6..27107e0026 100644 --- a/.github/workflows/everything-mcp-diff.yml +++ b/.github/workflows/everything-mcp-diff.yml @@ -48,7 +48,9 @@ on: - "scripts/interface-diff.mjs" - ".github/workflows/everything-mcp-diff.yml" # A published Release (a milestone, tagged vX.Y.Z on main; see RELEASING.md) - # surfaces the cumulative interface delta since the previous release's tag. + # surfaces the cumulative interface delta since the previous Release. This + # replaces #3260's `typescript-servers-*` tag trigger: those tags stopped in + # 2024, and no release since has used them. release: types: [published] # Manual trigger for comparing any two refs (commits, tags, branches) @@ -84,7 +86,7 @@ jobs: uses: actions/checkout@v6 with: # The base is built from history, so all of it is needed, tags - # included (the release base is the previous tag). + # included (the release base is the previous Release's tag). fetch-depth: 0 ref: ${{ inputs.target_ref || github.ref }} persist-credentials: false @@ -106,6 +108,9 @@ jobs: PR_BASE: ${{ github.event.pull_request.base.sha }} BEFORE: ${{ github.event.before }} INPUT_BASE: ${{ inputs.compare_ref }} + TAG: ${{ github.event.release.tag_name }} + REPO: ${{ github.repository }} + GH_TOKEN: ${{ github.token }} run: | case "$EVENT" in # The checkout is the PR's merge commit, so its base tip is @@ -118,7 +123,14 @@ jobs: else base=$(git rev-parse HEAD^) fi ;; - release) base=$(git describe --tags --abbrev=0 HEAD^) ;; + # The previous published Release's tag. Not `git describe`: the + # date-stamped releases tagged commits that never reached main, so + # the nearest ancestor tag is years older than the last release. + release) + base=$(gh release list --repo "$REPO" --exclude-drafts --exclude-pre-releases --limit 50 \ + --json tagName,publishedAt \ + --jq "map(select(.tagName != \"$TAG\")) | sort_by(.publishedAt) | last | .tagName // empty") + if [ -z "$base" ]; then echo "no earlier Release to compare against" >&2; exit 1; fi ;; workflow_dispatch) if [ -n "$INPUT_BASE" ]; then base="$INPUT_BASE" From f242458df7869e57b15f001c558ea277a0426885 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 4 Oct 2026 03:17:42 -0400 Subject: [PATCH 022/117] fix(interface-diff): check out the event's SHA; report the CLI's stderr (#4860) Copilot round 1 on #4975: - Check out github.sha rather than github.ref, so a push run diffs the commit it was triggered for against that event's `before`, not a branch tip a later push moved. - When mcp-server-diff prints no JSON report, include its stderr (where a fatal error goes) in the error, so the reason is not blank. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .github/workflows/everything-mcp-diff.yml | 4 +++- scripts/interface-diff.mjs | 12 +++++++++--- scripts/interface-diff.test.mjs | 6 ++++++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/.github/workflows/everything-mcp-diff.yml b/.github/workflows/everything-mcp-diff.yml index 27107e0026..43fae86a20 100644 --- a/.github/workflows/everything-mcp-diff.yml +++ b/.github/workflows/everything-mcp-diff.yml @@ -88,7 +88,9 @@ jobs: # The base is built from history, so all of it is needed, tags # included (the release base is the previous Release's tag). fetch-depth: 0 - ref: ${{ inputs.target_ref || github.ref }} + # The event's own commit (the merge commit for a PR), not a branch + # ref that a later push could move before this step runs. + ref: ${{ inputs.target_ref || github.sha }} persist-credentials: false - uses: actions/setup-node@v7 diff --git a/scripts/interface-diff.mjs b/scripts/interface-diff.mjs index 3b46b27bc0..ce4c9ff63c 100644 --- a/scripts/interface-diff.mjs +++ b/scripts/interface-diff.mjs @@ -101,18 +101,24 @@ export function parseArgs(argv) { * its exit code cannot tell a change from a broken check; the JSON can. * * @param {string} stdout what `mcp-server-diff -o json -q` printed + * @param {string} [stderr] what it printed to stderr: a fatal error before the + * report goes there, so it is the reason when no report came out * @returns {{ status: "unchanged" | "changed" | "error", error?: string, * diffs: { endpoint: string, diff: string }[], * baseCounts?: object, headCounts?: object }} */ -export function classify(stdout) { +export function classify(stdout, stderr = "") { let parsed; try { parsed = JSON.parse(stdout); } catch { return { status: "error", - error: `mcp-server-diff printed no JSON report:\n${stdout.trim().slice(0, 2000)}`, + error: `mcp-server-diff printed no JSON report:\n${[stderr, stdout] + .map((s) => s.trim()) + .filter(Boolean) + .join("\n") + .slice(0, 2000)}`, diffs: [], }; } @@ -321,7 +327,7 @@ export async function main(argv = process.argv.slice(2)) { { cwd: repoRoot, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }, ); if (res.error) throw res.error; - verdict = classify(res.stdout); + verdict = classify(res.stdout, res.stderr); } catch (err) { verdict = { status: "error", error: err.message, diffs: [] }; } finally { diff --git a/scripts/interface-diff.test.mjs b/scripts/interface-diff.test.mjs index e30008c34e..0d36cbffbf 100644 --- a/scripts/interface-diff.test.mjs +++ b/scripts/interface-diff.test.mjs @@ -89,6 +89,12 @@ test("output that is not the JSON report is an error, never a pass", () => { assert.equal(classify(JSON.stringify({ results: [] })).status, "error"); }); +test("a CLI crash before the report carries its stderr as the reason", () => { + const v = classify("", "Fatal error: Error: spawn node ENOENT"); + assert.equal(v.status, "error"); + assert.match(v.error, /no JSON report[\s\S]*spawn node ENOENT/); +}); + test("fenceFor outruns any backticks in the content", () => { assert.equal(fenceFor("plain"), "```"); assert.equal(fenceFor("a ``` b"), "````"); From 0c2a61d62bd7206d9f43a4a2b94bf55cbed2c4b9 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 4 Oct 2026 03:25:44 -0400 Subject: [PATCH 023/117] fix(interface-diff): diff a Release against its own predecessor (#4860) Copilot round 2 on #4975: taking the latest Release other than the current one picks a newer Release when an older one's run is re-run. Keep only Releases published before this event's Release. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .github/workflows/everything-mcp-diff.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/everything-mcp-diff.yml b/.github/workflows/everything-mcp-diff.yml index 43fae86a20..adbc391f65 100644 --- a/.github/workflows/everything-mcp-diff.yml +++ b/.github/workflows/everything-mcp-diff.yml @@ -111,6 +111,7 @@ jobs: BEFORE: ${{ github.event.before }} INPUT_BASE: ${{ inputs.compare_ref }} TAG: ${{ github.event.release.tag_name }} + PUBLISHED: ${{ github.event.release.published_at }} REPO: ${{ github.repository }} GH_TOKEN: ${{ github.token }} run: | @@ -125,13 +126,15 @@ jobs: else base=$(git rev-parse HEAD^) fi ;; - # The previous published Release's tag. Not `git describe`: the - # date-stamped releases tagged commits that never reached main, so - # the nearest ancestor tag is years older than the last release. + # The Release published just before this one (so a re-run of an + # older Release still compares against ITS predecessor). Not + # `git describe`: the date-stamped releases tagged commits that + # never reached main, so the nearest ancestor tag is years older + # than the last release. release) base=$(gh release list --repo "$REPO" --exclude-drafts --exclude-pre-releases --limit 50 \ --json tagName,publishedAt \ - --jq "map(select(.tagName != \"$TAG\")) | sort_by(.publishedAt) | last | .tagName // empty") + --jq "map(select(.tagName != \"$TAG\" and .publishedAt < \"$PUBLISHED\")) | sort_by(.publishedAt) | last | .tagName // empty") if [ -z "$base" ]; then echo "no earlier Release to compare against" >&2; exit 1; fi ;; workflow_dispatch) if [ -n "$INPUT_BASE" ]; then From a793ed4d99ab997472c5a969f797791581028d59 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 4 Oct 2026 03:34:27 -0400 Subject: [PATCH 024/117] fix(interface-diff): Dependabot, root tsconfig, Windows quoting (#4860) Copilot round 3 on #4975 ("previously missed"): - Skip the comment job on Dependabot PRs, which are same-repo but get a read-only token; they keep the summary and artifact like fork PRs. - Trigger on the root tsconfig.json, which everything's tsconfig extends. - Spawn only npm through a shell on Windows, with its arguments quoted by winShellArgs; git needs no shell. - Silence the expected usage error in the script test. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .github/workflows/everything-mcp-diff.yml | 5 +++++ scripts/interface-diff.mjs | 12 +++++++++--- scripts/interface-diff.test.mjs | 5 ++++- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/.github/workflows/everything-mcp-diff.yml b/.github/workflows/everything-mcp-diff.yml index adbc391f65..8add0835c9 100644 --- a/.github/workflows/everything-mcp-diff.yml +++ b/.github/workflows/everything-mcp-diff.yml @@ -36,6 +36,7 @@ on: - "src/everything/**" - "package.json" - "package-lock.json" + - "tsconfig.json" - "scripts/interface-diff.mjs" - ".github/workflows/everything-mcp-diff.yml" # No branch filter: PRs to v2/main and to main both run, and so does a PR @@ -45,6 +46,7 @@ on: - "src/everything/**" - "package.json" - "package-lock.json" + - "tsconfig.json" - "scripts/interface-diff.mjs" - ".github/workflows/everything-mcp-diff.yml" # A published Release (a milestone, tagged vX.Y.Z on main; see RELEASING.md) @@ -187,6 +189,8 @@ jobs: # navigating to the Actions tab. One comment per PR, found again by its # marker and edited on each push. This job checks nothing out and runs none # of the PR's code; it only reads the report the `diff` job uploaded. + # Dependabot's PRs are same-repo but get a read-only token, so they are + # skipped like fork PRs and keep the job summary and the artifact. comment: name: Post the diff on the PR needs: diff @@ -194,6 +198,7 @@ jobs: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.user.login != 'dependabot[bot]' && (needs.diff.result == 'success' || needs.diff.result == 'failure') timeout-minutes: 5 runs-on: ubuntu-latest diff --git a/scripts/interface-diff.mjs b/scripts/interface-diff.mjs index ce4c9ff63c..0f40d0d904 100644 --- a/scripts/interface-diff.mjs +++ b/scripts/interface-diff.mjs @@ -56,6 +56,7 @@ import { import { createRequire } from "node:module"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { winShellArgs } from "./lib/win-shell-args.mjs"; const repoRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -191,14 +192,19 @@ export function renderReport(verdict, { base, head }) { return lines.join("\n"); } -/** Run a command to completion; throw with its output when it fails. */ +/** + * Run a command to completion; throw with its output when it fails. Only + * `npm` needs a shell, and only on Windows (it is a `.cmd` shim there); its + * arguments are then quoted for `cmd.exe`. `git` is spawned without one. + */ function run(command, args, cwd, env) { - const res = spawnSync(command, args, { + const shell = process.platform === "win32" && command === "npm"; + const res = spawnSync(command, shell ? winShellArgs(args) : args, { cwd, env: env ?? process.env, encoding: "utf8", maxBuffer: 64 * 1024 * 1024, - shell: process.platform === "win32", + shell, }); if (res.error) throw res.error; if (res.status !== 0) diff --git a/scripts/interface-diff.test.mjs b/scripts/interface-diff.test.mjs index 0d36cbffbf..20134909a5 100644 --- a/scripts/interface-diff.test.mjs +++ b/scripts/interface-diff.test.mjs @@ -37,8 +37,11 @@ test("parseArgs refuses an unknown flag or a missing value", () => { assert.throws(() => parseArgs(["--base="]), /--base needs a value/); }); -test("main exits 2 on a bad command line, before building anything", async () => { +test("main exits 2 on a bad command line, before building anything", async (t) => { + // The usage error it prints is expected; keep it out of the test output. + const error = t.mock.method(console, "error", () => {}); assert.equal(await main(["--nope"]), 2); + assert.match(String(error.mock.calls[0]?.arguments[0]), /unknown argument/); }); test("an identical interface is unchanged", () => { From b15cf214094b773487490c24ba79aa9d81ca5f2e Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 4 Oct 2026 03:44:53 -0400 Subject: [PATCH 025/117] fix(interface-diff): give each push and release run its own group (#4860) Copilot round 4 on #4975: a concurrency group keeps only one pending run, so a third rapid push cancelled the queued one even without cancel-in-progress. Group by ref only for PRs; by run id otherwise. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .github/workflows/everything-mcp-diff.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/everything-mcp-diff.yml b/.github/workflows/everything-mcp-diff.yml index 8add0835c9..9520b235c7 100644 --- a/.github/workflows/everything-mcp-diff.yml +++ b/.github/workflows/everything-mcp-diff.yml @@ -70,10 +70,11 @@ on: permissions: contents: read -# Superseded runs on the same PR are cancelled; pushes and releases each keep -# their own run. +# Superseded runs on the same PR are cancelled. Every other run gets a group +# of its own: a group holds one running and one pending run, so a shared group +# would drop a push's run when two more queued behind it. concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: From fe4cef392799f5462a2fa61745aa9f4fc06ab6cf Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 4 Oct 2026 03:51:55 -0400 Subject: [PATCH 026/117] fix(interface-diff): never pass a report without a verdict (#4860) Copilot round 5 on #4975: - A result with no boolean hasDifferences is an error, not "unchanged". - On a probe failure, report the CLI's contextual message ("Base server probe failed" / "Target probe failed") rather than the bare exception. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- scripts/interface-diff.mjs | 15 +++++++++++++-- scripts/interface-diff.test.mjs | 6 ++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/scripts/interface-diff.mjs b/scripts/interface-diff.mjs index 0f40d0d904..58c084f127 100644 --- a/scripts/interface-diff.mjs +++ b/scripts/interface-diff.mjs @@ -131,8 +131,19 @@ export function classify(stdout, stderr = "") { diffs: [], }; const diffs = Array.isArray(result.diffs) ? result.diffs : []; - if (result.error) - return { status: "error", error: String(result.error), diffs }; + if (result.error) { + // `error` is the bare exception; the entry in `diffs` says which build + // failed ("Base server probe failed: …" / "Target probe failed: …"). + const context = diffs.find((d) => d.endpoint === "error")?.diff; + return { status: "error", error: String(context ?? result.error), diffs }; + } + // A report without a verdict is not a pass. + if (typeof result.hasDifferences !== "boolean") + return { + status: "error", + error: "mcp-server-diff's report has no hasDifferences verdict", + diffs, + }; return { status: result.hasDifferences ? "changed" : "unchanged", diffs, diff --git a/scripts/interface-diff.test.mjs b/scripts/interface-diff.test.mjs index 20134909a5..90d6fc25ab 100644 --- a/scripts/interface-diff.test.mjs +++ b/scripts/interface-diff.test.mjs @@ -77,12 +77,13 @@ test("a probe failure is an error, though the CLI reports it as a difference", ( const v = classify( cliJson({ hasDifferences: true, - error: "Target probe failed: boom", + error: "boom", diffs: [{ endpoint: "error", diff: "Target probe failed: boom" }], }), ); assert.equal(v.status, "error"); - assert.match(v.error, /boom/); + // The context (which build failed) comes from the diffs entry. + assert.equal(v.error, "Target probe failed: boom"); }); test("output that is not the JSON report is an error, never a pass", () => { @@ -90,6 +91,7 @@ test("output that is not the JSON report is an error, never a pass", () => { assert.equal(classify("Fatal error: x").status, "error"); assert.equal(classify("{}").status, "error"); assert.equal(classify(JSON.stringify({ results: [] })).status, "error"); + assert.equal(classify(JSON.stringify({ results: [{}] })).status, "error"); }); test("a CLI crash before the report carries its stderr as the reason", () => { From ff191795dcb1dc70f25da0adc638d49de1b5f50e Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 4 Oct 2026 04:02:20 -0400 Subject: [PATCH 027/117] fix(interface-diff): bound the job summary; watch the Windows helper (#4860) Copilot round 6 on #4975: - Cap the job summary below GitHub's 1 MiB limit, pointing to the artifact when the report is longer. - Add scripts/lib/win-shell-args.mjs, which the script imports, to the path filters. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .github/workflows/everything-mcp-diff.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/everything-mcp-diff.yml b/.github/workflows/everything-mcp-diff.yml index 9520b235c7..144eacfd79 100644 --- a/.github/workflows/everything-mcp-diff.yml +++ b/.github/workflows/everything-mcp-diff.yml @@ -38,6 +38,7 @@ on: - "package-lock.json" - "tsconfig.json" - "scripts/interface-diff.mjs" + - "scripts/lib/win-shell-args.mjs" - ".github/workflows/everything-mcp-diff.yml" # No branch filter: PRs to v2/main and to main both run, and so does a PR # stacked on another PR's branch. The base is the PR's own base either way. @@ -48,6 +49,7 @@ on: - "package-lock.json" - "tsconfig.json" - "scripts/interface-diff.mjs" + - "scripts/lib/win-shell-args.mjs" - ".github/workflows/everything-mcp-diff.yml" # A published Release (a milestone, tagged vX.Y.Z on main; see RELEASING.md) # surfaces the cumulative interface delta since the previous Release. This @@ -160,7 +162,12 @@ jobs: run: | { if [ -f mcp-diff-report/report.md ]; then - cat mcp-diff-report/report.md + # A step summary holds at most 1 MiB. + head -c 900000 mcp-diff-report/report.md + if [ "$(wc -c < mcp-diff-report/report.md)" -gt 900000 ]; then + echo "" + echo "_The report is truncated here; the full report is in the run's \`mcp-diff-report\` artifact._" + fi else echo "The interface diff produced no report; see the job log." fi From 9d7128ca3b5b98398e88c9ea90279b69b7f24c2b Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 4 Oct 2026 04:24:53 -0400 Subject: [PATCH 028/117] fix(interface-diff): drop target_ref; keep both streams in errors (#4860) Copilot round 7 on #4975: - Remove the dispatch `target_ref` input: a target that predates the script has no `npm run interface-diff`, so the run failed. The head is the branch chosen in "Use workflow from". - A failed base install or build now reports the tail of both stdout and stderr, so tsc diagnostics are not lost behind an npm footer. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .github/workflows/everything-mcp-diff.yml | 11 +++++------ scripts/interface-diff.mjs | 8 +++++++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/.github/workflows/everything-mcp-diff.yml b/.github/workflows/everything-mcp-diff.yml index 144eacfd79..16fe49c17d 100644 --- a/.github/workflows/everything-mcp-diff.yml +++ b/.github/workflows/everything-mcp-diff.yml @@ -57,17 +57,16 @@ on: # 2024, and no release since has used them. release: types: [published] - # Manual trigger for comparing any two refs (commits, tags, branches) + # Manual trigger: the branch chosen in "Use workflow from" is the head, and + # `compare_ref` any base. #3260 also took a `target_ref`, but a target that + # predates the interface-diff script has no `npm run interface-diff` to run, + # so the head is the dispatched branch instead. workflow_dispatch: inputs: compare_ref: description: "Base ref to compare against (commit SHA, tag, or branch; defaults to the merge-base with v2/main)" required: false default: "" - target_ref: - description: "Target ref to compare (defaults to the ref the workflow runs on)" - required: false - default: "" permissions: contents: read @@ -95,7 +94,7 @@ jobs: fetch-depth: 0 # The event's own commit (the merge commit for a PR), not a branch # ref that a later push could move before this step runs. - ref: ${{ inputs.target_ref || github.sha }} + ref: ${{ github.sha }} persist-credentials: false - uses: actions/setup-node@v7 diff --git a/scripts/interface-diff.mjs b/scripts/interface-diff.mjs index 58c084f127..59403326c4 100644 --- a/scripts/interface-diff.mjs +++ b/scripts/interface-diff.mjs @@ -203,6 +203,12 @@ export function renderReport(verdict, { base, head }) { return lines.join("\n"); } +/** The last lines of a stream, for an error message; empty stays empty. */ +const tail = (text) => { + const lines = (text ?? "").trim(); + return lines ? `${lines.split("\n").slice(-25).join("\n")}\n` : ""; +}; + /** * Run a command to completion; throw with its output when it fails. Only * `npm` needs a shell, and only on Windows (it is a `.cmd` shim there); its @@ -220,7 +226,7 @@ function run(command, args, cwd, env) { if (res.error) throw res.error; if (res.status !== 0) throw new Error( - `\`${command} ${args.join(" ")}\` exited ${res.status}:\n${(res.stderr || res.stdout).trim().split("\n").slice(-25).join("\n")}`, + `\`${command} ${args.join(" ")}\` exited ${res.status}:\n${tail(res.stdout)}${tail(res.stderr)}`, ); return res.stdout; } From 6eebb0c65045c839448e8623ddced0ea5ffd806e Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 9 Oct 2026 23:25:40 -0400 Subject: [PATCH 029/117] chore(interface-diff): refresh the lockfile after rebasing onto v2/main (#4860) v2/main no longer carries SDK v1 at the root (#5063), so mcp-server-diff's @modelcontextprotocol/sdk ^1.13.2 now resolves to its own dev-only copy. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- package-lock.json | 140 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) diff --git a/package-lock.json b/package-lock.json index 6359f597db..a9adead5f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -622,6 +622,19 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@hono/node-server": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.4.tgz", + "integrity": "sha512-2g1qeHl4BduwDn6L8nkAvDrPUWPMlkQ//a5NhP/zIyaXKMjZNRV+cG9Csu+BiJFf6+68pqzjf4Hi5sJGKHuS1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4 || ^5.0.0-0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -841,6 +854,47 @@ "hono": "^4" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.32.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.1.tgz", + "integrity": "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, "node_modules/@modelcontextprotocol/server": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.3.1.tgz", @@ -1847,6 +1901,41 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2829,6 +2918,23 @@ "fast-string-truncated-width": "^3.0.2" } }, + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fast-wrap-ansi": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", @@ -3447,6 +3553,20 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -4518,6 +4638,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -5520,6 +5650,16 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, "src/everything": { "name": "@modelcontextprotocol/server-everything", "version": "1.0.0", From d52c223121209f44c30ff867d9fc4665155f91de Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 9 Oct 2026 23:46:10 -0400 Subject: [PATCH 030/117] time: accept only exact IANA timezone keys on every platform On macOS, zoneinfo resolves a key such as europe/warsaw through the case-insensitive APFS tz database, so the server accepted it where Linux rejects it, and test_get_current_time_errors failed locally. Check each key against available_timezones() in the tool path and in the --local-timezone validation. Closes #5060 Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/time/README.md | 2 ++ src/time/src/mcp_server_time/server.py | 28 +++++++++++++++++++++++--- src/time/tests/test_protocol.py | 1 + src/time/tests/test_server.py | 21 ++++++++++++++++++- 4 files changed, 48 insertions(+), 4 deletions(-) diff --git a/src/time/README.md b/src/time/README.md index 2b878eab81..6cb87ee4b6 100644 --- a/src/time/README.md +++ b/src/time/README.md @@ -20,6 +20,8 @@ Requires MCP Python SDK 2.x (`mcp>=2.2,<3`). - `time` (string): Time in 24-hour format (HH:MM) - `target_timezone` (string): Target IANA timezone name +A timezone name, here and in `--local-timezone`, must be an exact IANA key, case included: `Europe/Warsaw` is accepted and `europe/warsaw` is rejected on every platform, including macOS, whose case-insensitive filesystem would otherwise resolve it. + ## Installation ### Using uv (recommended) diff --git a/src/time/src/mcp_server_time/server.py b/src/time/src/mcp_server_time/server.py index 9f56bb28fa..92e2704d7b 100644 --- a/src/time/src/mcp_server_time/server.py +++ b/src/time/src/mcp_server_time/server.py @@ -9,13 +9,14 @@ # loop only; serving 2026-07-28 is #4853. from datetime import datetime, timedelta +from functools import cache from importlib.metadata import version from enum import Enum import json import sys from typing import Any -from zoneinfo import ZoneInfo, ZoneInfoNotFoundError +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError, available_timezones from tzlocal import get_localzone_name # ← returns "Europe/Paris", etc. import jsonschema @@ -79,9 +80,30 @@ def get_local_tz(local_tz_override: str | None = None) -> ZoneInfo: return ZoneInfo("UTC") +@cache +def known_timezones() -> frozenset[str]: + """The exact IANA keys this host's tz database holds, read once.""" + return frozenset(available_timezones()) + + +def load_zoneinfo(timezone_name: str) -> ZoneInfo: + """`ZoneInfo(timezone_name)`, accepting only an exact IANA key. + + zoneinfo looks a key up as a file path, so on a case-insensitive + filesystem (macOS's APFS) `europe/warsaw` resolves to `Europe/Warsaw` + while Linux rejects it (#5060). Checking the key against the database's + own list makes every platform reject it. `ZoneInfo` runs first so a path + or a directory keeps the error zoneinfo gives it. + """ + zone = ZoneInfo(timezone_name) + if timezone_name not in known_timezones(): + raise ZoneInfoNotFoundError(f"No time zone found with key {timezone_name}") + return zone + + def get_zoneinfo(timezone_name: str) -> ZoneInfo: try: - return ZoneInfo(timezone_name) + return load_zoneinfo(timezone_name) except Exception as e: raise MCPError(code=INVALID_PARAMS, message=f"Invalid timezone: {str(e)}") @@ -173,7 +195,7 @@ async def serve(local_timezone: str | None = None) -> None: # bad key (unknown name, a tzdata directory, a path, a null byte), and # its messages can carry a filesystem path, so none of them is shown. try: - ZoneInfo(local_timezone) + load_zoneinfo(local_timezone) except (ZoneInfoNotFoundError, ValueError, OSError): sys.exit( f"Error: invalid --local-timezone {local_timezone!r}: " diff --git a/src/time/tests/test_protocol.py b/src/time/tests/test_protocol.py index e2ab1d6384..5c2246097e 100644 --- a/src/time/tests/test_protocol.py +++ b/src/time/tests/test_protocol.py @@ -250,6 +250,7 @@ async def test_empty_local_timezone_override_is_treated_as_absent() -> None: "Not/AZone", # unknown name: ZoneInfoNotFoundError "America", # a tzdata directory, not a zone: OSError "../etc/passwd", # outside TZPATH: ValueError + "europe/warsaw", # not the exact key, on any filesystem (#5060) ], ) async def test_invalid_local_timezone_fails_before_the_transport_opens( diff --git a/src/time/tests/test_server.py b/src/time/tests/test_server.py index a197daa07a..a64ecc863f 100644 --- a/src/time/tests/test_server.py +++ b/src/time/tests/test_server.py @@ -4,7 +4,12 @@ from unittest.mock import patch from zoneinfo import ZoneInfo -from mcp_server_time.server import TimeServer, get_local_tz +from mcp_server_time.server import ( + TimeServer, + get_local_tz, + get_zoneinfo, + known_timezones, +) @pytest.mark.parametrize( @@ -525,3 +530,17 @@ def test_get_local_tz_various_timezones(mock_get_localzone, timezone_name): result = get_local_tz() assert str(result) == timezone_name assert isinstance(result, ZoneInfo) + + +def test_get_zoneinfo_rejects_a_key_the_tz_database_does_not_list(): + # #5060: on a case-insensitive filesystem `ZoneInfo` resolves a key that + # is not an exact IANA name. Simulate that on any platform: `ZoneInfo` + # accepts "Europe/Warsaw", but the database's key list does not hold it. + without_warsaw = known_timezones() - {"Europe/Warsaw"} + with patch("mcp_server_time.server.known_timezones", return_value=without_warsaw): + with pytest.raises( + MCPError, + match="Invalid timezone: 'No time zone found with key Europe/Warsaw'", + ): + get_zoneinfo("Europe/Warsaw") + assert str(get_zoneinfo("Europe/Warsaw")) == "Europe/Warsaw" From d7388c6408754374b034d6a104094159c145b69d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Fri, 9 Oct 2026 23:46:29 -0400 Subject: [PATCH 031/117] test(memory): expect Node 26's path-suffixed EISDIR message Node 26 appends the file path to the EISDIR read error, so the exact-match assertion in persistence.test.ts failed there. Expect the suffixed text on Node 26 and later and the bare text before, keeping the exact match. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/memory/__tests__/persistence.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/memory/__tests__/persistence.test.ts b/src/memory/__tests__/persistence.test.ts index 24ac8c2a57..9f867bed5b 100644 --- a/src/memory/__tests__/persistence.test.ts +++ b/src/memory/__tests__/persistence.test.ts @@ -211,6 +211,11 @@ describe("memory persistence over the protocol", () => { // which is not the ENOENT that means "no graph yet". await fs.mkdir(filePath); const { client } = await open(); + // Node 26 appends the path to the message; earlier versions do not + // (#5076). The server passes Node's message through either way. + const bare = "EISDIR: illegal operation on a directory, read"; + const nodeMajor = Number(process.versions.node.split(".")[0]); + const expected = nodeMajor >= 26 ? `${bare} '${filePath}'` : bare; for (const [name, args] of [ ["read_graph", {}], @@ -220,9 +225,7 @@ describe("memory persistence over the protocol", () => { ] as const) { const result = await call(client, name, args); expect(result.isError).toBe(true); - expect(textOf(result)).toBe( - "EISDIR: illegal operation on a directory, read", - ); + expect(textOf(result)).toBe(expected); } await expect( client.readResource({ uri: "memory://knowledge-graph" }), From cd34c3b0f91c851a8052399a4d6dbe8f37ec51f5 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 10 Oct 2026 01:02:46 -0400 Subject: [PATCH 032/117] chore(deps): pin proxy-addr to ^2.0.8 (GHSA-jqcg-44mw-7w3h) Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- package-lock.json | 10 +++++++--- package.json | 3 ++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index a9adead5f4..21cc802b98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4489,9 +4489,9 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -4499,6 +4499,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/pump": { diff --git a/package.json b/package.json index 49ed4ba99e..d66229c32f 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ }, "overrides": { "qs": ">=6.15.2", - "hono": ">=4.12.21" + "hono": ">=4.12.21", + "proxy-addr": "^2.0.8" } } From 916ccca7bfa0357d7d65ef6f7f5d610ad21d49d6 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 10 Oct 2026 01:02:46 -0400 Subject: [PATCH 033/117] chore(deps): pin ip-address to ^10.7.1 (GHSA-mwp4-54f8-5fhr and six more) Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- package-lock.json | 6 +++--- package.json | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 21cc802b98..3b563fc491 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3386,9 +3386,9 @@ } }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.3.tgz", + "integrity": "sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==", "license": "MIT", "engines": { "node": ">= 12" diff --git a/package.json b/package.json index d66229c32f..d32ec469b8 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "overrides": { "qs": ">=6.15.2", "hono": ">=4.12.21", - "proxy-addr": "^2.0.8" + "proxy-addr": "^2.0.8", + "ip-address": "^10.7.1" } } From 5f0b9b99deecc175f7708507c2be1ff0274416b1 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 10 Oct 2026 01:02:47 -0400 Subject: [PATCH 034/117] chore(deps): pin brace-expansion to ^5.0.12 (GHSA-rgw5-rvv9-x895 and three more) Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- package-lock.json | 6 +++--- package.json | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3b563fc491..6f381ad645 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2005,9 +2005,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" diff --git a/package.json b/package.json index d32ec469b8..29465dd222 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "qs": ">=6.15.2", "hono": ">=4.12.21", "proxy-addr": "^2.0.8", - "ip-address": "^10.7.1" + "ip-address": "^10.7.1", + "brace-expansion": "^5.0.12" } } From ed640a12a412c729acaf45e243fc0ed63e6ae590 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 10 Oct 2026 01:02:47 -0400 Subject: [PATCH 035/117] chore(deps): raise the hono override to >=4.13.7 (GHSA-8j4g-w8fx-2239 and seven more) Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6f381ad645..d1c6b9dbe6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3264,9 +3264,9 @@ } }, "node_modules/hono": { - "version": "4.12.32", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.32.tgz", - "integrity": "sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==", + "version": "4.13.13", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz", + "integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==", "license": "MIT", "engines": { "node": ">=16.9.0" diff --git a/package.json b/package.json index 29465dd222..3995650a3a 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,7 @@ }, "overrides": { "qs": ">=6.15.2", - "hono": ">=4.12.21", + "hono": ">=4.13.7", "proxy-addr": "^2.0.8", "ip-address": "^10.7.1", "brace-expansion": "^5.0.12" From c88aa6dbf65b0daaab7d0dfc6973cc19446e0b32 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 10 Oct 2026 01:02:47 -0400 Subject: [PATCH 036/117] chore(deps): raise the qs override to >=6.16.0 (GHSA-x5fp-wj9c-mxmx, GHSA-4mjr-xmp4-gh2g) Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- package-lock.json | 9 +++++---- package.json | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index d1c6b9dbe6..aad36ae4fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4547,12 +4547,13 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" diff --git a/package.json b/package.json index 3995650a3a..62287e50d2 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ "yaml": "^2.9.0" }, "overrides": { - "qs": ">=6.15.2", + "qs": ">=6.16.0", "hono": ">=4.13.7", "proxy-addr": "^2.0.8", "ip-address": "^10.7.1", From d261885b799e89f988c4f5ffd23746cc8f332ccf Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 10 Oct 2026 01:03:05 -0400 Subject: [PATCH 037/117] chore(deps): pin nanoid to ^3.3.18 (GHSA-2v37-7h3g-55p8) Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- package-lock.json | 6 +++--- package.json | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index aad36ae4fc..01386d4635 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4124,9 +4124,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "dev": true, "funding": [ { diff --git a/package.json b/package.json index 62287e50d2..33a49082a8 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "hono": ">=4.13.7", "proxy-addr": "^2.0.8", "ip-address": "^10.7.1", - "brace-expansion": "^5.0.12" + "brace-expansion": "^5.0.12", + "nanoid": "^3.3.18" } } From fb942ea050c860fe5cd7be172ba7e17b2ab949cd Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 10 Oct 2026 01:03:05 -0400 Subject: [PATCH 038/117] chore(deps): pin source-map-js to ^1.2.2 (GHSA-68fv-2mgg-jv7q) Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- package-lock.json | 6 +++--- package.json | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 01386d4635..aa59d49cfd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5018,9 +5018,9 @@ "license": "MIT" }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { diff --git a/package.json b/package.json index 33a49082a8..f63222a12b 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "proxy-addr": "^2.0.8", "ip-address": "^10.7.1", "brace-expansion": "^5.0.12", - "nanoid": "^3.3.18" + "nanoid": "^3.3.18", + "source-map-js": "^1.2.2" } } From 12efa9b121f848d98fada6766b4afacb8944891f Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 10 Oct 2026 01:03:11 -0400 Subject: [PATCH 039/117] chore(deps): bump vitest and @vitest/coverage-v8 to ^4.1.11 (GHSA-82fw-gwwq-j7x9) Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- package-lock.json | 540 +++++++++++++--------------- src/everything/package.json | 4 +- src/filesystem/package.json | 4 +- src/memory/package.json | 4 +- src/sequentialthinking/package.json | 4 +- 5 files changed, 256 insertions(+), 300 deletions(-) diff --git a/package-lock.json b/package-lock.json index aa59d49cfd..20e5533cdc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -460,40 +460,6 @@ "node": ">= 20.12.0" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", @@ -951,25 +917,6 @@ "resolved": "src/sequentialthinking", "link": true }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", - "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1009,13 +956,13 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "version": "0.153.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", + "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", "dev": true, "license": "MIT", "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/oxc-project" } }, "node_modules/@pnpm/deps.graph-sequencer": { @@ -1031,10 +978,27 @@ "url": "https://opencollective.com/pnpm" } }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", + "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", + "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", "cpu": [ "arm64" ], @@ -1049,9 +1013,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", - "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", + "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", "cpu": [ "arm64" ], @@ -1066,9 +1030,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", + "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", "cpu": [ "x64" ], @@ -1083,9 +1047,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", + "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", "cpu": [ "x64" ], @@ -1100,9 +1064,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", + "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", "cpu": [ "arm" ], @@ -1117,13 +1081,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", + "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1134,13 +1101,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", + "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1151,13 +1121,16 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", + "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1168,13 +1141,16 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", + "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1185,13 +1161,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", + "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1202,13 +1181,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", + "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1219,9 +1201,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", + "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", "cpu": [ "arm64" ], @@ -1235,29 +1217,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", + "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", "cpu": [ "arm64" ], @@ -1272,9 +1235,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", + "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", "cpu": [ "x64" ], @@ -1302,17 +1265,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -1722,14 +1674,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.8.tgz", - "integrity": "sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz", + "integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.8", + "@vitest/utils": "4.1.11", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -1743,8 +1695,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.8", - "vitest": "4.1.8" + "@vitest/browser": "4.1.11", + "vitest": "4.1.11" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1753,16 +1705,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", - "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1771,13 +1723,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", - "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.8", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1798,9 +1750,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", - "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -1811,13 +1763,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", - "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.8", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -1825,14 +1777,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", - "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1841,9 +1793,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", - "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -1851,13 +1803,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", - "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.8", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -2092,9 +2044,9 @@ } }, "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "dev": true, "license": "MIT", "engines": { @@ -2420,9 +2372,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", - "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, "license": "MIT" }, @@ -2793,9 +2745,9 @@ } }, "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3638,9 +3590,9 @@ } }, "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -3654,23 +3606,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", "cpu": [ "arm64" ], @@ -3689,9 +3641,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", "cpu": [ "arm64" ], @@ -3710,9 +3662,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", "cpu": [ "x64" ], @@ -3731,9 +3683,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", "cpu": [ "x64" ], @@ -3752,9 +3704,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", "cpu": [ "arm" ], @@ -3773,13 +3725,16 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3794,13 +3749,16 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3815,13 +3773,16 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3836,13 +3797,16 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3857,9 +3821,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", "cpu": [ "arm64" ], @@ -3878,9 +3842,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", "cpu": [ "x64" ], @@ -4210,9 +4174,9 @@ } }, "node_modules/obug": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", - "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -4416,9 +4380,9 @@ } }, "node_modules/postcss": { - "version": "8.5.25", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", - "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", "dev": true, "funding": [ { @@ -4436,9 +4400,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.19", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -4697,13 +4661,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", - "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", + "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.133.0", + "@oxc-project/types": "=0.153.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -4713,21 +4677,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.3", - "@rolldown/binding-darwin-arm64": "1.0.3", - "@rolldown/binding-darwin-x64": "1.0.3", - "@rolldown/binding-freebsd-x64": "1.0.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.3", - "@rolldown/binding-linux-arm64-musl": "1.0.3", - "@rolldown/binding-linux-ppc64-gnu": "1.0.3", - "@rolldown/binding-linux-s390x-gnu": "1.0.3", - "@rolldown/binding-linux-x64-gnu": "1.0.3", - "@rolldown/binding-linux-x64-musl": "1.0.3", - "@rolldown/binding-openharmony-arm64": "1.0.3", - "@rolldown/binding-wasm32-wasi": "1.0.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" + "@rolldown/binding-android-arm-eabi": "1.2.13", + "@rolldown/binding-android-arm64": "1.2.13", + "@rolldown/binding-darwin-arm64": "1.2.13", + "@rolldown/binding-darwin-x64": "1.2.13", + "@rolldown/binding-freebsd-x64": "1.2.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", + "@rolldown/binding-linux-arm64-gnu": "1.2.13", + "@rolldown/binding-linux-arm64-musl": "1.2.13", + "@rolldown/binding-linux-ppc64-gnu": "1.2.13", + "@rolldown/binding-linux-s390x-gnu": "1.2.13", + "@rolldown/binding-linux-x64-gnu": "1.2.13", + "@rolldown/binding-linux-x64-musl": "1.2.13", + "@rolldown/binding-openharmony-arm64": "1.2.13", + "@rolldown/binding-win32-arm64-msvc": "1.2.13", + "@rolldown/binding-win32-x64-msvc": "1.2.13" } }, "node_modules/router": { @@ -5044,9 +5008,9 @@ } }, "node_modules/std-env": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", "dev": true, "license": "MIT" }, @@ -5130,9 +5094,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", "dev": true, "license": "MIT", "engines": { @@ -5174,14 +5138,6 @@ "typescript": ">=4.8.4" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, "node_modules/tunnel": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", @@ -5324,16 +5280,16 @@ } }, "node_modules/vite": { - "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", "dev": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.3", + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", "tinyglobby": "^0.2.17" }, "bin": { @@ -5350,7 +5306,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -5402,19 +5358,19 @@ } }, "node_modules/vitest": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", - "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.8", - "@vitest/mocker": "4.1.8", - "@vitest/pretty-format": "4.1.8", - "@vitest/runner": "4.1.8", - "@vitest/snapshot": "4.1.8", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -5442,12 +5398,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.8", - "@vitest/browser-preview": "4.1.8", - "@vitest/browser-webdriverio": "4.1.8", - "@vitest/coverage-istanbul": "4.1.8", - "@vitest/coverage-v8": "4.1.8", - "@vitest/ui": "4.1.8", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -5686,10 +5642,10 @@ "@modelcontextprotocol/core": "^2.3.1", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", - "@vitest/coverage-v8": "^4.1.8", + "@vitest/coverage-v8": "^4.1.11", "shx": "^0.4.0", "typescript": "^5.8.2", - "vitest": "^4.1.8" + "vitest": "^4.1.11" }, "engines": { "node": ">=20" @@ -5714,10 +5670,10 @@ "@types/diff": "^5.0.9", "@types/minimatch": "^5.1.2", "@types/node": "^22", - "@vitest/coverage-v8": "^4.1.8", + "@vitest/coverage-v8": "^4.1.11", "shx": "^0.4.0", "typescript": "^5.8.2", - "vitest": "^4.1.8" + "vitest": "^4.1.11" }, "engines": { "node": ">=20" @@ -5737,10 +5693,10 @@ "devDependencies": { "@modelcontextprotocol/client": "^2.3.1", "@types/node": "^22", - "@vitest/coverage-v8": "^4.1.8", + "@vitest/coverage-v8": "^4.1.11", "shx": "^0.4.0", "typescript": "^5.8.2", - "vitest": "^4.1.8" + "vitest": "^4.1.11" }, "engines": { "node": ">=20" @@ -5763,10 +5719,10 @@ "@modelcontextprotocol/client": "^2.3.1", "@types/node": "^22", "@types/yargs": "^17.0.32", - "@vitest/coverage-v8": "^4.1.8", + "@vitest/coverage-v8": "^4.1.11", "shx": "^0.4.0", "typescript": "^5.8.2", - "vitest": "^4.1.8" + "vitest": "^4.1.11" }, "engines": { "node": ">=20" diff --git a/src/everything/package.json b/src/everything/package.json index 51ce62ead7..ecdb17ce3f 100644 --- a/src/everything/package.json +++ b/src/everything/package.json @@ -50,9 +50,9 @@ "@modelcontextprotocol/core": "^2.3.1", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", - "@vitest/coverage-v8": "^4.1.8", + "@vitest/coverage-v8": "^4.1.11", "shx": "^0.4.0", "typescript": "^5.8.2", - "vitest": "^4.1.8" + "vitest": "^4.1.11" } } diff --git a/src/filesystem/package.json b/src/filesystem/package.json index 2badd52383..8adbd53b22 100644 --- a/src/filesystem/package.json +++ b/src/filesystem/package.json @@ -45,9 +45,9 @@ "@types/diff": "^5.0.9", "@types/minimatch": "^5.1.2", "@types/node": "^22", - "@vitest/coverage-v8": "^4.1.8", + "@vitest/coverage-v8": "^4.1.11", "shx": "^0.4.0", "typescript": "^5.8.2", - "vitest": "^4.1.8" + "vitest": "^4.1.11" } } diff --git a/src/memory/package.json b/src/memory/package.json index 3d5fb93444..e48e521b9d 100644 --- a/src/memory/package.json +++ b/src/memory/package.json @@ -40,9 +40,9 @@ "devDependencies": { "@modelcontextprotocol/client": "^2.3.1", "@types/node": "^22", - "@vitest/coverage-v8": "^4.1.8", + "@vitest/coverage-v8": "^4.1.11", "shx": "^0.4.0", "typescript": "^5.8.2", - "vitest": "^4.1.8" + "vitest": "^4.1.11" } } diff --git a/src/sequentialthinking/package.json b/src/sequentialthinking/package.json index 42fa282ed9..ae41494e13 100644 --- a/src/sequentialthinking/package.json +++ b/src/sequentialthinking/package.json @@ -43,9 +43,9 @@ "@modelcontextprotocol/client": "^2.3.1", "@types/node": "^22", "@types/yargs": "^17.0.32", - "@vitest/coverage-v8": "^4.1.8", + "@vitest/coverage-v8": "^4.1.11", "shx": "^0.4.0", "typescript": "^5.8.2", - "vitest": "^4.1.8" + "vitest": "^4.1.11" } } From 73fe3283828451a727b9daba9ddd3109f649fead Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 10 Oct 2026 01:03:29 -0400 Subject: [PATCH 040/117] chore(fetch): require protego>=0.6.2 (PYSEC-2026-3906) Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/fetch/pyproject.toml | 2 +- src/fetch/uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/fetch/pyproject.toml b/src/fetch/pyproject.toml index 0666d3b768..2f596c278a 100644 --- a/src/fetch/pyproject.toml +++ b/src/fetch/pyproject.toml @@ -20,7 +20,7 @@ dependencies = [ "markdownify>=0.13.1", "jsonschema>=4.20.0", "mcp>=2.2,<3", - "protego>=0.3.1", + "protego>=0.6.2", "pydantic>=2.0.0", "readabilipy>=0.2.0", "requests>=2.32.3", diff --git a/src/fetch/uv.lock b/src/fetch/uv.lock index dc2c712ded..8cc539425a 100644 --- a/src/fetch/uv.lock +++ b/src/fetch/uv.lock @@ -760,7 +760,7 @@ requires-dist = [ { name = "jsonschema", specifier = ">=4.20.0" }, { name = "markdownify", specifier = ">=0.13.1" }, { name = "mcp", specifier = ">=2.2,<3" }, - { name = "protego", specifier = ">=0.3.1" }, + { name = "protego", specifier = ">=0.6.2" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "readabilipy", specifier = ">=0.2.0" }, { name = "requests", specifier = ">=2.32.3" }, @@ -829,11 +829,11 @@ wheels = [ [[package]] name = "protego" -version = "0.3.1" +version = "0.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8a/12/cab9fa77ff4e9e444a5eb5480db4b4f872c03aa079145804aa054be377bc/Protego-0.3.1.tar.gz", hash = "sha256:e94430d0d25cbbf239bc849d86c5e544fbde531fcccfa059953c7da344a1712c", size = 3246145, upload-time = "2024-04-05T10:08:54.884Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/d9/5026b9e75db1172f02441a84eaf42efb199b4cea14dda7651a620d1acd40/protego-0.7.0.tar.gz", hash = "sha256:2c032d9736a1f4f0c4318f3558353ae34da5cd038f1a5e064ded7548df315e5a", size = 3144321, upload-time = "2026-09-21T10:54:00.107Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/74/ef/ece78585a5a189d8cc2b4c2d2b92a0dc025f156a6501159b026472ebbedc/Protego-0.3.1-py2.py3-none-any.whl", hash = "sha256:2fbe8e9b7a7dbc5016a932b14c98d236aad4c29290bbe457b8d2779666ef7a41", size = 8474, upload-time = "2024-04-05T10:08:53.5Z" }, + { url = "https://files.pythonhosted.org/packages/c0/20/e4382a85e4cf4746d11746481f83360b990165725dd36ba73d55e029e6a9/protego-0.7.0-py3-none-any.whl", hash = "sha256:944638aaee608f6c83fcad442695f3f9381a60be8d5007248f46b00f201fa5fc", size = 13714, upload-time = "2026-09-21T10:53:58.812Z" }, ] [[package]] From c8cee7151b8a434be47a68e8ab2d434054013b57 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 10 Oct 2026 01:03:30 -0400 Subject: [PATCH 041/117] chore(git): require gitpython>=3.1.60 (PYSEC-2026-3783 and fourteen more) Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/git/pyproject.toml | 2 +- src/git/uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/git/pyproject.toml b/src/git/pyproject.toml index cf6374776e..d9fda96b55 100644 --- a/src/git/pyproject.toml +++ b/src/git/pyproject.toml @@ -17,7 +17,7 @@ classifiers = [ ] dependencies = [ "click>=8.1.7", - "gitpython>=3.1.50", + "gitpython>=3.1.60", "jsonschema>=4.20.0", "mcp>=2.2,<3", "pydantic>=2.0.0", diff --git a/src/git/uv.lock b/src/git/uv.lock index 92d67b2a77..821074839e 100644 --- a/src/git/uv.lock +++ b/src/git/uv.lock @@ -366,14 +366,14 @@ wheels = [ [[package]] name = "gitpython" -version = "3.1.54" +version = "3.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gitdb" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/d5/3da0b92033887033f4c27f2dd109a303c4ca62813c7b3bb2511edb4777de/gitpython-3.1.54.tar.gz", hash = "sha256:53f2085e24a2cda300eed7c3fc5f1559ae289634b725e98acaf4791940247aa0", size = 225076, upload-time = "2026-07-22T04:08:51.403Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/2d/6f6e649818da44d4499604802c89329b8d9799687a124e3a5e467a643336/gitpython-3.2.0.tar.gz", hash = "sha256:fb92310af6844d96adc95ca066ed2e617c00e1dbd146a326626c81e72e18cc2e", size = 238091, upload-time = "2026-09-30T09:10:32.65Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/b9/876f442a28df5c068ca69b0122d5c35e65fd2d2fa9992ea5cb5944ea00a6/gitpython-3.1.54-py3-none-any.whl", hash = "sha256:b90d7b3d9bc0238681d24369130826f0dcdb0ceaa45db67cf1d4ffa4c302dedf", size = 216575, upload-time = "2026-07-22T04:08:50.05Z" }, + { url = "https://files.pythonhosted.org/packages/fa/48/f36d233ce749d1bb880cb5e6f8bb171dc2effabc7435c31da604d831b3ec/gitpython-3.2.0-py3-none-any.whl", hash = "sha256:bd70c5ec05cd2b797423e7eb312147d2458d3cca92085888fba2213f85905537", size = 228537, upload-time = "2026-09-30T09:10:31.159Z" }, ] [[package]] @@ -518,7 +518,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "click", specifier = ">=8.1.7" }, - { name = "gitpython", specifier = ">=3.1.50" }, + { name = "gitpython", specifier = ">=3.1.60" }, { name = "jsonschema", specifier = ">=4.20.0" }, { name = "mcp", specifier = ">=2.2,<3" }, { name = "pydantic", specifier = ">=2.0.0" }, From ce54c890dff348b437b04a48f102ea513b111b35 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 10 Oct 2026 01:03:30 -0400 Subject: [PATCH 042/117] chore(python): lock cryptography 50 (PYSEC-2026-3552) Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/fetch/uv.lock | 107 ++++++++++++++++++++++++++-------------------- src/git/uv.lock | 107 ++++++++++++++++++++++++++-------------------- src/time/uv.lock | 107 ++++++++++++++++++++++++++-------------------- 3 files changed, 180 insertions(+), 141 deletions(-) diff --git a/src/fetch/uv.lock b/src/fetch/uv.lock index 8cc539425a..64c1c7b94b 100644 --- a/src/fetch/uv.lock +++ b/src/fetch/uv.lock @@ -370,59 +370,72 @@ toml = [ [[package]] name = "cryptography" -version = "49.0.0" +version = "50.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, - { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, - { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, - { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, - { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, - { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, - { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, - { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, - { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, - { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, - { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, - { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, - { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, - { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, - { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, - { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, - { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, - { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, - { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, - { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, - { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, - { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, - { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, - { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, - { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, - { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, - { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, - { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, - { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, - { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, - { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, - { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, - { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, - { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, - { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, - { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, - { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, - { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, - { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, - { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, - { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, + { url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" }, + { url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" }, + { url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" }, + { url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" }, + { url = "https://files.pythonhosted.org/packages/ce/cb/52eb3770c0d0be2702a98c6e96065ddc0a2877cf0845aa9c23397c142cd4/cryptography-50.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8", size = 3892731, upload-time = "2026-09-30T14:44:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/19/8e/aa1fc533d4546b127b45de8aa024eb5933d23eff9debfe25931e56861095/cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047", size = 4710431, upload-time = "2026-09-30T14:44:15.427Z" }, + { url = "https://files.pythonhosted.org/packages/6a/64/72bc3f75176e7e406b748a3e3830432b8c51297b38368713df04dc04898a/cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539", size = 4694824, upload-time = "2026-09-30T14:44:17.69Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c6/62c77550edfa5ca3f14bf44a1e6739b9fa09d6e998a11d97ed8213bccc98/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1", size = 4716967, upload-time = "2026-09-30T14:44:19.661Z" }, + { url = "https://files.pythonhosted.org/packages/f4/37/cce70f150c432914460157a6ecc161752e053aa5ec0ef3b3f7dc6e31039a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7", size = 5328676, upload-time = "2026-09-30T14:44:21.744Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9a/6f2f0304d634ceafdeaf23e84537336664ac419b5d07611675c2ad3f6b7a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18", size = 4727698, upload-time = "2026-09-30T14:44:24.178Z" }, + { url = "https://files.pythonhosted.org/packages/1d/de/66bcf9244d118663b2e1aaded8990f4640e3d7b7411870a5765f252074d2/cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37", size = 4354821, upload-time = "2026-09-30T14:44:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e6/db28a28c7b6c676addce89136de3d8db49ea825a8c863472e36e42ead4ad/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2", size = 4716748, upload-time = "2026-09-30T14:44:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/30/96/01546c7f69ea0e2ab790a2e4f0934a4052fb9b388147fbf83c2fd72f1e57/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1", size = 5285085, upload-time = "2026-09-30T14:44:30.704Z" }, + { url = "https://files.pythonhosted.org/packages/6c/01/03263395f74d50b071e9e66daace3f8bef80493e5d410726f2ba8554736b/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05", size = 4727268, upload-time = "2026-09-30T14:44:32.92Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/2bfe8f29ec0cc9c0d99359c4161adf32858e4934b72c6d100d2ac0bbe962/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e", size = 4849503, upload-time = "2026-09-30T14:44:34.969Z" }, + { url = "https://files.pythonhosted.org/packages/54/44/e80651ecbf0e42b62e2bb5f5768916e07eea72e1297338956a61df361f88/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e", size = 5004057, upload-time = "2026-09-30T14:44:37.064Z" }, + { url = "https://files.pythonhosted.org/packages/f8/cc/1d33befb3cd7ea7e77d2d73f43f2066471da1b21f24a6156efcaabf6d2e8/cryptography-50.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45", size = 3795868, upload-time = "2026-09-30T14:44:39.71Z" }, + { url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" }, + { url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" }, + { url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" }, + { url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" }, + { url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" }, + { url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" }, + { url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" }, + { url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" }, + { url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" }, + { url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" }, + { url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" }, + { url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" }, + { url = "https://files.pythonhosted.org/packages/1d/7a/f08d34ce09d60f89ebd391e2ebc6ba2b995e6dd7552f41820f8085f94e53/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:92e665960f25fcdc73725b9cec7a3824f279ba97a98653afe9ffac2e43668f67", size = 4716035, upload-time = "2026-09-30T15:29:48.681Z" }, + { url = "https://files.pythonhosted.org/packages/45/67/e18fb65592451a2acb76e9f2fbe14e0f47a8318b4c5430f1633851d03daa/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:eef4c2f3423810b3070ab391f85436d2f8bbfcb286ac15cbc73190b3563b1f1a", size = 4726917, upload-time = "2026-09-30T15:29:50.608Z" }, + { url = "https://files.pythonhosted.org/packages/83/28/38fdce17e60f6b825e69fc3b7f75e70a6612759980704697e1de4cbfaf6e/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7c6d0330c472d96f6a6afe24d80dfdf15176c33096f0a4397ae4c60f3dd3be48", size = 4715341, upload-time = "2026-09-30T15:29:52.522Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b1/d9121a717e0f893c64bd6ca7702614778d7df2a5c309128a002421788516/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:1ba34f04897fcdaa73f74145c25f3ec146fbd56593853e88adc2e811303c5f42", size = 4726322, upload-time = "2026-09-30T15:29:54.263Z" }, + { url = "https://files.pythonhosted.org/packages/36/8b/e6d153808bf353e152abd2fd4d8f09670d956ac78379ac46e60d7efbf04c/cryptography-50.0.2-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:3dc4fd8058cea1644971207d530e1a03a184a805ffc8ebdddf0599d78a331b81", size = 3873097, upload-time = "2026-09-30T15:29:56.097Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1d/1271f287ff7170ddafc2aad36260c4eec20ccd2fea70f38455e9d56d427b/cryptography-50.0.2-pp311-pypy311_pp80-win_amd64.whl", hash = "sha256:7b75de3c8b3be1cdb1052747c929440c3eea46c1bc2cb8a6e3a48388e9b7b452", size = 3805376, upload-time = "2026-09-30T15:29:58.729Z" }, ] [[package]] diff --git a/src/git/uv.lock b/src/git/uv.lock index 821074839e..3921d8c8ed 100644 --- a/src/git/uv.lock +++ b/src/git/uv.lock @@ -288,59 +288,72 @@ toml = [ [[package]] name = "cryptography" -version = "49.0.0" +version = "50.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, - { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, - { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, - { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, - { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, - { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, - { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, - { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, - { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, - { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, - { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, - { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, - { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, - { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, - { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, - { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, - { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, - { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, - { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, - { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, - { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, - { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, - { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, - { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, - { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, - { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, - { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, - { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, - { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, - { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, - { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, - { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, - { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, - { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, - { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, - { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, - { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, - { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, - { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, - { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, - { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, + { url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" }, + { url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" }, + { url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" }, + { url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" }, + { url = "https://files.pythonhosted.org/packages/ce/cb/52eb3770c0d0be2702a98c6e96065ddc0a2877cf0845aa9c23397c142cd4/cryptography-50.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8", size = 3892731, upload-time = "2026-09-30T14:44:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/19/8e/aa1fc533d4546b127b45de8aa024eb5933d23eff9debfe25931e56861095/cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047", size = 4710431, upload-time = "2026-09-30T14:44:15.427Z" }, + { url = "https://files.pythonhosted.org/packages/6a/64/72bc3f75176e7e406b748a3e3830432b8c51297b38368713df04dc04898a/cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539", size = 4694824, upload-time = "2026-09-30T14:44:17.69Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c6/62c77550edfa5ca3f14bf44a1e6739b9fa09d6e998a11d97ed8213bccc98/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1", size = 4716967, upload-time = "2026-09-30T14:44:19.661Z" }, + { url = "https://files.pythonhosted.org/packages/f4/37/cce70f150c432914460157a6ecc161752e053aa5ec0ef3b3f7dc6e31039a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7", size = 5328676, upload-time = "2026-09-30T14:44:21.744Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9a/6f2f0304d634ceafdeaf23e84537336664ac419b5d07611675c2ad3f6b7a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18", size = 4727698, upload-time = "2026-09-30T14:44:24.178Z" }, + { url = "https://files.pythonhosted.org/packages/1d/de/66bcf9244d118663b2e1aaded8990f4640e3d7b7411870a5765f252074d2/cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37", size = 4354821, upload-time = "2026-09-30T14:44:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e6/db28a28c7b6c676addce89136de3d8db49ea825a8c863472e36e42ead4ad/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2", size = 4716748, upload-time = "2026-09-30T14:44:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/30/96/01546c7f69ea0e2ab790a2e4f0934a4052fb9b388147fbf83c2fd72f1e57/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1", size = 5285085, upload-time = "2026-09-30T14:44:30.704Z" }, + { url = "https://files.pythonhosted.org/packages/6c/01/03263395f74d50b071e9e66daace3f8bef80493e5d410726f2ba8554736b/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05", size = 4727268, upload-time = "2026-09-30T14:44:32.92Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/2bfe8f29ec0cc9c0d99359c4161adf32858e4934b72c6d100d2ac0bbe962/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e", size = 4849503, upload-time = "2026-09-30T14:44:34.969Z" }, + { url = "https://files.pythonhosted.org/packages/54/44/e80651ecbf0e42b62e2bb5f5768916e07eea72e1297338956a61df361f88/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e", size = 5004057, upload-time = "2026-09-30T14:44:37.064Z" }, + { url = "https://files.pythonhosted.org/packages/f8/cc/1d33befb3cd7ea7e77d2d73f43f2066471da1b21f24a6156efcaabf6d2e8/cryptography-50.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45", size = 3795868, upload-time = "2026-09-30T14:44:39.71Z" }, + { url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" }, + { url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" }, + { url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" }, + { url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" }, + { url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" }, + { url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" }, + { url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" }, + { url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" }, + { url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" }, + { url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" }, + { url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" }, + { url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" }, + { url = "https://files.pythonhosted.org/packages/1d/7a/f08d34ce09d60f89ebd391e2ebc6ba2b995e6dd7552f41820f8085f94e53/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:92e665960f25fcdc73725b9cec7a3824f279ba97a98653afe9ffac2e43668f67", size = 4716035, upload-time = "2026-09-30T15:29:48.681Z" }, + { url = "https://files.pythonhosted.org/packages/45/67/e18fb65592451a2acb76e9f2fbe14e0f47a8318b4c5430f1633851d03daa/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:eef4c2f3423810b3070ab391f85436d2f8bbfcb286ac15cbc73190b3563b1f1a", size = 4726917, upload-time = "2026-09-30T15:29:50.608Z" }, + { url = "https://files.pythonhosted.org/packages/83/28/38fdce17e60f6b825e69fc3b7f75e70a6612759980704697e1de4cbfaf6e/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7c6d0330c472d96f6a6afe24d80dfdf15176c33096f0a4397ae4c60f3dd3be48", size = 4715341, upload-time = "2026-09-30T15:29:52.522Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b1/d9121a717e0f893c64bd6ca7702614778d7df2a5c309128a002421788516/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:1ba34f04897fcdaa73f74145c25f3ec146fbd56593853e88adc2e811303c5f42", size = 4726322, upload-time = "2026-09-30T15:29:54.263Z" }, + { url = "https://files.pythonhosted.org/packages/36/8b/e6d153808bf353e152abd2fd4d8f09670d956ac78379ac46e60d7efbf04c/cryptography-50.0.2-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:3dc4fd8058cea1644971207d530e1a03a184a805ffc8ebdddf0599d78a331b81", size = 3873097, upload-time = "2026-09-30T15:29:56.097Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1d/1271f287ff7170ddafc2aad36260c4eec20ccd2fea70f38455e9d56d427b/cryptography-50.0.2-pp311-pypy311_pp80-win_amd64.whl", hash = "sha256:7b75de3c8b3be1cdb1052747c929440c3eea46c1bc2cb8a6e3a48388e9b7b452", size = 3805376, upload-time = "2026-09-30T15:29:58.729Z" }, ] [[package]] diff --git a/src/time/uv.lock b/src/time/uv.lock index d5201d4788..93cde3dd7e 100644 --- a/src/time/uv.lock +++ b/src/time/uv.lock @@ -288,59 +288,72 @@ toml = [ [[package]] name = "cryptography" -version = "49.0.0" +version = "50.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" }, - { url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" }, - { url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" }, - { url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" }, - { url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" }, - { url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" }, - { url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" }, - { url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" }, - { url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" }, - { url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" }, - { url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" }, - { url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" }, - { url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" }, - { url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" }, - { url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" }, - { url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" }, - { url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" }, - { url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" }, - { url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" }, - { url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" }, - { url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" }, - { url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" }, - { url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" }, - { url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" }, - { url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" }, - { url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" }, - { url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" }, - { url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" }, - { url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" }, - { url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" }, - { url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" }, - { url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" }, - { url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" }, - { url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" }, - { url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" }, - { url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" }, - { url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" }, - { url = "https://files.pythonhosted.org/packages/63/d3/4a83af35d65e3fad632c926fad684c193ea4398569ccb0bbbc7fe8f5dc9a/cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b", size = 3993685, upload-time = "2026-06-12T20:02:14.883Z" }, - { url = "https://files.pythonhosted.org/packages/d6/a7/f9dac0ab7f80368c56993a7bf638ef9935f825c91902798481fac0898138/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838", size = 4676239, upload-time = "2026-06-12T20:02:28.793Z" }, - { url = "https://files.pythonhosted.org/packages/d7/70/2ba3769dd0ae167e2f33dfa9592d45db6ff9a61d62ca1a5b3d1bdd09068f/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5", size = 4715584, upload-time = "2026-06-12T20:01:27.495Z" }, - { url = "https://files.pythonhosted.org/packages/94/64/2923570ac1c0bd3a737aa366ac3abbbbde273042308b8cde95e2364a6e6a/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615", size = 4675885, upload-time = "2026-06-12T20:01:55.49Z" }, - { url = "https://files.pythonhosted.org/packages/ab/f8/614dc7e051418cfe53d55173c1e24c6b0085e89996fe90508c2fdf769aef/cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6", size = 4715449, upload-time = "2026-06-12T20:02:05.469Z" }, - { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, + { url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" }, + { url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" }, + { url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" }, + { url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" }, + { url = "https://files.pythonhosted.org/packages/ce/cb/52eb3770c0d0be2702a98c6e96065ddc0a2877cf0845aa9c23397c142cd4/cryptography-50.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8", size = 3892731, upload-time = "2026-09-30T14:44:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/19/8e/aa1fc533d4546b127b45de8aa024eb5933d23eff9debfe25931e56861095/cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047", size = 4710431, upload-time = "2026-09-30T14:44:15.427Z" }, + { url = "https://files.pythonhosted.org/packages/6a/64/72bc3f75176e7e406b748a3e3830432b8c51297b38368713df04dc04898a/cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539", size = 4694824, upload-time = "2026-09-30T14:44:17.69Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c6/62c77550edfa5ca3f14bf44a1e6739b9fa09d6e998a11d97ed8213bccc98/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1", size = 4716967, upload-time = "2026-09-30T14:44:19.661Z" }, + { url = "https://files.pythonhosted.org/packages/f4/37/cce70f150c432914460157a6ecc161752e053aa5ec0ef3b3f7dc6e31039a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7", size = 5328676, upload-time = "2026-09-30T14:44:21.744Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9a/6f2f0304d634ceafdeaf23e84537336664ac419b5d07611675c2ad3f6b7a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18", size = 4727698, upload-time = "2026-09-30T14:44:24.178Z" }, + { url = "https://files.pythonhosted.org/packages/1d/de/66bcf9244d118663b2e1aaded8990f4640e3d7b7411870a5765f252074d2/cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37", size = 4354821, upload-time = "2026-09-30T14:44:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e6/db28a28c7b6c676addce89136de3d8db49ea825a8c863472e36e42ead4ad/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2", size = 4716748, upload-time = "2026-09-30T14:44:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/30/96/01546c7f69ea0e2ab790a2e4f0934a4052fb9b388147fbf83c2fd72f1e57/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1", size = 5285085, upload-time = "2026-09-30T14:44:30.704Z" }, + { url = "https://files.pythonhosted.org/packages/6c/01/03263395f74d50b071e9e66daace3f8bef80493e5d410726f2ba8554736b/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05", size = 4727268, upload-time = "2026-09-30T14:44:32.92Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/2bfe8f29ec0cc9c0d99359c4161adf32858e4934b72c6d100d2ac0bbe962/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e", size = 4849503, upload-time = "2026-09-30T14:44:34.969Z" }, + { url = "https://files.pythonhosted.org/packages/54/44/e80651ecbf0e42b62e2bb5f5768916e07eea72e1297338956a61df361f88/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e", size = 5004057, upload-time = "2026-09-30T14:44:37.064Z" }, + { url = "https://files.pythonhosted.org/packages/f8/cc/1d33befb3cd7ea7e77d2d73f43f2066471da1b21f24a6156efcaabf6d2e8/cryptography-50.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45", size = 3795868, upload-time = "2026-09-30T14:44:39.71Z" }, + { url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" }, + { url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" }, + { url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" }, + { url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" }, + { url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" }, + { url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" }, + { url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" }, + { url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" }, + { url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" }, + { url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" }, + { url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" }, + { url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" }, + { url = "https://files.pythonhosted.org/packages/1d/7a/f08d34ce09d60f89ebd391e2ebc6ba2b995e6dd7552f41820f8085f94e53/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:92e665960f25fcdc73725b9cec7a3824f279ba97a98653afe9ffac2e43668f67", size = 4716035, upload-time = "2026-09-30T15:29:48.681Z" }, + { url = "https://files.pythonhosted.org/packages/45/67/e18fb65592451a2acb76e9f2fbe14e0f47a8318b4c5430f1633851d03daa/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:eef4c2f3423810b3070ab391f85436d2f8bbfcb286ac15cbc73190b3563b1f1a", size = 4726917, upload-time = "2026-09-30T15:29:50.608Z" }, + { url = "https://files.pythonhosted.org/packages/83/28/38fdce17e60f6b825e69fc3b7f75e70a6612759980704697e1de4cbfaf6e/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7c6d0330c472d96f6a6afe24d80dfdf15176c33096f0a4397ae4c60f3dd3be48", size = 4715341, upload-time = "2026-09-30T15:29:52.522Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b1/d9121a717e0f893c64bd6ca7702614778d7df2a5c309128a002421788516/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:1ba34f04897fcdaa73f74145c25f3ec146fbd56593853e88adc2e811303c5f42", size = 4726322, upload-time = "2026-09-30T15:29:54.263Z" }, + { url = "https://files.pythonhosted.org/packages/36/8b/e6d153808bf353e152abd2fd4d8f09670d956ac78379ac46e60d7efbf04c/cryptography-50.0.2-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:3dc4fd8058cea1644971207d530e1a03a184a805ffc8ebdddf0599d78a331b81", size = 3873097, upload-time = "2026-09-30T15:29:56.097Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1d/1271f287ff7170ddafc2aad36260c4eec20ccd2fea70f38455e9d56d427b/cryptography-50.0.2-pp311-pypy311_pp80-win_amd64.whl", hash = "sha256:7b75de3c8b3be1cdb1052747c929440c3eea46c1bc2cb8a6e3a48388e9b7b452", size = 3805376, upload-time = "2026-09-30T15:29:58.729Z" }, ] [[package]] From ac259b619eb46fe39494bd6db4d31cbf9c5fd534 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 10 Oct 2026 01:03:31 -0400 Subject: [PATCH 043/117] chore(python): lock pyjwt 2.15 (PYSEC-2026-4140 and twelve more) Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/fetch/uv.lock | 6 +++--- src/git/uv.lock | 6 +++--- src/time/uv.lock | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/fetch/uv.lock b/src/fetch/uv.lock index 64c1c7b94b..82067cab44 100644 --- a/src/fetch/uv.lock +++ b/src/fetch/uv.lock @@ -1002,14 +1002,14 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.13.0" +version = "2.15.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252, upload-time = "2026-09-28T18:40:42.598Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, + { url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860, upload-time = "2026-09-28T18:40:41.429Z" }, ] [package.optional-dependencies] diff --git a/src/git/uv.lock b/src/git/uv.lock index 3921d8c8ed..39bec35e34 100644 --- a/src/git/uv.lock +++ b/src/git/uv.lock @@ -751,14 +751,14 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.13.0" +version = "2.15.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252, upload-time = "2026-09-28T18:40:42.598Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, + { url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860, upload-time = "2026-09-28T18:40:41.429Z" }, ] [package.optional-dependencies] diff --git a/src/time/uv.lock b/src/time/uv.lock index 93cde3dd7e..fdb92e13d1 100644 --- a/src/time/uv.lock +++ b/src/time/uv.lock @@ -741,14 +741,14 @@ wheels = [ [[package]] name = "pyjwt" -version = "2.13.0" +version = "2.15.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252, upload-time = "2026-09-28T18:40:42.598Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, + { url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860, upload-time = "2026-09-28T18:40:41.429Z" }, ] [package.optional-dependencies] From ef6bffb7ac81482713aa2a092614322d04673bd9 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 10 Oct 2026 01:03:31 -0400 Subject: [PATCH 044/117] chore(fetch): lock urllib3 2.8 (PYSEC-2026-4175, -4176, -4177) Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/fetch/uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/fetch/uv.lock b/src/fetch/uv.lock index 82067cab44..7aef782b58 100644 --- a/src/fetch/uv.lock +++ b/src/fetch/uv.lock @@ -1515,11 +1515,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.7.0" +version = "2.8.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, ] [[package]] From 478a08ef9be29e06e0bc966a41b3ab36c7b931ac Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 10 Oct 2026 01:03:31 -0400 Subject: [PATCH 045/117] chore(fetch): lock soupsieve 2.10 (PYSEC-2026-4170, -4171) Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/fetch/uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/fetch/uv.lock b/src/fetch/uv.lock index 7aef782b58..2b9d970293 100644 --- a/src/fetch/uv.lock +++ b/src/fetch/uv.lock @@ -1396,11 +1396,11 @@ wheels = [ [[package]] name = "soupsieve" -version = "2.8.4" +version = "2.10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } +sdist = { url = "https://files.pythonhosted.org/packages/71/c3/1b817965ac12dc002d7c9cd7dfffdd7d4fbf9b45763ef2ffe7b86ee94670/soupsieve-2.10.tar.gz", hash = "sha256:49e9380d7d2905463583bafe285e818c7366a9ed7b3aee221c1ac79c905d8bc0", size = 124337, upload-time = "2026-09-24T02:36:35.048Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, + { url = "https://files.pythonhosted.org/packages/66/87/5ed59e1d0290564e2027ed066c52b059892c4637741337fc0967183c8d4d/soupsieve-2.10-py3-none-any.whl", hash = "sha256:8596eb8967d744174820280fa62b4542a2e955bfaccca73ed8a13c6eb8e9b502", size = 38019, upload-time = "2026-09-24T02:36:33.709Z" }, ] [[package]] From 0c505a6114c58e0243761b842b2ce2893b6d21af Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 10 Oct 2026 01:05:02 -0400 Subject: [PATCH 046/117] chore: fold the pending changesets into a 1.0.0 CHANGELOG entry per TypeScript server Generated by the repo's version-packages script (changeset version with @changesets/changelog-github), then each new 2.0.0 heading retitled 1.0.0 and the package.json and lockfile changes discarded: all four servers already read 1.0.0, which has never been published. Every pending changeset is consumed, so the next Version Packages PR bumps only for modern-era work. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .changeset/everything-blob-mime-type.md | 5 ---- .../everything-conditional-tools-4792.md | 5 ---- .changeset/everything-no-tests-in-dist.md | 5 ---- .changeset/everything-sdk-v2.md | 5 ---- ...everything-session-resources-per-server.md | 5 ---- .changeset/everything-sse-session-errors.md | 5 ---- .../everything-streamable-http-sessions.md | 5 ---- .changeset/everything-testable-transports.md | 5 ---- .changeset/filesystem-create-server.md | 5 ---- .../filesystem-edit-line-endings-indent.md | 5 ---- .changeset/filesystem-in-place-write.md | 5 ---- .changeset/filesystem-initial-roots.md | 5 ---- .changeset/filesystem-schema-dialect.md | 5 ---- .changeset/filesystem-sdk-v2.md | 5 ---- .changeset/filesystem-unc-share-root.md | 5 ---- .changeset/filesystem-unicode-paths.md | 5 ---- .changeset/memory-create-entities-skipped.md | 5 ---- .changeset/memory-create-server-factory.md | 5 ---- .changeset/memory-cross-process-lock.md | 5 ---- .changeset/memory-file-path-base-dir.md | 5 ---- .changeset/memory-keep-unreadable-lines.md | 5 ---- .changeset/memory-no-tests-in-dist.md | 5 ---- .changeset/memory-preserve-file-mode.md | 5 ---- .changeset/memory-sdk-v2.md | 5 ---- .changeset/sequentialthinking-annotations.md | 5 ---- .../sequentialthinking-create-server.md | 5 ---- .changeset/sequentialthinking-log-header.md | 5 ---- .../sequentialthinking-no-tests-in-dist.md | 5 ---- .../sequentialthinking-prototype-branchid.md | 5 ---- .changeset/sequentialthinking-sdk-v2.md | 5 ---- .changeset/sse-port-in-use.md | 5 ---- src/everything/CHANGELOG.md | 27 +++++++++++++++++++ src/filesystem/CHANGELOG.md | 25 +++++++++++++++++ src/memory/CHANGELOG.md | 25 +++++++++++++++++ src/sequentialthinking/CHANGELOG.md | 21 +++++++++++++++ 35 files changed, 98 insertions(+), 155 deletions(-) delete mode 100644 .changeset/everything-blob-mime-type.md delete mode 100644 .changeset/everything-conditional-tools-4792.md delete mode 100644 .changeset/everything-no-tests-in-dist.md delete mode 100644 .changeset/everything-sdk-v2.md delete mode 100644 .changeset/everything-session-resources-per-server.md delete mode 100644 .changeset/everything-sse-session-errors.md delete mode 100644 .changeset/everything-streamable-http-sessions.md delete mode 100644 .changeset/everything-testable-transports.md delete mode 100644 .changeset/filesystem-create-server.md delete mode 100644 .changeset/filesystem-edit-line-endings-indent.md delete mode 100644 .changeset/filesystem-in-place-write.md delete mode 100644 .changeset/filesystem-initial-roots.md delete mode 100644 .changeset/filesystem-schema-dialect.md delete mode 100644 .changeset/filesystem-sdk-v2.md delete mode 100644 .changeset/filesystem-unc-share-root.md delete mode 100644 .changeset/filesystem-unicode-paths.md delete mode 100644 .changeset/memory-create-entities-skipped.md delete mode 100644 .changeset/memory-create-server-factory.md delete mode 100644 .changeset/memory-cross-process-lock.md delete mode 100644 .changeset/memory-file-path-base-dir.md delete mode 100644 .changeset/memory-keep-unreadable-lines.md delete mode 100644 .changeset/memory-no-tests-in-dist.md delete mode 100644 .changeset/memory-preserve-file-mode.md delete mode 100644 .changeset/memory-sdk-v2.md delete mode 100644 .changeset/sequentialthinking-annotations.md delete mode 100644 .changeset/sequentialthinking-create-server.md delete mode 100644 .changeset/sequentialthinking-log-header.md delete mode 100644 .changeset/sequentialthinking-no-tests-in-dist.md delete mode 100644 .changeset/sequentialthinking-prototype-branchid.md delete mode 100644 .changeset/sequentialthinking-sdk-v2.md delete mode 100644 .changeset/sse-port-in-use.md create mode 100644 src/everything/CHANGELOG.md create mode 100644 src/filesystem/CHANGELOG.md create mode 100644 src/memory/CHANGELOG.md create mode 100644 src/sequentialthinking/CHANGELOG.md diff --git a/.changeset/everything-blob-mime-type.md b/.changeset/everything-blob-mime-type.md deleted file mode 100644 index 8129a3a1e7..0000000000 --- a/.changeset/everything-blob-mime-type.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-everything": patch ---- - -Label dynamic blob resources `application/octet-stream`, matching their resource template, instead of `text/plain`. This applies to `resources/read`, the `get-resource-reference` and `get-resource-links` tools, and the `resource-prompt` prompt; blob resource links are now described as "binary blob resource". diff --git a/.changeset/everything-conditional-tools-4792.md b/.changeset/everything-conditional-tools-4792.md deleted file mode 100644 index 0b07bc7238..0000000000 --- a/.changeset/everything-conditional-tools-4792.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-everything": patch ---- - -Server instructions no longer tell every client to use tools that are registered only for clients declaring a capability; they list those tools and the capability each needs (#4792, ported from #4835). `trigger-elicitation-request` is now registered only for clients that support form-mode elicitation, so a client that declared only URL-mode elicitation no longer sees a tool it cannot answer (#4985). diff --git a/.changeset/everything-no-tests-in-dist.md b/.changeset/everything-no-tests-in-dist.md deleted file mode 100644 index 3be8be9adf..0000000000 --- a/.changeset/everything-no-tests-in-dist.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-everything": patch ---- - -Stop shipping the compiled test suite and Vitest config in the published package: the build now excludes `__tests__/`, `*.test.ts`, `*.spec.ts` and `vitest.config.ts`, as the other TypeScript servers' builds do. diff --git a/.changeset/everything-sdk-v2.md b/.changeset/everything-sdk-v2.md deleted file mode 100644 index 07fe4f38ae..0000000000 --- a/.changeset/everything-sdk-v2.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-everything": major ---- - -Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`, `@modelcontextprotocol/node`, and `@modelcontextprotocol/server-legacy` for the deprecated HTTP+SSE transport) from `@modelcontextprotocol/sdk` 1.x (#4856). The task tools `simulate-research-query`, `trigger-sampling-request-async` and `trigger-elicitation-request-async` are removed, and the server no longer advertises the `tasks` capability, because SDK v2 dropped the experimental tasks layer they were built on; they return on the tasks extension in #4852. Breaking for clients: the server now requires Node.js 20 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); tool schemas in `tools/list` declare JSON Schema 2020-12 instead of draft-07; and tools no longer advertise `execution.taskSupport`. The optional `state` argument of `args-prompt` now carries its description. diff --git a/.changeset/everything-session-resources-per-server.md b/.changeset/everything-session-resources-per-server.md deleted file mode 100644 index caea9ac495..0000000000 --- a/.changeset/everything-session-resources-per-server.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-everything": patch ---- - -Session resources are now tracked per server, so two sessions that create a session resource with the same name (for example via `gzip-file-as-resource`) no longer evict each other's resource (#4808). diff --git a/.changeset/everything-sse-session-errors.md b/.changeset/everything-sse-session-errors.md deleted file mode 100644 index 974a314da9..0000000000 --- a/.changeset/everything-sse-session-errors.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-everything": patch ---- - -The HTTP+SSE transport now answers requests for sessions it cannot serve instead of leaving them hanging or failing with a 500: `POST /message` for an unknown session returns `404` (and `400` with no `sessionId`), and `GET /sse?sessionId=…` returns `409` for a session that already has its stream and `404` for an unknown one. Each carries a JSON-RPC error body. diff --git a/.changeset/everything-streamable-http-sessions.md b/.changeset/everything-streamable-http-sessions.md deleted file mode 100644 index 721e1f7f0e..0000000000 --- a/.changeset/everything-streamable-http-sessions.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-everything": patch ---- - -Streamable HTTP: answer an unknown or ended session ID with `404 Not Found` (carrying the request's `id` on a POST) instead of `400`; replay only the resumed stream's events after a `Last-Event-ID`, and refuse an unknown one; close every open session on shutdown. diff --git a/.changeset/everything-testable-transports.md b/.changeset/everything-testable-transports.md deleted file mode 100644 index 5f8b911475..0000000000 --- a/.changeset/everything-testable-transports.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-everything": patch ---- - -Internal refactor for in-process testing, with no change in behavior: the launcher only starts a transport when run as the binary, and the stdio, SSE and Streamable HTTP transport modules export their start-up (`startStdioServer`, `startSseServer`, `startStreamableHttpServer`, and `createApp()` for the HTTP transports) instead of starting a server when imported. diff --git a/.changeset/filesystem-create-server.md b/.changeset/filesystem-create-server.md deleted file mode 100644 index 7c26e314d5..0000000000 --- a/.changeset/filesystem-create-server.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-filesystem": patch ---- - -Internal: the server is now built by a `createServer()` factory in `server.ts`, and `index.ts` starts it over stdio only when run as the bin, with each server instance holding its own allowed directories. No change to the tools, their results, or how the allowed directories are resolved from arguments and Roots. diff --git a/.changeset/filesystem-edit-line-endings-indent.md b/.changeset/filesystem-edit-line-endings-indent.md deleted file mode 100644 index 3e362fd793..0000000000 --- a/.changeset/filesystem-edit-line-endings-indent.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-filesystem": patch ---- - -`edit_file` keeps a file's line endings: a CRLF file is written back with CRLF instead of being converted to LF. When `oldText` has no exact match and the whitespace-tolerant matcher is used, each replacement line now takes the indentation of the file line it replaces, shifted by its indentation relative to the matching `oldText` line, instead of only the first line being reindented. diff --git a/.changeset/filesystem-in-place-write.md b/.changeset/filesystem-in-place-write.md deleted file mode 100644 index 45ec2c4648..0000000000 --- a/.changeset/filesystem-in-place-write.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-filesystem": patch ---- - -`write_file` and `edit_file` now overwrite an existing file in place instead of renaming a temp file over it, so the file keeps its inode, creation time (birthtime), hard links and permission bits (#4512), and an overwrite no longer fails with `EPERM` on Windows when another process holds the file open (#3199). A symlink swapped in after the path was validated is still refused rather than written through, now by an `O_NOFOLLOW` open (on POSIX) and a check that the opened file is the one validated (on every platform). The overwrite is no longer crash-atomic. A read-only file is now refused (`EACCES`, or `EPERM` on Windows) instead of being replaced, since writing in place opens the file itself for writing. diff --git a/.changeset/filesystem-initial-roots.md b/.changeset/filesystem-initial-roots.md deleted file mode 100644 index b887385c51..0000000000 --- a/.changeset/filesystem-initial-roots.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-filesystem": patch ---- - -Tool calls now wait for the client's initial roots before checking paths, so a call sent right after connecting is no longer refused with "Access denied" (#3204). A server started with no directories, connected to a client without Roots support, now fails visibly: it logs the reason, closes the connection and exits with status 1, instead of staying up and refusing every call (#4992). diff --git a/.changeset/filesystem-schema-dialect.md b/.changeset/filesystem-schema-dialect.md deleted file mode 100644 index 0579ae918b..0000000000 --- a/.changeset/filesystem-schema-dialect.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-filesystem": patch ---- - -Every tool's input and output schema in `tools/list` now declares `"$schema": "https://json-schema.org/draft/2020-12/schema"` instead of draft-07, so clients that validate tool schemas strictly against JSON Schema 2020-12 no longer reject every tool. The schemas are otherwise unchanged. diff --git a/.changeset/filesystem-sdk-v2.md b/.changeset/filesystem-sdk-v2.md deleted file mode 100644 index 523a805d57..0000000000 --- a/.changeset/filesystem-sdk-v2.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-filesystem": major ---- - -Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`) from `@modelcontextprotocol/sdk` 1.x (#4856). Breaking for clients: the server now requires Node.js 20 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); and tools no longer advertise `execution.taskSupport`. diff --git a/.changeset/filesystem-unc-share-root.md b/.changeset/filesystem-unc-share-root.md deleted file mode 100644 index df29e88f31..0000000000 --- a/.changeset/filesystem-unc-share-root.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-filesystem": patch ---- - -On Windows, a UNC share root given as an allowed directory (`\\server\share` or `\\server\share\`) now admits the files and subdirectories under it. Before, only the share root itself was accessible and every path below it was refused. Sibling shares such as `\\server\share-evil` are still refused. diff --git a/.changeset/filesystem-unicode-paths.md b/.changeset/filesystem-unicode-paths.md deleted file mode 100644 index 1f24c07e6d..0000000000 --- a/.changeset/filesystem-unicode-paths.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-filesystem": patch ---- - -Paths that differ from the names on disk only in Unicode form now resolve (#1970). An NFC spelling of an allowed directory whose name is stored NFD (macOS "Capture d’écran", Japanese dakuten) is no longer refused as outside the allowed directories, and a request that spells a U+202F or U+00A0 in a name as a plain space (the macOS screenshot "Screenshot … at 2.40.40 PM.png") finds the file instead of failing with ENOENT. The exact spelling still wins when it exists, and a name matching two entries is refused as ambiguous. diff --git a/.changeset/memory-create-entities-skipped.md b/.changeset/memory-create-entities-skipped.md deleted file mode 100644 index 5ed174763d..0000000000 --- a/.changeset/memory-create-entities-skipped.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-memory": patch ---- - -`create_entities` now reports the entities it skipped because their name already exists (or repeats earlier in the same call): the structured result lists them in a new optional `skipped` array, a second text item names them and points to `add_observations`, and the tool description says so. Skipped entities are still not created and their observations are still not added (#4887). diff --git a/.changeset/memory-create-server-factory.md b/.changeset/memory-create-server-factory.md deleted file mode 100644 index 710e9243ca..0000000000 --- a/.changeset/memory-create-server-factory.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-memory": patch ---- - -Build the server with an exported `createServer()` factory and start stdio only when the package is run as a program, so importing the module no longer starts a server. Behavior over stdio is unchanged. diff --git a/.changeset/memory-cross-process-lock.md b/.changeset/memory-cross-process-lock.md deleted file mode 100644 index 32b61ec2d8..0000000000 --- a/.changeset/memory-cross-process-lock.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-memory": patch ---- - -Two server processes sharing one memory file no longer silently discard each other's writes: every write tool now holds an exclusive lock file (`.lock`) while it reads, changes and saves the graph, and a lock left by a crashed server is recovered automatically (#4797). diff --git a/.changeset/memory-file-path-base-dir.md b/.changeset/memory-file-path-base-dir.md deleted file mode 100644 index ec2f84b554..0000000000 --- a/.changeset/memory-file-path-base-dir.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-memory": patch ---- - -Internal: `ensureMemoryFilePath` accepts the directory its default files live in, so the server's tests no longer write into the package directory. No change to how the server chooses or migrates its memory file. diff --git a/.changeset/memory-keep-unreadable-lines.md b/.changeset/memory-keep-unreadable-lines.md deleted file mode 100644 index 71a30793ff..0000000000 --- a/.changeset/memory-keep-unreadable-lines.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-memory": patch ---- - -Lines in the memory file that the server cannot read (malformed JSON, an entity or relation that fails validation, an unknown record type) are no longer deleted by the next write. They are still left out of the graph, and are now written back unchanged after the graph's own lines. diff --git a/.changeset/memory-no-tests-in-dist.md b/.changeset/memory-no-tests-in-dist.md deleted file mode 100644 index 8d7588cd11..0000000000 --- a/.changeset/memory-no-tests-in-dist.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-memory": patch ---- - -Stop shipping the compiled test helper `dist/__tests__/helpers.js` in the published package: the build now excludes everything under `__tests__/`, not just `*.test.ts`. diff --git a/.changeset/memory-preserve-file-mode.md b/.changeset/memory-preserve-file-mode.md deleted file mode 100644 index a0180a0f66..0000000000 --- a/.changeset/memory-preserve-file-mode.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-memory": patch ---- - -Saving the knowledge graph keeps the memory file's permission bits (an operator's `0600` no longer comes back `0644`), and a write to a read-only memory file now fails with `EACCES` instead of silently replacing it (#4827). diff --git a/.changeset/memory-sdk-v2.md b/.changeset/memory-sdk-v2.md deleted file mode 100644 index e1ab5b57df..0000000000 --- a/.changeset/memory-sdk-v2.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-memory": major ---- - -Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`) from `@modelcontextprotocol/sdk` 1.x (#4856). Breaking for clients: the server now requires Node.js 20 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); tool schemas in `tools/list` declare JSON Schema 2020-12 instead of draft-07; and tools no longer advertise `execution.taskSupport`. diff --git a/.changeset/sequentialthinking-annotations.md b/.changeset/sequentialthinking-annotations.md deleted file mode 100644 index 6c4974d857..0000000000 --- a/.changeset/sequentialthinking-annotations.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-sequential-thinking": patch ---- - -Advertise the `sequentialthinking` tool as `readOnlyHint: false` and `idempotentHint: false`. Every call appends to the server's thought history, and a call with both `branchFromThought` and `branchId` also appends to that branch, so the tool was never read-only or idempotent. `destructiveHint` and `openWorldHint` stay `false`. (#4721) diff --git a/.changeset/sequentialthinking-create-server.md b/.changeset/sequentialthinking-create-server.md deleted file mode 100644 index 91f8072d8a..0000000000 --- a/.changeset/sequentialthinking-create-server.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-sequential-thinking": patch ---- - -Build the server with an exported `createServer()` factory and attach stdio only when `index.js` runs as the binary, so the server can be tested in-process. No change to the tool, its schemas or its results. diff --git a/.changeset/sequentialthinking-log-header.md b/.changeset/sequentialthinking-log-header.md deleted file mode 100644 index aca9fdcfdf..0000000000 --- a/.changeset/sequentialthinking-log-header.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-sequential-thinking": patch ---- - -The thought log on stderr no longer prints "undefined" in its headers: a revision with no `revisesThought` reads "Revision N/M", and a branch with no `branchId` reads "(from thought N)" with no ID. The box border is sized from the header's visible text, so colour escape codes no longer make it wider than its text. diff --git a/.changeset/sequentialthinking-no-tests-in-dist.md b/.changeset/sequentialthinking-no-tests-in-dist.md deleted file mode 100644 index 7b760969f9..0000000000 --- a/.changeset/sequentialthinking-no-tests-in-dist.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-sequential-thinking": patch ---- - -Stop shipping the compiled test helper `dist/__tests__/helpers.js` in the published package: the build now excludes everything under `__tests__/`, not just `*.test.ts`. diff --git a/.changeset/sequentialthinking-prototype-branchid.md b/.changeset/sequentialthinking-prototype-branchid.md deleted file mode 100644 index dbc380da45..0000000000 --- a/.changeset/sequentialthinking-prototype-branchid.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-sequential-thinking": patch ---- - -A `branchId` that names an `Object.prototype` key, such as `"constructor"` or `"__proto__"`, now creates and lists its branch like any other id instead of failing the call. A call that fails no longer adds its thought to the history first, so `thoughtHistoryLength` counts only thoughts that were accepted. diff --git a/.changeset/sequentialthinking-sdk-v2.md b/.changeset/sequentialthinking-sdk-v2.md deleted file mode 100644 index 9e76a69e93..0000000000 --- a/.changeset/sequentialthinking-sdk-v2.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-sequential-thinking": major ---- - -Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`) from `@modelcontextprotocol/sdk` 1.x (#4856). Breaking for clients: the server now requires Node.js 20 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); tool schemas in `tools/list` declare JSON Schema 2020-12 instead of draft-07; and tools no longer advertise `execution.taskSupport`. diff --git a/.changeset/sse-port-in-use.md b/.changeset/sse-port-in-use.md deleted file mode 100644 index 59c5dbd55e..0000000000 --- a/.changeset/sse-port-in-use.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-everything": patch ---- - -The HTTP+SSE transport no longer prints "Server is running" and stays up when its port is already in use: it reports the port and exits non-zero, as Streamable HTTP does. Streamable HTTP no longer prints its listening line before that error. diff --git a/src/everything/CHANGELOG.md b/src/everything/CHANGELOG.md new file mode 100644 index 0000000000..5cf0063995 --- /dev/null +++ b/src/everything/CHANGELOG.md @@ -0,0 +1,27 @@ +# @modelcontextprotocol/server-everything + +## 1.0.0 + +The first semantic version, resuming semver after the date-stamped releases (last `2026.8.31`). It describes everything since `2026.8.31`, and is the last release of the legacy-era server. + +### Major Changes + +- [#5063](https://github.com/modelcontextprotocol/servers/pull/5063) [`b5af226`](https://github.com/modelcontextprotocol/servers/commit/b5af22668592700c02236d737f9836c42654edb6) Thanks [@cliffhall](https://github.com/cliffhall)! - Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`, `@modelcontextprotocol/node`, and `@modelcontextprotocol/server-legacy` for the deprecated HTTP+SSE transport) from `@modelcontextprotocol/sdk` 1.x ([#4856](https://github.com/modelcontextprotocol/servers/issues/4856)). The task tools `simulate-research-query`, `trigger-sampling-request-async` and `trigger-elicitation-request-async` are removed, and the server no longer advertises the `tasks` capability, because SDK v2 dropped the experimental tasks layer they were built on; they return on the tasks extension in [#4852](https://github.com/modelcontextprotocol/servers/issues/4852). Breaking for clients: the server now requires Node.js 20 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); tool schemas in `tools/list` declare JSON Schema 2020-12 instead of draft-07; and tools no longer advertise `execution.taskSupport`. The optional `state` argument of `args-prompt` now carries its description. + +### Patch Changes + +- [#5005](https://github.com/modelcontextprotocol/servers/pull/5005) [`63dc92f`](https://github.com/modelcontextprotocol/servers/commit/63dc92f0e95992faf9d3e339cb8a51b1090d138b) Thanks [@cliffhall](https://github.com/cliffhall)! - Label dynamic blob resources `application/octet-stream`, matching their resource template, instead of `text/plain`. This applies to `resources/read`, the `get-resource-reference` and `get-resource-links` tools, and the `resource-prompt` prompt; blob resource links are now described as "binary blob resource". + +- [#5034](https://github.com/modelcontextprotocol/servers/pull/5034) [`6bed4c5`](https://github.com/modelcontextprotocol/servers/commit/6bed4c5f7c219e77fe90b9e4b14cdb9f3f7e9503) Thanks [@cliffhall](https://github.com/cliffhall)! - Server instructions no longer tell every client to use tools that are registered only for clients declaring a capability; they list those tools and the capability each needs ([#4792](https://github.com/modelcontextprotocol/servers/issues/4792), ported from [#4835](https://github.com/modelcontextprotocol/servers/issues/4835)). `trigger-elicitation-request` is now registered only for clients that support form-mode elicitation, so a client that declared only URL-mode elicitation no longer sees a tool it cannot answer ([#4985](https://github.com/modelcontextprotocol/servers/issues/4985)). + +- [#5053](https://github.com/modelcontextprotocol/servers/pull/5053) [`c63255a`](https://github.com/modelcontextprotocol/servers/commit/c63255ae9616b110d6089fa343fcfdf3f5def534) Thanks [@cliffhall](https://github.com/cliffhall)! - Stop shipping the compiled test suite and Vitest config in the published package: the build now excludes `__tests__/`, `*.test.ts`, `*.spec.ts` and `vitest.config.ts`, as the other TypeScript servers' builds do. + +- [#5032](https://github.com/modelcontextprotocol/servers/pull/5032) [`d4fb2f4`](https://github.com/modelcontextprotocol/servers/commit/d4fb2f4f11efc084d97958dbecefa12c03020bd9) Thanks [@cliffhall](https://github.com/cliffhall)! - Session resources are now tracked per server, so two sessions that create a session resource with the same name (for example via `gzip-file-as-resource`) no longer evict each other's resource ([#4808](https://github.com/modelcontextprotocol/servers/issues/4808)). + +- [#5020](https://github.com/modelcontextprotocol/servers/pull/5020) [`f7af617`](https://github.com/modelcontextprotocol/servers/commit/f7af617ead2d4c7faf10338159a280d6387a1475) Thanks [@cliffhall](https://github.com/cliffhall)! - The HTTP+SSE transport now answers requests for sessions it cannot serve instead of leaving them hanging or failing with a 500: `POST /message` for an unknown session returns `404` (and `400` with no `sessionId`), and `GET /sse?sessionId=…` returns `409` for a session that already has its stream and `404` for an unknown one. Each carries a JSON-RPC error body. + +- [#5006](https://github.com/modelcontextprotocol/servers/pull/5006) [`dbd1b2e`](https://github.com/modelcontextprotocol/servers/commit/dbd1b2e699714074c6355ddf60e1fb832f139d7f) Thanks [@cliffhall](https://github.com/cliffhall)! - Streamable HTTP: answer an unknown or ended session ID with `404 Not Found` (carrying the request's `id` on a POST) instead of `400`; replay only the resumed stream's events after a `Last-Event-ID`, and refuse an unknown one; close every open session on shutdown. + +- [#4970](https://github.com/modelcontextprotocol/servers/pull/4970) [`c449b5e`](https://github.com/modelcontextprotocol/servers/commit/c449b5e71b7c9cc926a39799a2dcf0ec401d3a20) Thanks [@cliffhall](https://github.com/cliffhall)! - Internal refactor for in-process testing, with no change in behavior: the launcher only starts a transport when run as the binary, and the stdio, SSE and Streamable HTTP transport modules export their start-up (`startStdioServer`, `startSseServer`, `startStreamableHttpServer`, and `createApp()` for the HTTP transports) instead of starting a server when imported. + +- [#4936](https://github.com/modelcontextprotocol/servers/pull/4936) [`feb251d`](https://github.com/modelcontextprotocol/servers/commit/feb251d2fd0057570125ba0ba25e98c84f8c5b5c) Thanks [@cliffhall](https://github.com/cliffhall)! - The HTTP+SSE transport no longer prints "Server is running" and stays up when its port is already in use: it reports the port and exits non-zero, as Streamable HTTP does. Streamable HTTP no longer prints its listening line before that error. diff --git a/src/filesystem/CHANGELOG.md b/src/filesystem/CHANGELOG.md new file mode 100644 index 0000000000..bd71316b1a --- /dev/null +++ b/src/filesystem/CHANGELOG.md @@ -0,0 +1,25 @@ +# @modelcontextprotocol/server-filesystem + +## 1.0.0 + +The first semantic version, resuming semver after the date-stamped releases (last `2026.8.31`). It describes everything since `2026.8.31`, and is the last release of the legacy-era server. + +### Major Changes + +- [#5063](https://github.com/modelcontextprotocol/servers/pull/5063) [`b5af226`](https://github.com/modelcontextprotocol/servers/commit/b5af22668592700c02236d737f9836c42654edb6) Thanks [@cliffhall](https://github.com/cliffhall)! - Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`) from `@modelcontextprotocol/sdk` 1.x ([#4856](https://github.com/modelcontextprotocol/servers/issues/4856)). Breaking for clients: the server now requires Node.js 20 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); and tools no longer advertise `execution.taskSupport`. + +### Patch Changes + +- [#4970](https://github.com/modelcontextprotocol/servers/pull/4970) [`3a63f52`](https://github.com/modelcontextprotocol/servers/commit/3a63f52e751882e20b24e8f0c840d402348bbd72) Thanks [@cliffhall](https://github.com/cliffhall)! - Internal: the server is now built by a `createServer()` factory in `server.ts`, and `index.ts` starts it over stdio only when run as the bin, with each server instance holding its own allowed directories. No change to the tools, their results, or how the allowed directories are resolved from arguments and Roots. + +- [#5011](https://github.com/modelcontextprotocol/servers/pull/5011) [`d8ebdbe`](https://github.com/modelcontextprotocol/servers/commit/d8ebdbef770768c05d9fddbb663bc6f380a08867) Thanks [@cliffhall](https://github.com/cliffhall)! - `edit_file` keeps a file's line endings: a CRLF file is written back with CRLF instead of being converted to LF. When `oldText` has no exact match and the whitespace-tolerant matcher is used, each replacement line now takes the indentation of the file line it replaces, shifted by its indentation relative to the matching `oldText` line, instead of only the first line being reindented. + +- [#5022](https://github.com/modelcontextprotocol/servers/pull/5022) [`7dc802f`](https://github.com/modelcontextprotocol/servers/commit/7dc802f914a727e840b65712b0a9fbaf7fe1c2b5) Thanks [@cliffhall](https://github.com/cliffhall)! - `write_file` and `edit_file` now overwrite an existing file in place instead of renaming a temp file over it, so the file keeps its inode, creation time (birthtime), hard links and permission bits ([#4512](https://github.com/modelcontextprotocol/servers/issues/4512)), and an overwrite no longer fails with `EPERM` on Windows when another process holds the file open ([#3199](https://github.com/modelcontextprotocol/servers/issues/3199)). A symlink swapped in after the path was validated is still refused rather than written through, now by an `O_NOFOLLOW` open (on POSIX) and a check that the opened file is the one validated (on every platform). The overwrite is no longer crash-atomic. A read-only file is now refused (`EACCES`, or `EPERM` on Windows) instead of being replaced, since writing in place opens the file itself for writing. + +- [#5025](https://github.com/modelcontextprotocol/servers/pull/5025) [`2309e08`](https://github.com/modelcontextprotocol/servers/commit/2309e089b12385650e52784e26668aabdbc9b74b) Thanks [@cliffhall](https://github.com/cliffhall)! - Tool calls now wait for the client's initial roots before checking paths, so a call sent right after connecting is no longer refused with "Access denied" ([#3204](https://github.com/modelcontextprotocol/servers/issues/3204)). A server started with no directories, connected to a client without Roots support, now fails visibly: it logs the reason, closes the connection and exits with status 1, instead of staying up and refusing every call ([#4992](https://github.com/modelcontextprotocol/servers/issues/4992)). + +- [#5030](https://github.com/modelcontextprotocol/servers/pull/5030) [`5c04d74`](https://github.com/modelcontextprotocol/servers/commit/5c04d74ff6430ae82763aae76b3b1e585f5f13fd) Thanks [@cliffhall](https://github.com/cliffhall)! - Every tool's input and output schema in `tools/list` now declares `"$schema": "https://json-schema.org/draft/2020-12/schema"` instead of draft-07, so clients that validate tool schemas strictly against JSON Schema 2020-12 no longer reject every tool. The schemas are otherwise unchanged. + +- [#5010](https://github.com/modelcontextprotocol/servers/pull/5010) [`fd4dff7`](https://github.com/modelcontextprotocol/servers/commit/fd4dff7da877068734bee9419fa935213d66e359) Thanks [@cliffhall](https://github.com/cliffhall)! - On Windows, a UNC share root given as an allowed directory (`\\server\share` or `\\server\share\`) now admits the files and subdirectories under it. Before, only the share root itself was accessible and every path below it was refused. Sibling shares such as `\\server\share-evil` are still refused. + +- [#5009](https://github.com/modelcontextprotocol/servers/pull/5009) [`f621c75`](https://github.com/modelcontextprotocol/servers/commit/f621c75a92347738f28f26c9461286764beba3c8) Thanks [@cliffhall](https://github.com/cliffhall)! - Paths that differ from the names on disk only in Unicode form now resolve ([#1970](https://github.com/modelcontextprotocol/servers/issues/1970)). An NFC spelling of an allowed directory whose name is stored NFD (macOS "Capture d’écran", Japanese dakuten) is no longer refused as outside the allowed directories, and a request that spells a U+202F or U+00A0 in a name as a plain space (the macOS screenshot "Screenshot … at 2.40.40 PM.png") finds the file instead of failing with ENOENT. The exact spelling still wins when it exists, and a name matching two entries is refused as ambiguous. diff --git a/src/memory/CHANGELOG.md b/src/memory/CHANGELOG.md new file mode 100644 index 0000000000..30d1530203 --- /dev/null +++ b/src/memory/CHANGELOG.md @@ -0,0 +1,25 @@ +# @modelcontextprotocol/server-memory + +## 1.0.0 + +The first semantic version, resuming semver after the date-stamped releases (last `2026.8.31`). It describes everything since `2026.8.31`, and is the last release of the legacy-era server. + +### Major Changes + +- [#5063](https://github.com/modelcontextprotocol/servers/pull/5063) [`b5af226`](https://github.com/modelcontextprotocol/servers/commit/b5af22668592700c02236d737f9836c42654edb6) Thanks [@cliffhall](https://github.com/cliffhall)! - Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`) from `@modelcontextprotocol/sdk` 1.x ([#4856](https://github.com/modelcontextprotocol/servers/issues/4856)). Breaking for clients: the server now requires Node.js 20 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); tool schemas in `tools/list` declare JSON Schema 2020-12 instead of draft-07; and tools no longer advertise `execution.taskSupport`. + +### Patch Changes + +- [#5008](https://github.com/modelcontextprotocol/servers/pull/5008) [`bb8aa6f`](https://github.com/modelcontextprotocol/servers/commit/bb8aa6fd73b2a2fd68bc7084292296d2b0084780) Thanks [@cliffhall](https://github.com/cliffhall)! - `create_entities` now reports the entities it skipped because their name already exists (or repeats earlier in the same call): the structured result lists them in a new optional `skipped` array, a second text item names them and points to `add_observations`, and the tool description says so. Skipped entities are still not created and their observations are still not added ([#4887](https://github.com/modelcontextprotocol/servers/issues/4887)). + +- [#4970](https://github.com/modelcontextprotocol/servers/pull/4970) [`a8127ba`](https://github.com/modelcontextprotocol/servers/commit/a8127ba956c12c7f6597b43765f1f7a68aee0d7e) Thanks [@cliffhall](https://github.com/cliffhall)! - Build the server with an exported `createServer()` factory and start stdio only when the package is run as a program, so importing the module no longer starts a server. Behavior over stdio is unchanged. + +- [#5037](https://github.com/modelcontextprotocol/servers/pull/5037) [`7be08e9`](https://github.com/modelcontextprotocol/servers/commit/7be08e97f399721c215de52bfb74a5030c20f78f) Thanks [@cliffhall](https://github.com/cliffhall)! - Two server processes sharing one memory file no longer silently discard each other's writes: every write tool now holds an exclusive lock file (`.lock`) while it reads, changes and saves the graph, and a lock left by a crashed server is recovered automatically ([#4797](https://github.com/modelcontextprotocol/servers/issues/4797)). + +- [#4937](https://github.com/modelcontextprotocol/servers/pull/4937) [`478db6a`](https://github.com/modelcontextprotocol/servers/commit/478db6a5d5bc0bec3196e2c8c9461ec1597ded34) Thanks [@cliffhall](https://github.com/cliffhall)! - Internal: `ensureMemoryFilePath` accepts the directory its default files live in, so the server's tests no longer write into the package directory. No change to how the server chooses or migrates its memory file. + +- [#5035](https://github.com/modelcontextprotocol/servers/pull/5035) [`2b1305f`](https://github.com/modelcontextprotocol/servers/commit/2b1305fc0db794cdf1bd57ca83d13fb8a195e869) Thanks [@cliffhall](https://github.com/cliffhall)! - Lines in the memory file that the server cannot read (malformed JSON, an entity or relation that fails validation, an unknown record type) are no longer deleted by the next write. They are still left out of the graph, and are now written back unchanged after the graph's own lines. + +- [#5053](https://github.com/modelcontextprotocol/servers/pull/5053) [`c63255a`](https://github.com/modelcontextprotocol/servers/commit/c63255ae9616b110d6089fa343fcfdf3f5def534) Thanks [@cliffhall](https://github.com/cliffhall)! - Stop shipping the compiled test helper `dist/__tests__/helpers.js` in the published package: the build now excludes everything under `__tests__/`, not just `*.test.ts`. + +- [#5013](https://github.com/modelcontextprotocol/servers/pull/5013) [`819ae90`](https://github.com/modelcontextprotocol/servers/commit/819ae904cc08ab0b208ccb91addc3169b3ece85b) Thanks [@cliffhall](https://github.com/cliffhall)! - Saving the knowledge graph keeps the memory file's permission bits (an operator's `0600` no longer comes back `0644`), and a write to a read-only memory file now fails with `EACCES` instead of silently replacing it ([#4827](https://github.com/modelcontextprotocol/servers/issues/4827)). diff --git a/src/sequentialthinking/CHANGELOG.md b/src/sequentialthinking/CHANGELOG.md new file mode 100644 index 0000000000..352bd55a4b --- /dev/null +++ b/src/sequentialthinking/CHANGELOG.md @@ -0,0 +1,21 @@ +# @modelcontextprotocol/server-sequential-thinking + +## 1.0.0 + +The first semantic version, resuming semver after the date-stamped releases (last `2026.8.31`). It describes everything since `2026.8.31`, and is the last release of the legacy-era server. + +### Major Changes + +- [#5063](https://github.com/modelcontextprotocol/servers/pull/5063) [`b5af226`](https://github.com/modelcontextprotocol/servers/commit/b5af22668592700c02236d737f9836c42654edb6) Thanks [@cliffhall](https://github.com/cliffhall)! - Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`) from `@modelcontextprotocol/sdk` 1.x ([#4856](https://github.com/modelcontextprotocol/servers/issues/4856)). Breaking for clients: the server now requires Node.js 20 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); tool schemas in `tools/list` declare JSON Schema 2020-12 instead of draft-07; and tools no longer advertise `execution.taskSupport`. + +### Patch Changes + +- [#5015](https://github.com/modelcontextprotocol/servers/pull/5015) [`8a4139c`](https://github.com/modelcontextprotocol/servers/commit/8a4139c3d71b66e95d2f7cd2bc485d4781ecf1d7) Thanks [@cliffhall](https://github.com/cliffhall)! - Advertise the `sequentialthinking` tool as `readOnlyHint: false` and `idempotentHint: false`. Every call appends to the server's thought history, and a call with both `branchFromThought` and `branchId` also appends to that branch, so the tool was never read-only or idempotent. `destructiveHint` and `openWorldHint` stay `false`. ([#4721](https://github.com/modelcontextprotocol/servers/issues/4721)) + +- [#4970](https://github.com/modelcontextprotocol/servers/pull/4970) [`86806cc`](https://github.com/modelcontextprotocol/servers/commit/86806cc3870a112f56870e86cd36bae8239edb02) Thanks [@cliffhall](https://github.com/cliffhall)! - Build the server with an exported `createServer()` factory and attach stdio only when `index.js` runs as the binary, so the server can be tested in-process. No change to the tool, its schemas or its results. + +- [#5031](https://github.com/modelcontextprotocol/servers/pull/5031) [`bc52d0e`](https://github.com/modelcontextprotocol/servers/commit/bc52d0e07a1d9dca505746165b30f4c99f8cc79b) Thanks [@cliffhall](https://github.com/cliffhall)! - The thought log on stderr no longer prints "undefined" in its headers: a revision with no `revisesThought` reads "Revision N/M", and a branch with no `branchId` reads "(from thought N)" with no ID. The box border is sized from the header's visible text, so colour escape codes no longer make it wider than its text. + +- [#5053](https://github.com/modelcontextprotocol/servers/pull/5053) [`c63255a`](https://github.com/modelcontextprotocol/servers/commit/c63255ae9616b110d6089fa343fcfdf3f5def534) Thanks [@cliffhall](https://github.com/cliffhall)! - Stop shipping the compiled test helper `dist/__tests__/helpers.js` in the published package: the build now excludes everything under `__tests__/`, not just `*.test.ts`. + +- [#5036](https://github.com/modelcontextprotocol/servers/pull/5036) [`b5c5ccc`](https://github.com/modelcontextprotocol/servers/commit/b5c5cccb79a5d43b41247065fb9efd174363e799) Thanks [@cliffhall](https://github.com/cliffhall)! - A `branchId` that names an `Object.prototype` key, such as `"constructor"` or `"__proto__"`, now creates and lists its branch like any other id instead of failing the call. A call that fails no longer adds its thought to the history first, so `thoughtHistoryLength` counts only thoughts that were accepted. From eb991bdb4b9ff01685fa353bf9d58324b0dda8af Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 10 Oct 2026 01:17:37 -0400 Subject: [PATCH 047/117] chore: credit @cliffhall without the Thanks prefix in the 1.0.0 changelogs Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/everything/CHANGELOG.md | 18 +++++++++--------- src/filesystem/CHANGELOG.md | 16 ++++++++-------- src/memory/CHANGELOG.md | 16 ++++++++-------- src/sequentialthinking/CHANGELOG.md | 12 ++++++------ 4 files changed, 31 insertions(+), 31 deletions(-) diff --git a/src/everything/CHANGELOG.md b/src/everything/CHANGELOG.md index 5cf0063995..d21e889a5a 100644 --- a/src/everything/CHANGELOG.md +++ b/src/everything/CHANGELOG.md @@ -6,22 +6,22 @@ The first semantic version, resuming semver after the date-stamped releases (las ### Major Changes -- [#5063](https://github.com/modelcontextprotocol/servers/pull/5063) [`b5af226`](https://github.com/modelcontextprotocol/servers/commit/b5af22668592700c02236d737f9836c42654edb6) Thanks [@cliffhall](https://github.com/cliffhall)! - Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`, `@modelcontextprotocol/node`, and `@modelcontextprotocol/server-legacy` for the deprecated HTTP+SSE transport) from `@modelcontextprotocol/sdk` 1.x ([#4856](https://github.com/modelcontextprotocol/servers/issues/4856)). The task tools `simulate-research-query`, `trigger-sampling-request-async` and `trigger-elicitation-request-async` are removed, and the server no longer advertises the `tasks` capability, because SDK v2 dropped the experimental tasks layer they were built on; they return on the tasks extension in [#4852](https://github.com/modelcontextprotocol/servers/issues/4852). Breaking for clients: the server now requires Node.js 20 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); tool schemas in `tools/list` declare JSON Schema 2020-12 instead of draft-07; and tools no longer advertise `execution.taskSupport`. The optional `state` argument of `args-prompt` now carries its description. +- [#5063](https://github.com/modelcontextprotocol/servers/pull/5063) [`b5af226`](https://github.com/modelcontextprotocol/servers/commit/b5af22668592700c02236d737f9836c42654edb6) [@cliffhall](https://github.com/cliffhall) - Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`, `@modelcontextprotocol/node`, and `@modelcontextprotocol/server-legacy` for the deprecated HTTP+SSE transport) from `@modelcontextprotocol/sdk` 1.x ([#4856](https://github.com/modelcontextprotocol/servers/issues/4856)). The task tools `simulate-research-query`, `trigger-sampling-request-async` and `trigger-elicitation-request-async` are removed, and the server no longer advertises the `tasks` capability, because SDK v2 dropped the experimental tasks layer they were built on; they return on the tasks extension in [#4852](https://github.com/modelcontextprotocol/servers/issues/4852). Breaking for clients: the server now requires Node.js 20 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); tool schemas in `tools/list` declare JSON Schema 2020-12 instead of draft-07; and tools no longer advertise `execution.taskSupport`. The optional `state` argument of `args-prompt` now carries its description. ### Patch Changes -- [#5005](https://github.com/modelcontextprotocol/servers/pull/5005) [`63dc92f`](https://github.com/modelcontextprotocol/servers/commit/63dc92f0e95992faf9d3e339cb8a51b1090d138b) Thanks [@cliffhall](https://github.com/cliffhall)! - Label dynamic blob resources `application/octet-stream`, matching their resource template, instead of `text/plain`. This applies to `resources/read`, the `get-resource-reference` and `get-resource-links` tools, and the `resource-prompt` prompt; blob resource links are now described as "binary blob resource". +- [#5005](https://github.com/modelcontextprotocol/servers/pull/5005) [`63dc92f`](https://github.com/modelcontextprotocol/servers/commit/63dc92f0e95992faf9d3e339cb8a51b1090d138b) [@cliffhall](https://github.com/cliffhall) - Label dynamic blob resources `application/octet-stream`, matching their resource template, instead of `text/plain`. This applies to `resources/read`, the `get-resource-reference` and `get-resource-links` tools, and the `resource-prompt` prompt; blob resource links are now described as "binary blob resource". -- [#5034](https://github.com/modelcontextprotocol/servers/pull/5034) [`6bed4c5`](https://github.com/modelcontextprotocol/servers/commit/6bed4c5f7c219e77fe90b9e4b14cdb9f3f7e9503) Thanks [@cliffhall](https://github.com/cliffhall)! - Server instructions no longer tell every client to use tools that are registered only for clients declaring a capability; they list those tools and the capability each needs ([#4792](https://github.com/modelcontextprotocol/servers/issues/4792), ported from [#4835](https://github.com/modelcontextprotocol/servers/issues/4835)). `trigger-elicitation-request` is now registered only for clients that support form-mode elicitation, so a client that declared only URL-mode elicitation no longer sees a tool it cannot answer ([#4985](https://github.com/modelcontextprotocol/servers/issues/4985)). +- [#5034](https://github.com/modelcontextprotocol/servers/pull/5034) [`6bed4c5`](https://github.com/modelcontextprotocol/servers/commit/6bed4c5f7c219e77fe90b9e4b14cdb9f3f7e9503) [@cliffhall](https://github.com/cliffhall) - Server instructions no longer tell every client to use tools that are registered only for clients declaring a capability; they list those tools and the capability each needs ([#4792](https://github.com/modelcontextprotocol/servers/issues/4792), ported from [#4835](https://github.com/modelcontextprotocol/servers/issues/4835)). `trigger-elicitation-request` is now registered only for clients that support form-mode elicitation, so a client that declared only URL-mode elicitation no longer sees a tool it cannot answer ([#4985](https://github.com/modelcontextprotocol/servers/issues/4985)). -- [#5053](https://github.com/modelcontextprotocol/servers/pull/5053) [`c63255a`](https://github.com/modelcontextprotocol/servers/commit/c63255ae9616b110d6089fa343fcfdf3f5def534) Thanks [@cliffhall](https://github.com/cliffhall)! - Stop shipping the compiled test suite and Vitest config in the published package: the build now excludes `__tests__/`, `*.test.ts`, `*.spec.ts` and `vitest.config.ts`, as the other TypeScript servers' builds do. +- [#5053](https://github.com/modelcontextprotocol/servers/pull/5053) [`c63255a`](https://github.com/modelcontextprotocol/servers/commit/c63255ae9616b110d6089fa343fcfdf3f5def534) [@cliffhall](https://github.com/cliffhall) - Stop shipping the compiled test suite and Vitest config in the published package: the build now excludes `__tests__/`, `*.test.ts`, `*.spec.ts` and `vitest.config.ts`, as the other TypeScript servers' builds do. -- [#5032](https://github.com/modelcontextprotocol/servers/pull/5032) [`d4fb2f4`](https://github.com/modelcontextprotocol/servers/commit/d4fb2f4f11efc084d97958dbecefa12c03020bd9) Thanks [@cliffhall](https://github.com/cliffhall)! - Session resources are now tracked per server, so two sessions that create a session resource with the same name (for example via `gzip-file-as-resource`) no longer evict each other's resource ([#4808](https://github.com/modelcontextprotocol/servers/issues/4808)). +- [#5032](https://github.com/modelcontextprotocol/servers/pull/5032) [`d4fb2f4`](https://github.com/modelcontextprotocol/servers/commit/d4fb2f4f11efc084d97958dbecefa12c03020bd9) [@cliffhall](https://github.com/cliffhall) - Session resources are now tracked per server, so two sessions that create a session resource with the same name (for example via `gzip-file-as-resource`) no longer evict each other's resource ([#4808](https://github.com/modelcontextprotocol/servers/issues/4808)). -- [#5020](https://github.com/modelcontextprotocol/servers/pull/5020) [`f7af617`](https://github.com/modelcontextprotocol/servers/commit/f7af617ead2d4c7faf10338159a280d6387a1475) Thanks [@cliffhall](https://github.com/cliffhall)! - The HTTP+SSE transport now answers requests for sessions it cannot serve instead of leaving them hanging or failing with a 500: `POST /message` for an unknown session returns `404` (and `400` with no `sessionId`), and `GET /sse?sessionId=…` returns `409` for a session that already has its stream and `404` for an unknown one. Each carries a JSON-RPC error body. +- [#5020](https://github.com/modelcontextprotocol/servers/pull/5020) [`f7af617`](https://github.com/modelcontextprotocol/servers/commit/f7af617ead2d4c7faf10338159a280d6387a1475) [@cliffhall](https://github.com/cliffhall) - The HTTP+SSE transport now answers requests for sessions it cannot serve instead of leaving them hanging or failing with a 500: `POST /message` for an unknown session returns `404` (and `400` with no `sessionId`), and `GET /sse?sessionId=…` returns `409` for a session that already has its stream and `404` for an unknown one. Each carries a JSON-RPC error body. -- [#5006](https://github.com/modelcontextprotocol/servers/pull/5006) [`dbd1b2e`](https://github.com/modelcontextprotocol/servers/commit/dbd1b2e699714074c6355ddf60e1fb832f139d7f) Thanks [@cliffhall](https://github.com/cliffhall)! - Streamable HTTP: answer an unknown or ended session ID with `404 Not Found` (carrying the request's `id` on a POST) instead of `400`; replay only the resumed stream's events after a `Last-Event-ID`, and refuse an unknown one; close every open session on shutdown. +- [#5006](https://github.com/modelcontextprotocol/servers/pull/5006) [`dbd1b2e`](https://github.com/modelcontextprotocol/servers/commit/dbd1b2e699714074c6355ddf60e1fb832f139d7f) [@cliffhall](https://github.com/cliffhall) - Streamable HTTP: answer an unknown or ended session ID with `404 Not Found` (carrying the request's `id` on a POST) instead of `400`; replay only the resumed stream's events after a `Last-Event-ID`, and refuse an unknown one; close every open session on shutdown. -- [#4970](https://github.com/modelcontextprotocol/servers/pull/4970) [`c449b5e`](https://github.com/modelcontextprotocol/servers/commit/c449b5e71b7c9cc926a39799a2dcf0ec401d3a20) Thanks [@cliffhall](https://github.com/cliffhall)! - Internal refactor for in-process testing, with no change in behavior: the launcher only starts a transport when run as the binary, and the stdio, SSE and Streamable HTTP transport modules export their start-up (`startStdioServer`, `startSseServer`, `startStreamableHttpServer`, and `createApp()` for the HTTP transports) instead of starting a server when imported. +- [#4970](https://github.com/modelcontextprotocol/servers/pull/4970) [`c449b5e`](https://github.com/modelcontextprotocol/servers/commit/c449b5e71b7c9cc926a39799a2dcf0ec401d3a20) [@cliffhall](https://github.com/cliffhall) - Internal refactor for in-process testing, with no change in behavior: the launcher only starts a transport when run as the binary, and the stdio, SSE and Streamable HTTP transport modules export their start-up (`startStdioServer`, `startSseServer`, `startStreamableHttpServer`, and `createApp()` for the HTTP transports) instead of starting a server when imported. -- [#4936](https://github.com/modelcontextprotocol/servers/pull/4936) [`feb251d`](https://github.com/modelcontextprotocol/servers/commit/feb251d2fd0057570125ba0ba25e98c84f8c5b5c) Thanks [@cliffhall](https://github.com/cliffhall)! - The HTTP+SSE transport no longer prints "Server is running" and stays up when its port is already in use: it reports the port and exits non-zero, as Streamable HTTP does. Streamable HTTP no longer prints its listening line before that error. +- [#4936](https://github.com/modelcontextprotocol/servers/pull/4936) [`feb251d`](https://github.com/modelcontextprotocol/servers/commit/feb251d2fd0057570125ba0ba25e98c84f8c5b5c) [@cliffhall](https://github.com/cliffhall) - The HTTP+SSE transport no longer prints "Server is running" and stays up when its port is already in use: it reports the port and exits non-zero, as Streamable HTTP does. Streamable HTTP no longer prints its listening line before that error. diff --git a/src/filesystem/CHANGELOG.md b/src/filesystem/CHANGELOG.md index bd71316b1a..e81be2302c 100644 --- a/src/filesystem/CHANGELOG.md +++ b/src/filesystem/CHANGELOG.md @@ -6,20 +6,20 @@ The first semantic version, resuming semver after the date-stamped releases (las ### Major Changes -- [#5063](https://github.com/modelcontextprotocol/servers/pull/5063) [`b5af226`](https://github.com/modelcontextprotocol/servers/commit/b5af22668592700c02236d737f9836c42654edb6) Thanks [@cliffhall](https://github.com/cliffhall)! - Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`) from `@modelcontextprotocol/sdk` 1.x ([#4856](https://github.com/modelcontextprotocol/servers/issues/4856)). Breaking for clients: the server now requires Node.js 20 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); and tools no longer advertise `execution.taskSupport`. +- [#5063](https://github.com/modelcontextprotocol/servers/pull/5063) [`b5af226`](https://github.com/modelcontextprotocol/servers/commit/b5af22668592700c02236d737f9836c42654edb6) [@cliffhall](https://github.com/cliffhall) - Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`) from `@modelcontextprotocol/sdk` 1.x ([#4856](https://github.com/modelcontextprotocol/servers/issues/4856)). Breaking for clients: the server now requires Node.js 20 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); and tools no longer advertise `execution.taskSupport`. ### Patch Changes -- [#4970](https://github.com/modelcontextprotocol/servers/pull/4970) [`3a63f52`](https://github.com/modelcontextprotocol/servers/commit/3a63f52e751882e20b24e8f0c840d402348bbd72) Thanks [@cliffhall](https://github.com/cliffhall)! - Internal: the server is now built by a `createServer()` factory in `server.ts`, and `index.ts` starts it over stdio only when run as the bin, with each server instance holding its own allowed directories. No change to the tools, their results, or how the allowed directories are resolved from arguments and Roots. +- [#4970](https://github.com/modelcontextprotocol/servers/pull/4970) [`3a63f52`](https://github.com/modelcontextprotocol/servers/commit/3a63f52e751882e20b24e8f0c840d402348bbd72) [@cliffhall](https://github.com/cliffhall) - Internal: the server is now built by a `createServer()` factory in `server.ts`, and `index.ts` starts it over stdio only when run as the bin, with each server instance holding its own allowed directories. No change to the tools, their results, or how the allowed directories are resolved from arguments and Roots. -- [#5011](https://github.com/modelcontextprotocol/servers/pull/5011) [`d8ebdbe`](https://github.com/modelcontextprotocol/servers/commit/d8ebdbef770768c05d9fddbb663bc6f380a08867) Thanks [@cliffhall](https://github.com/cliffhall)! - `edit_file` keeps a file's line endings: a CRLF file is written back with CRLF instead of being converted to LF. When `oldText` has no exact match and the whitespace-tolerant matcher is used, each replacement line now takes the indentation of the file line it replaces, shifted by its indentation relative to the matching `oldText` line, instead of only the first line being reindented. +- [#5011](https://github.com/modelcontextprotocol/servers/pull/5011) [`d8ebdbe`](https://github.com/modelcontextprotocol/servers/commit/d8ebdbef770768c05d9fddbb663bc6f380a08867) [@cliffhall](https://github.com/cliffhall) - `edit_file` keeps a file's line endings: a CRLF file is written back with CRLF instead of being converted to LF. When `oldText` has no exact match and the whitespace-tolerant matcher is used, each replacement line now takes the indentation of the file line it replaces, shifted by its indentation relative to the matching `oldText` line, instead of only the first line being reindented. -- [#5022](https://github.com/modelcontextprotocol/servers/pull/5022) [`7dc802f`](https://github.com/modelcontextprotocol/servers/commit/7dc802f914a727e840b65712b0a9fbaf7fe1c2b5) Thanks [@cliffhall](https://github.com/cliffhall)! - `write_file` and `edit_file` now overwrite an existing file in place instead of renaming a temp file over it, so the file keeps its inode, creation time (birthtime), hard links and permission bits ([#4512](https://github.com/modelcontextprotocol/servers/issues/4512)), and an overwrite no longer fails with `EPERM` on Windows when another process holds the file open ([#3199](https://github.com/modelcontextprotocol/servers/issues/3199)). A symlink swapped in after the path was validated is still refused rather than written through, now by an `O_NOFOLLOW` open (on POSIX) and a check that the opened file is the one validated (on every platform). The overwrite is no longer crash-atomic. A read-only file is now refused (`EACCES`, or `EPERM` on Windows) instead of being replaced, since writing in place opens the file itself for writing. +- [#5022](https://github.com/modelcontextprotocol/servers/pull/5022) [`7dc802f`](https://github.com/modelcontextprotocol/servers/commit/7dc802f914a727e840b65712b0a9fbaf7fe1c2b5) [@cliffhall](https://github.com/cliffhall) - `write_file` and `edit_file` now overwrite an existing file in place instead of renaming a temp file over it, so the file keeps its inode, creation time (birthtime), hard links and permission bits ([#4512](https://github.com/modelcontextprotocol/servers/issues/4512)), and an overwrite no longer fails with `EPERM` on Windows when another process holds the file open ([#3199](https://github.com/modelcontextprotocol/servers/issues/3199)). A symlink swapped in after the path was validated is still refused rather than written through, now by an `O_NOFOLLOW` open (on POSIX) and a check that the opened file is the one validated (on every platform). The overwrite is no longer crash-atomic. A read-only file is now refused (`EACCES`, or `EPERM` on Windows) instead of being replaced, since writing in place opens the file itself for writing. -- [#5025](https://github.com/modelcontextprotocol/servers/pull/5025) [`2309e08`](https://github.com/modelcontextprotocol/servers/commit/2309e089b12385650e52784e26668aabdbc9b74b) Thanks [@cliffhall](https://github.com/cliffhall)! - Tool calls now wait for the client's initial roots before checking paths, so a call sent right after connecting is no longer refused with "Access denied" ([#3204](https://github.com/modelcontextprotocol/servers/issues/3204)). A server started with no directories, connected to a client without Roots support, now fails visibly: it logs the reason, closes the connection and exits with status 1, instead of staying up and refusing every call ([#4992](https://github.com/modelcontextprotocol/servers/issues/4992)). +- [#5025](https://github.com/modelcontextprotocol/servers/pull/5025) [`2309e08`](https://github.com/modelcontextprotocol/servers/commit/2309e089b12385650e52784e26668aabdbc9b74b) [@cliffhall](https://github.com/cliffhall) - Tool calls now wait for the client's initial roots before checking paths, so a call sent right after connecting is no longer refused with "Access denied" ([#3204](https://github.com/modelcontextprotocol/servers/issues/3204)). A server started with no directories, connected to a client without Roots support, now fails visibly: it logs the reason, closes the connection and exits with status 1, instead of staying up and refusing every call ([#4992](https://github.com/modelcontextprotocol/servers/issues/4992)). -- [#5030](https://github.com/modelcontextprotocol/servers/pull/5030) [`5c04d74`](https://github.com/modelcontextprotocol/servers/commit/5c04d74ff6430ae82763aae76b3b1e585f5f13fd) Thanks [@cliffhall](https://github.com/cliffhall)! - Every tool's input and output schema in `tools/list` now declares `"$schema": "https://json-schema.org/draft/2020-12/schema"` instead of draft-07, so clients that validate tool schemas strictly against JSON Schema 2020-12 no longer reject every tool. The schemas are otherwise unchanged. +- [#5030](https://github.com/modelcontextprotocol/servers/pull/5030) [`5c04d74`](https://github.com/modelcontextprotocol/servers/commit/5c04d74ff6430ae82763aae76b3b1e585f5f13fd) [@cliffhall](https://github.com/cliffhall) - Every tool's input and output schema in `tools/list` now declares `"$schema": "https://json-schema.org/draft/2020-12/schema"` instead of draft-07, so clients that validate tool schemas strictly against JSON Schema 2020-12 no longer reject every tool. The schemas are otherwise unchanged. -- [#5010](https://github.com/modelcontextprotocol/servers/pull/5010) [`fd4dff7`](https://github.com/modelcontextprotocol/servers/commit/fd4dff7da877068734bee9419fa935213d66e359) Thanks [@cliffhall](https://github.com/cliffhall)! - On Windows, a UNC share root given as an allowed directory (`\\server\share` or `\\server\share\`) now admits the files and subdirectories under it. Before, only the share root itself was accessible and every path below it was refused. Sibling shares such as `\\server\share-evil` are still refused. +- [#5010](https://github.com/modelcontextprotocol/servers/pull/5010) [`fd4dff7`](https://github.com/modelcontextprotocol/servers/commit/fd4dff7da877068734bee9419fa935213d66e359) [@cliffhall](https://github.com/cliffhall) - On Windows, a UNC share root given as an allowed directory (`\\server\share` or `\\server\share\`) now admits the files and subdirectories under it. Before, only the share root itself was accessible and every path below it was refused. Sibling shares such as `\\server\share-evil` are still refused. -- [#5009](https://github.com/modelcontextprotocol/servers/pull/5009) [`f621c75`](https://github.com/modelcontextprotocol/servers/commit/f621c75a92347738f28f26c9461286764beba3c8) Thanks [@cliffhall](https://github.com/cliffhall)! - Paths that differ from the names on disk only in Unicode form now resolve ([#1970](https://github.com/modelcontextprotocol/servers/issues/1970)). An NFC spelling of an allowed directory whose name is stored NFD (macOS "Capture d’écran", Japanese dakuten) is no longer refused as outside the allowed directories, and a request that spells a U+202F or U+00A0 in a name as a plain space (the macOS screenshot "Screenshot … at 2.40.40 PM.png") finds the file instead of failing with ENOENT. The exact spelling still wins when it exists, and a name matching two entries is refused as ambiguous. +- [#5009](https://github.com/modelcontextprotocol/servers/pull/5009) [`f621c75`](https://github.com/modelcontextprotocol/servers/commit/f621c75a92347738f28f26c9461286764beba3c8) [@cliffhall](https://github.com/cliffhall) - Paths that differ from the names on disk only in Unicode form now resolve ([#1970](https://github.com/modelcontextprotocol/servers/issues/1970)). An NFC spelling of an allowed directory whose name is stored NFD (macOS "Capture d’écran", Japanese dakuten) is no longer refused as outside the allowed directories, and a request that spells a U+202F or U+00A0 in a name as a plain space (the macOS screenshot "Screenshot … at 2.40.40 PM.png") finds the file instead of failing with ENOENT. The exact spelling still wins when it exists, and a name matching two entries is refused as ambiguous. diff --git a/src/memory/CHANGELOG.md b/src/memory/CHANGELOG.md index 30d1530203..81c2482f3d 100644 --- a/src/memory/CHANGELOG.md +++ b/src/memory/CHANGELOG.md @@ -6,20 +6,20 @@ The first semantic version, resuming semver after the date-stamped releases (las ### Major Changes -- [#5063](https://github.com/modelcontextprotocol/servers/pull/5063) [`b5af226`](https://github.com/modelcontextprotocol/servers/commit/b5af22668592700c02236d737f9836c42654edb6) Thanks [@cliffhall](https://github.com/cliffhall)! - Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`) from `@modelcontextprotocol/sdk` 1.x ([#4856](https://github.com/modelcontextprotocol/servers/issues/4856)). Breaking for clients: the server now requires Node.js 20 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); tool schemas in `tools/list` declare JSON Schema 2020-12 instead of draft-07; and tools no longer advertise `execution.taskSupport`. +- [#5063](https://github.com/modelcontextprotocol/servers/pull/5063) [`b5af226`](https://github.com/modelcontextprotocol/servers/commit/b5af22668592700c02236d737f9836c42654edb6) [@cliffhall](https://github.com/cliffhall) - Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`) from `@modelcontextprotocol/sdk` 1.x ([#4856](https://github.com/modelcontextprotocol/servers/issues/4856)). Breaking for clients: the server now requires Node.js 20 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); tool schemas in `tools/list` declare JSON Schema 2020-12 instead of draft-07; and tools no longer advertise `execution.taskSupport`. ### Patch Changes -- [#5008](https://github.com/modelcontextprotocol/servers/pull/5008) [`bb8aa6f`](https://github.com/modelcontextprotocol/servers/commit/bb8aa6fd73b2a2fd68bc7084292296d2b0084780) Thanks [@cliffhall](https://github.com/cliffhall)! - `create_entities` now reports the entities it skipped because their name already exists (or repeats earlier in the same call): the structured result lists them in a new optional `skipped` array, a second text item names them and points to `add_observations`, and the tool description says so. Skipped entities are still not created and their observations are still not added ([#4887](https://github.com/modelcontextprotocol/servers/issues/4887)). +- [#5008](https://github.com/modelcontextprotocol/servers/pull/5008) [`bb8aa6f`](https://github.com/modelcontextprotocol/servers/commit/bb8aa6fd73b2a2fd68bc7084292296d2b0084780) [@cliffhall](https://github.com/cliffhall) - `create_entities` now reports the entities it skipped because their name already exists (or repeats earlier in the same call): the structured result lists them in a new optional `skipped` array, a second text item names them and points to `add_observations`, and the tool description says so. Skipped entities are still not created and their observations are still not added ([#4887](https://github.com/modelcontextprotocol/servers/issues/4887)). -- [#4970](https://github.com/modelcontextprotocol/servers/pull/4970) [`a8127ba`](https://github.com/modelcontextprotocol/servers/commit/a8127ba956c12c7f6597b43765f1f7a68aee0d7e) Thanks [@cliffhall](https://github.com/cliffhall)! - Build the server with an exported `createServer()` factory and start stdio only when the package is run as a program, so importing the module no longer starts a server. Behavior over stdio is unchanged. +- [#4970](https://github.com/modelcontextprotocol/servers/pull/4970) [`a8127ba`](https://github.com/modelcontextprotocol/servers/commit/a8127ba956c12c7f6597b43765f1f7a68aee0d7e) [@cliffhall](https://github.com/cliffhall) - Build the server with an exported `createServer()` factory and start stdio only when the package is run as a program, so importing the module no longer starts a server. Behavior over stdio is unchanged. -- [#5037](https://github.com/modelcontextprotocol/servers/pull/5037) [`7be08e9`](https://github.com/modelcontextprotocol/servers/commit/7be08e97f399721c215de52bfb74a5030c20f78f) Thanks [@cliffhall](https://github.com/cliffhall)! - Two server processes sharing one memory file no longer silently discard each other's writes: every write tool now holds an exclusive lock file (`.lock`) while it reads, changes and saves the graph, and a lock left by a crashed server is recovered automatically ([#4797](https://github.com/modelcontextprotocol/servers/issues/4797)). +- [#5037](https://github.com/modelcontextprotocol/servers/pull/5037) [`7be08e9`](https://github.com/modelcontextprotocol/servers/commit/7be08e97f399721c215de52bfb74a5030c20f78f) [@cliffhall](https://github.com/cliffhall) - Two server processes sharing one memory file no longer silently discard each other's writes: every write tool now holds an exclusive lock file (`.lock`) while it reads, changes and saves the graph, and a lock left by a crashed server is recovered automatically ([#4797](https://github.com/modelcontextprotocol/servers/issues/4797)). -- [#4937](https://github.com/modelcontextprotocol/servers/pull/4937) [`478db6a`](https://github.com/modelcontextprotocol/servers/commit/478db6a5d5bc0bec3196e2c8c9461ec1597ded34) Thanks [@cliffhall](https://github.com/cliffhall)! - Internal: `ensureMemoryFilePath` accepts the directory its default files live in, so the server's tests no longer write into the package directory. No change to how the server chooses or migrates its memory file. +- [#4937](https://github.com/modelcontextprotocol/servers/pull/4937) [`478db6a`](https://github.com/modelcontextprotocol/servers/commit/478db6a5d5bc0bec3196e2c8c9461ec1597ded34) [@cliffhall](https://github.com/cliffhall) - Internal: `ensureMemoryFilePath` accepts the directory its default files live in, so the server's tests no longer write into the package directory. No change to how the server chooses or migrates its memory file. -- [#5035](https://github.com/modelcontextprotocol/servers/pull/5035) [`2b1305f`](https://github.com/modelcontextprotocol/servers/commit/2b1305fc0db794cdf1bd57ca83d13fb8a195e869) Thanks [@cliffhall](https://github.com/cliffhall)! - Lines in the memory file that the server cannot read (malformed JSON, an entity or relation that fails validation, an unknown record type) are no longer deleted by the next write. They are still left out of the graph, and are now written back unchanged after the graph's own lines. +- [#5035](https://github.com/modelcontextprotocol/servers/pull/5035) [`2b1305f`](https://github.com/modelcontextprotocol/servers/commit/2b1305fc0db794cdf1bd57ca83d13fb8a195e869) [@cliffhall](https://github.com/cliffhall) - Lines in the memory file that the server cannot read (malformed JSON, an entity or relation that fails validation, an unknown record type) are no longer deleted by the next write. They are still left out of the graph, and are now written back unchanged after the graph's own lines. -- [#5053](https://github.com/modelcontextprotocol/servers/pull/5053) [`c63255a`](https://github.com/modelcontextprotocol/servers/commit/c63255ae9616b110d6089fa343fcfdf3f5def534) Thanks [@cliffhall](https://github.com/cliffhall)! - Stop shipping the compiled test helper `dist/__tests__/helpers.js` in the published package: the build now excludes everything under `__tests__/`, not just `*.test.ts`. +- [#5053](https://github.com/modelcontextprotocol/servers/pull/5053) [`c63255a`](https://github.com/modelcontextprotocol/servers/commit/c63255ae9616b110d6089fa343fcfdf3f5def534) [@cliffhall](https://github.com/cliffhall) - Stop shipping the compiled test helper `dist/__tests__/helpers.js` in the published package: the build now excludes everything under `__tests__/`, not just `*.test.ts`. -- [#5013](https://github.com/modelcontextprotocol/servers/pull/5013) [`819ae90`](https://github.com/modelcontextprotocol/servers/commit/819ae904cc08ab0b208ccb91addc3169b3ece85b) Thanks [@cliffhall](https://github.com/cliffhall)! - Saving the knowledge graph keeps the memory file's permission bits (an operator's `0600` no longer comes back `0644`), and a write to a read-only memory file now fails with `EACCES` instead of silently replacing it ([#4827](https://github.com/modelcontextprotocol/servers/issues/4827)). +- [#5013](https://github.com/modelcontextprotocol/servers/pull/5013) [`819ae90`](https://github.com/modelcontextprotocol/servers/commit/819ae904cc08ab0b208ccb91addc3169b3ece85b) [@cliffhall](https://github.com/cliffhall) - Saving the knowledge graph keeps the memory file's permission bits (an operator's `0600` no longer comes back `0644`), and a write to a read-only memory file now fails with `EACCES` instead of silently replacing it ([#4827](https://github.com/modelcontextprotocol/servers/issues/4827)). diff --git a/src/sequentialthinking/CHANGELOG.md b/src/sequentialthinking/CHANGELOG.md index 352bd55a4b..ed28422843 100644 --- a/src/sequentialthinking/CHANGELOG.md +++ b/src/sequentialthinking/CHANGELOG.md @@ -6,16 +6,16 @@ The first semantic version, resuming semver after the date-stamped releases (las ### Major Changes -- [#5063](https://github.com/modelcontextprotocol/servers/pull/5063) [`b5af226`](https://github.com/modelcontextprotocol/servers/commit/b5af22668592700c02236d737f9836c42654edb6) Thanks [@cliffhall](https://github.com/cliffhall)! - Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`) from `@modelcontextprotocol/sdk` 1.x ([#4856](https://github.com/modelcontextprotocol/servers/issues/4856)). Breaking for clients: the server now requires Node.js 20 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); tool schemas in `tools/list` declare JSON Schema 2020-12 instead of draft-07; and tools no longer advertise `execution.taskSupport`. +- [#5063](https://github.com/modelcontextprotocol/servers/pull/5063) [`b5af226`](https://github.com/modelcontextprotocol/servers/commit/b5af22668592700c02236d737f9836c42654edb6) [@cliffhall](https://github.com/cliffhall) - Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`) from `@modelcontextprotocol/sdk` 1.x ([#4856](https://github.com/modelcontextprotocol/servers/issues/4856)). Breaking for clients: the server now requires Node.js 20 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); tool schemas in `tools/list` declare JSON Schema 2020-12 instead of draft-07; and tools no longer advertise `execution.taskSupport`. ### Patch Changes -- [#5015](https://github.com/modelcontextprotocol/servers/pull/5015) [`8a4139c`](https://github.com/modelcontextprotocol/servers/commit/8a4139c3d71b66e95d2f7cd2bc485d4781ecf1d7) Thanks [@cliffhall](https://github.com/cliffhall)! - Advertise the `sequentialthinking` tool as `readOnlyHint: false` and `idempotentHint: false`. Every call appends to the server's thought history, and a call with both `branchFromThought` and `branchId` also appends to that branch, so the tool was never read-only or idempotent. `destructiveHint` and `openWorldHint` stay `false`. ([#4721](https://github.com/modelcontextprotocol/servers/issues/4721)) +- [#5015](https://github.com/modelcontextprotocol/servers/pull/5015) [`8a4139c`](https://github.com/modelcontextprotocol/servers/commit/8a4139c3d71b66e95d2f7cd2bc485d4781ecf1d7) [@cliffhall](https://github.com/cliffhall) - Advertise the `sequentialthinking` tool as `readOnlyHint: false` and `idempotentHint: false`. Every call appends to the server's thought history, and a call with both `branchFromThought` and `branchId` also appends to that branch, so the tool was never read-only or idempotent. `destructiveHint` and `openWorldHint` stay `false`. ([#4721](https://github.com/modelcontextprotocol/servers/issues/4721)) -- [#4970](https://github.com/modelcontextprotocol/servers/pull/4970) [`86806cc`](https://github.com/modelcontextprotocol/servers/commit/86806cc3870a112f56870e86cd36bae8239edb02) Thanks [@cliffhall](https://github.com/cliffhall)! - Build the server with an exported `createServer()` factory and attach stdio only when `index.js` runs as the binary, so the server can be tested in-process. No change to the tool, its schemas or its results. +- [#4970](https://github.com/modelcontextprotocol/servers/pull/4970) [`86806cc`](https://github.com/modelcontextprotocol/servers/commit/86806cc3870a112f56870e86cd36bae8239edb02) [@cliffhall](https://github.com/cliffhall) - Build the server with an exported `createServer()` factory and attach stdio only when `index.js` runs as the binary, so the server can be tested in-process. No change to the tool, its schemas or its results. -- [#5031](https://github.com/modelcontextprotocol/servers/pull/5031) [`bc52d0e`](https://github.com/modelcontextprotocol/servers/commit/bc52d0e07a1d9dca505746165b30f4c99f8cc79b) Thanks [@cliffhall](https://github.com/cliffhall)! - The thought log on stderr no longer prints "undefined" in its headers: a revision with no `revisesThought` reads "Revision N/M", and a branch with no `branchId` reads "(from thought N)" with no ID. The box border is sized from the header's visible text, so colour escape codes no longer make it wider than its text. +- [#5031](https://github.com/modelcontextprotocol/servers/pull/5031) [`bc52d0e`](https://github.com/modelcontextprotocol/servers/commit/bc52d0e07a1d9dca505746165b30f4c99f8cc79b) [@cliffhall](https://github.com/cliffhall) - The thought log on stderr no longer prints "undefined" in its headers: a revision with no `revisesThought` reads "Revision N/M", and a branch with no `branchId` reads "(from thought N)" with no ID. The box border is sized from the header's visible text, so colour escape codes no longer make it wider than its text. -- [#5053](https://github.com/modelcontextprotocol/servers/pull/5053) [`c63255a`](https://github.com/modelcontextprotocol/servers/commit/c63255ae9616b110d6089fa343fcfdf3f5def534) Thanks [@cliffhall](https://github.com/cliffhall)! - Stop shipping the compiled test helper `dist/__tests__/helpers.js` in the published package: the build now excludes everything under `__tests__/`, not just `*.test.ts`. +- [#5053](https://github.com/modelcontextprotocol/servers/pull/5053) [`c63255a`](https://github.com/modelcontextprotocol/servers/commit/c63255ae9616b110d6089fa343fcfdf3f5def534) [@cliffhall](https://github.com/cliffhall) - Stop shipping the compiled test helper `dist/__tests__/helpers.js` in the published package: the build now excludes everything under `__tests__/`, not just `*.test.ts`. -- [#5036](https://github.com/modelcontextprotocol/servers/pull/5036) [`b5c5ccc`](https://github.com/modelcontextprotocol/servers/commit/b5c5cccb79a5d43b41247065fb9efd174363e799) Thanks [@cliffhall](https://github.com/cliffhall)! - A `branchId` that names an `Object.prototype` key, such as `"constructor"` or `"__proto__"`, now creates and lists its branch like any other id instead of failing the call. A call that fails no longer adds its thought to the history first, so `thoughtHistoryLength` counts only thoughts that were accepted. +- [#5036](https://github.com/modelcontextprotocol/servers/pull/5036) [`b5c5ccc`](https://github.com/modelcontextprotocol/servers/commit/b5c5cccb79a5d43b41247065fb9efd174363e799) [@cliffhall](https://github.com/cliffhall) - A `branchId` that names an `Object.prototype` key, such as `"constructor"` or `"__proto__"`, now creates and lists its branch like any other id instead of failing the call. A call that fails no longer adds its thought to the history first, so `thoughtHistoryLength` counts only thoughts that were accepted. From 3423e578031c554e9aefa4d648e0d05d6f0be917 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 10 Oct 2026 01:22:24 -0400 Subject: [PATCH 048/117] chore: stamp Python CalVer versions for 2026.10.10 Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/fetch/pyproject.toml | 2 +- src/fetch/uv.lock | 2 +- src/git/pyproject.toml | 2 +- src/git/uv.lock | 2 +- src/time/pyproject.toml | 2 +- src/time/uv.lock | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/fetch/pyproject.toml b/src/fetch/pyproject.toml index 2f596c278a..c629084d5a 100644 --- a/src/fetch/pyproject.toml +++ b/src/fetch/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcp-server-fetch" -version = "0.6.3" +version = "2026.10.10" description = "A Model Context Protocol server providing tools to fetch and convert web content for usage by LLMs" readme = "README.md" requires-python = ">=3.10" diff --git a/src/fetch/uv.lock b/src/fetch/uv.lock index 2b9d970293..771fcb39f4 100644 --- a/src/fetch/uv.lock +++ b/src/fetch/uv.lock @@ -745,7 +745,7 @@ wheels = [ [[package]] name = "mcp-server-fetch" -version = "0.6.3" +version = "2026.10.10" source = { editable = "." } dependencies = [ { name = "httpx", extra = ["socks"] }, diff --git a/src/git/pyproject.toml b/src/git/pyproject.toml index d9fda96b55..7036291e80 100644 --- a/src/git/pyproject.toml +++ b/src/git/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcp-server-git" -version = "0.6.2" +version = "2026.10.10" description = "A Model Context Protocol server providing tools to read, search, and manipulate Git repositories programmatically via LLMs" readme = "README.md" requires-python = ">=3.10" diff --git a/src/git/uv.lock b/src/git/uv.lock index 39bec35e34..0ed114edd3 100644 --- a/src/git/uv.lock +++ b/src/git/uv.lock @@ -509,7 +509,7 @@ wheels = [ [[package]] name = "mcp-server-git" -version = "0.6.2" +version = "2026.10.10" source = { editable = "." } dependencies = [ { name = "click" }, diff --git a/src/time/pyproject.toml b/src/time/pyproject.toml index 014dbf629e..d37e27724e 100644 --- a/src/time/pyproject.toml +++ b/src/time/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcp-server-time" -version = "0.6.2" +version = "2026.10.10" description = "A Model Context Protocol server providing tools for time queries and timezone conversions for LLMs" readme = "README.md" requires-python = ">=3.10" diff --git a/src/time/uv.lock b/src/time/uv.lock index fdb92e13d1..16584d44cd 100644 --- a/src/time/uv.lock +++ b/src/time/uv.lock @@ -497,7 +497,7 @@ wheels = [ [[package]] name = "mcp-server-time" -version = "0.6.2" +version = "2026.10.10" source = { editable = "." } dependencies = [ { name = "jsonschema" }, From 211aa1ee36f6b8f003557db58b73810bde94c395 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 10 Oct 2026 02:34:26 -0400 Subject: [PATCH 049/117] docs(release): warn against Update branch on the milestone merge PR GitHub always offers Update branch on the merge PR, since main holds commits the merge branch lacks. Clicking it adds a merge of main to the branch, so the PR is no longer a pure merge. Says so next to the merge-commit warning, with the recovery. Closes #5092 Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .claude/skills/release/SKILL.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 0b07c4b407..0a0bde684f 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -258,6 +258,20 @@ squash writes one new commit that `v2/main` does not contain, so the two branches stop sharing history and the next milestone merge conflicts with itself. +⚠️ **Do not click "Update branch" on the merge PR.** GitHub always offers it +here, because `main` always holds commits the merge branch lacks (its own +earlier merge commits), so it does not mean the PR is out of date. Clicking it +merges `main` into the merge branch: the PR gains a commit of its own, `main` +receives two merge commits, and the branch head is no longer the commit the +ledger names. If it was clicked, put the branch back on the commit it was cut +from, then repeat the `git merge-tree` check above: + +```sh +git fetch origin +git push --force-with-lease="v2/chore/$N-release-$MILESTONE" origin \ + "origin/v2/main:refs/heads/v2/chore/$N-release-$MILESTONE" +``` + ⚠️ **Never merge `main` back into `v2/main`** to "sync" them. Everything reaches `main` through `v2/main`, so there is nothing on `main` to bring back except the merge commits themselves. `v2/main` ahead of `main` means a release From aa7fa785c7c746e2fc52de6ffeb988847d38d93e Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 10 Oct 2026 02:40:03 -0400 Subject: [PATCH 050/117] docs(release): reset to the update merge's first parent, not origin/v2/main origin/v2/main may have moved past the commit the ledger verified; the update merge's first parent is the branch head from before the click. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .claude/skills/release/SKILL.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 0a0bde684f..0a8ffb8a07 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -263,13 +263,17 @@ here, because `main` always holds commits the merge branch lacks (its own earlier merge commits), so it does not mean the PR is out of date. Clicking it merges `main` into the merge branch: the PR gains a commit of its own, `main` receives two merge commits, and the branch head is no longer the commit the -ledger names. If it was clicked, put the branch back on the commit it was cut -from, then repeat the `git merge-tree` check above: +ledger names. If it was clicked, put the branch back on its head from before +the click: the update merge's **first parent**, which is the commit the ledger +verified. Not `origin/v2/main`, which may have moved on to a commit nothing has +verified. Then repeat the `git merge-tree` check above: ```sh +B="v2/chore/$N-release-$MILESTONE" git fetch origin -git push --force-with-lease="v2/chore/$N-release-$MILESTONE" origin \ - "origin/v2/main:refs/heads/v2/chore/$N-release-$MILESTONE" +UPDATE=$(git rev-parse "origin/$B") +git log -1 --format='%s' "$UPDATE" # must be "Merge branch 'main' into …" +git push --force-with-lease="$B:$UPDATE" origin "$UPDATE^1:refs/heads/$B" ``` ⚠️ **Never merge `main` back into `v2/main`** to "sync" them. Everything From 5ea217b57e042714c6cb29bb03502ce1c6d638c4 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 10 Oct 2026 02:56:35 -0400 Subject: [PATCH 051/117] fix(filesystem): revoke access when a roots update leaves no valid root Once the client's roots are in force, a roots/list_changed update with no valid roots now clears the allow-list instead of keeping the previous directories. Before any root has been in force, such an update keeps the command-line directories, as before. An empty allow-list now says so in its access-denied error. Closes #5094 Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .changeset/filesystem-revoke-empty-roots.md | 5 + src/filesystem/README.md | 1 + src/filesystem/__tests__/server-paths.test.ts | 2 +- src/filesystem/__tests__/server-roots.test.ts | 94 +++++++++++++++++++ src/filesystem/lib.ts | 4 +- src/filesystem/server.ts | 11 +++ 6 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 .changeset/filesystem-revoke-empty-roots.md diff --git a/.changeset/filesystem-revoke-empty-roots.md b/.changeset/filesystem-revoke-empty-roots.md new file mode 100644 index 0000000000..f563e3373a --- /dev/null +++ b/.changeset/filesystem-revoke-empty-roots.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/server-filesystem": patch +--- + +A `roots/list_changed` update that leaves no valid root now revokes access once the client's roots are in force, instead of keeping the previous allowed directories, so a client that withdraws its roots withdraws the server's access too (#5094). Every path is then refused with "Access denied - no allowed directories" until the client exposes a root again. Before any root has been in force, such an update still keeps the command-line directories. diff --git a/src/filesystem/README.md b/src/filesystem/README.md index 8b705087dc..056449ac86 100644 --- a/src/filesystem/README.md +++ b/src/filesystem/README.md @@ -51,6 +51,7 @@ The server's directory access control follows this flow: - Tool calls that arrive before the initial roots are loaded wait for them, so they are checked against the client's roots - **On runtime updates**: Client can send `notifications/roots/list_changed` - Server requests updated roots and replaces allowed directories again + - Once the client's roots are in force, an update with no valid roots revokes access: every path is refused ("Access denied - no allowed directories") until the client exposes a root again. Before any root has been in force, such an update keeps the command-line directories 4. **Fallback Behavior** (if client doesn't support roots) - Server continues using command-line directories only diff --git a/src/filesystem/__tests__/server-paths.test.ts b/src/filesystem/__tests__/server-paths.test.ts index 869991de8b..e382aaacf6 100644 --- a/src/filesystem/__tests__/server-paths.test.ts +++ b/src/filesystem/__tests__/server-paths.test.ts @@ -164,7 +164,7 @@ describe("relative paths", () => { path: "anything.txt", }); expect(textOf(result)).toBe( - `Access denied - path outside allowed directories: ${path.resolve(process.cwd(), "anything.txt")} not in `, + `Access denied - no allowed directories: the client's roots expose none (${path.resolve(process.cwd(), "anything.txt")})`, ); } finally { await empty.close(); diff --git a/src/filesystem/__tests__/server-roots.test.ts b/src/filesystem/__tests__/server-roots.test.ts index a35d81788c..124732dcea 100644 --- a/src/filesystem/__tests__/server-roots.test.ts +++ b/src/filesystem/__tests__/server-roots.test.ts @@ -251,6 +251,100 @@ describe("roots/list_changed", () => { ); }); + // #5094: once the client's roots are in force, an update that leaves no + // valid root revokes access instead of keeping the previous directories. + it("revokes access when the client withdraws its last root (#5094)", async () => { + let roots = [rootOf(rootDir)]; + const { client } = await connectTracked([cliDir], { + capabilities: ROOTS, + listRoots: () => roots, + }); + await vi.waitFor(async () => + expect(await allowedDirectoriesOf(client)).toEqual([rootDir]), + ); + + roots = []; + await client.sendRootsListChanged(); + await vi.waitFor(async () => + expect(await allowedDirectoriesOf(client)).toEqual([]), + ); + expect(stderr).toHaveBeenCalledWith( + "No valid root directories provided by client; access revoked until it exposes a root again", + ); + const result = await call(client, "list_directory", { path: rootDir }); + expect(result.isError).toBe(true); + expect(textOf(result)).toMatch( + /^Access denied - no allowed directories: the client's roots expose none/, + ); + // The command-line directories do not come back either. + expect( + textOf(await call(client, "list_directory", { path: cliDir })), + ).toMatch(/^Access denied/); + }); + + it("revokes access when every remaining root is invalid (#5094)", async () => { + let roots = [rootOf(rootDir)]; + const { client } = await connectTracked([], { + capabilities: ROOTS, + listRoots: () => roots, + }); + await vi.waitFor(async () => + expect(await allowedDirectoriesOf(client)).toEqual([rootDir]), + ); + + roots = [rootOf(path.join(rootDir, "gone"))]; + await client.sendRootsListChanged(); + await vi.waitFor(async () => + expect(await allowedDirectoriesOf(client)).toEqual([]), + ); + }); + + it("restores access when the client exposes a root again (#5094)", async () => { + let roots = [rootOf(rootDir)]; + const { client } = await connectTracked([], { + capabilities: ROOTS, + listRoots: () => roots, + }); + await vi.waitFor(async () => + expect(await allowedDirectoriesOf(client)).toEqual([rootDir]), + ); + roots = []; + await client.sendRootsListChanged(); + await vi.waitFor(async () => + expect(await allowedDirectoriesOf(client)).toEqual([]), + ); + + roots = [rootOf(rootDir)]; + await client.sendRootsListChanged(); + await vi.waitFor(async () => + expect(await allowedDirectoriesOf(client)).toEqual([rootDir]), + ); + }); + + it("keeps the command-line directories on an empty update before any root was in force", async () => { + let roots: Root[] = []; + const { client } = await connectTracked([cliDir], { + capabilities: ROOTS, + listRoots: () => roots, + }); + await vi.waitFor(() => + expect(stderr).toHaveBeenCalledWith( + "No valid root directories provided by client", + ), + ); + + roots = []; + await client.sendRootsListChanged(); + await vi.waitFor(() => + expect( + stderr.mock.calls.filter( + (c) => c[0] === "No valid root directories provided by client", + ), + ).toHaveLength(2), + ); + expect(await allowedDirectoriesOf(client)).toEqual([cliDir]); + }); + it("logs and keeps the current directories when the re-fetch fails", async () => { let fail = false; const { client } = await connectTracked([], { diff --git a/src/filesystem/lib.ts b/src/filesystem/lib.ts index e50a67624a..4eb1390ea1 100644 --- a/src/filesystem/lib.ts +++ b/src/filesystem/lib.ts @@ -233,7 +233,9 @@ export async function validatePath( ); if (!isAllowed) { throw new Error( - `Access denied - path outside allowed directories: ${absolute} not in ${allowedDirectories.join(", ")}`, + allowedDirectories.length === 0 + ? `Access denied - no allowed directories: the client's roots expose none (${absolute})` + : `Access denied - path outside allowed directories: ${absolute} not in ${allowedDirectories.join(", ")}`, ); } diff --git a/src/filesystem/server.ts b/src/filesystem/server.ts index a5aadf2e07..dbb955da5f 100644 --- a/src/filesystem/server.ts +++ b/src/filesystem/server.ts @@ -169,6 +169,11 @@ export function createServer( options: ServerOptions = {}, ): McpServer { let allowedDirectories = [...initialAllowedDirectories]; + // True once the client's roots have replaced the command-line directories. + // From then on a roots update replaces them even with none, so a client that + // withdraws its roots withdraws the server's access too (#5094). Until then an + // update with no valid roots keeps the command-line directories. + let rootsInForce = false; const server = new McpServer({ name: "secure-filesystem-server", @@ -760,9 +765,15 @@ export function createServer( const validatedRootDirs = await getValidRootDirectories(requestedRoots); if (validatedRootDirs.length > 0) { allowedDirectories = [...validatedRootDirs]; + rootsInForce = true; console.error( `Updated allowed directories from MCP roots: ${validatedRootDirs.length} valid directories`, ); + } else if (rootsInForce) { + allowedDirectories = []; + console.error( + "No valid root directories provided by client; access revoked until it exposes a root again", + ); } else { console.error("No valid root directories provided by client"); } From de49bdd27bb85ad889ed43d3b67aafb5deaca4d6 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 10 Oct 2026 03:02:18 -0400 Subject: [PATCH 052/117] fix(filesystem): say the client exposes no valid roots, not none An update whose roots are all invalid still lists roots; the empty-allow-list message now says none of them is valid (Copilot on #5095). Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/filesystem/__tests__/server-paths.test.ts | 2 +- src/filesystem/__tests__/server-roots.test.ts | 2 +- src/filesystem/lib.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/filesystem/__tests__/server-paths.test.ts b/src/filesystem/__tests__/server-paths.test.ts index e382aaacf6..d6a8995317 100644 --- a/src/filesystem/__tests__/server-paths.test.ts +++ b/src/filesystem/__tests__/server-paths.test.ts @@ -164,7 +164,7 @@ describe("relative paths", () => { path: "anything.txt", }); expect(textOf(result)).toBe( - `Access denied - no allowed directories: the client's roots expose none (${path.resolve(process.cwd(), "anything.txt")})`, + `Access denied - no allowed directories: the client exposes no valid roots (${path.resolve(process.cwd(), "anything.txt")})`, ); } finally { await empty.close(); diff --git a/src/filesystem/__tests__/server-roots.test.ts b/src/filesystem/__tests__/server-roots.test.ts index 124732dcea..7c8aea63d9 100644 --- a/src/filesystem/__tests__/server-roots.test.ts +++ b/src/filesystem/__tests__/server-roots.test.ts @@ -274,7 +274,7 @@ describe("roots/list_changed", () => { const result = await call(client, "list_directory", { path: rootDir }); expect(result.isError).toBe(true); expect(textOf(result)).toMatch( - /^Access denied - no allowed directories: the client's roots expose none/, + /^Access denied - no allowed directories: the client exposes no valid roots/, ); // The command-line directories do not come back either. expect( diff --git a/src/filesystem/lib.ts b/src/filesystem/lib.ts index 4eb1390ea1..abb6b09cd4 100644 --- a/src/filesystem/lib.ts +++ b/src/filesystem/lib.ts @@ -234,7 +234,7 @@ export async function validatePath( if (!isAllowed) { throw new Error( allowedDirectories.length === 0 - ? `Access denied - no allowed directories: the client's roots expose none (${absolute})` + ? `Access denied - no allowed directories: the client exposes no valid roots (${absolute})` : `Access denied - path outside allowed directories: ${absolute} not in ${allowedDirectories.join(", ")}`, ); } From 423fe6b7fca01b186443b5e21bc7440fe431bb64 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 10 Oct 2026 03:10:27 -0400 Subject: [PATCH 053/117] fix(filesystem): apply only the newest roots refresh's answer Each roots refresh (the initial load and every roots/list_changed) takes a generation before it asks for roots; its answer is applied only if no newer refresh has started, checked after both roots/list and validation. A stale answer is logged and discarded, so it cannot undo a newer update such as a revocation. Closes #5097 Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .changeset/filesystem-roots-refresh-order.md | 5 ++ src/filesystem/__tests__/server-roots.test.ts | 72 +++++++++++++++++++ src/filesystem/server.ts | 26 +++++-- 3 files changed, 99 insertions(+), 4 deletions(-) create mode 100644 .changeset/filesystem-roots-refresh-order.md diff --git a/.changeset/filesystem-roots-refresh-order.md b/.changeset/filesystem-roots-refresh-order.md new file mode 100644 index 0000000000..f1cf2f7c42 --- /dev/null +++ b/.changeset/filesystem-roots-refresh-order.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/server-filesystem": patch +--- + +Overlapping roots refreshes now take effect in the order they started: when a client sends `roots/list_changed` again before the previous `roots/list` answer arrived (or while the initial roots are still loading), an answer that arrives after a newer refresh has started is discarded instead of overwriting it, so a stale answer can no longer undo a newer update such as a revocation (#5097). diff --git a/src/filesystem/__tests__/server-roots.test.ts b/src/filesystem/__tests__/server-roots.test.ts index 7c8aea63d9..8e100551dd 100644 --- a/src/filesystem/__tests__/server-roots.test.ts +++ b/src/filesystem/__tests__/server-roots.test.ts @@ -345,6 +345,78 @@ describe("roots/list_changed", () => { expect(await allowedDirectoriesOf(client)).toEqual([cliDir]); }); + // #5097: the answers to overlapping refreshes can arrive out of order; only + // the newest refresh's answer is applied, so a stale one cannot undo it. + it("applies the newest of two overlapping updates when their answers arrive out of order (#5097)", async () => { + let calls = 0; + let releaseStale!: () => void; + const staleHeld = new Promise((resolve) => (releaseStale = resolve)); + const { client } = await connectTracked([], { + capabilities: ROOTS, + listRoots: async () => { + calls += 1; + if (calls === 1) return [rootOf(rootDir)]; // the initial load + if (calls === 2) { + await staleHeld; // the older update, answered last + return [rootOf(rootDir)]; + } + return []; // the newer update withdraws the root + }, + }); + await vi.waitFor(async () => + expect(await allowedDirectoriesOf(client)).toEqual([rootDir]), + ); + + await client.sendRootsListChanged(); + await vi.waitFor(() => expect(calls).toBe(2)); + await client.sendRootsListChanged(); + await vi.waitFor(async () => + expect(await allowedDirectoriesOf(client)).toEqual([]), + ); + + releaseStale(); + await vi.waitFor(() => + expect(stderr).toHaveBeenCalledWith( + "Discarded a stale roots/list answer: a newer roots update has started", + ), + ); + expect(await allowedDirectoriesOf(client)).toEqual([]); + }); + + it("discards the initial answer when an update overtakes it (#5097)", async () => { + let calls = 0; + let releaseInitial!: () => void; + const initialHeld = new Promise( + (resolve) => (releaseInitial = resolve), + ); + const { client } = await connectTracked([], { + capabilities: ROOTS, + listRoots: async () => { + calls += 1; + if (calls === 1) { + await initialHeld; + return [rootOf(rootDir)]; + } + return [rootOf(cliDir)]; + }, + }); + await vi.waitFor(() => expect(calls).toBe(1)); + await client.sendRootsListChanged(); + await vi.waitFor(() => + expect(stderr).toHaveBeenCalledWith( + "Updated allowed directories from MCP roots: 1 valid directories", + ), + ); + + releaseInitial(); + await vi.waitFor(() => + expect(stderr).toHaveBeenCalledWith( + "Discarded a stale roots/list answer: a newer roots update has started", + ), + ); + expect(await allowedDirectoriesOf(client)).toEqual([cliDir]); + }); + it("logs and keeps the current directories when the re-fetch fails", async () => { let fail = false; const { client } = await connectTracked([], { diff --git a/src/filesystem/server.ts b/src/filesystem/server.ts index dbb955da5f..0dc851819d 100644 --- a/src/filesystem/server.ts +++ b/src/filesystem/server.ts @@ -174,6 +174,12 @@ export function createServer( // withdraws its roots withdraws the server's access too (#5094). Until then an // update with no valid roots keeps the command-line directories. let rootsInForce = false; + // Each roots refresh (the initial load and every roots/list_changed) takes + // the next generation before it asks for roots. The SDK runs notification + // handlers without waiting for earlier ones, so answers can come back out of + // order; one is applied only if no newer refresh has started since, so a + // stale answer cannot undo a newer update, such as a revocation (#5097). + let rootsGeneration = 0; const server = new McpServer({ name: "secure-filesystem-server", @@ -760,9 +766,19 @@ export function createServer( }, ); - // Updates allowed directories based on MCP client roots - async function updateAllowedDirectoriesFromRoots(requestedRoots: Root[]) { + // Updates allowed directories based on MCP client roots, unless a newer + // refresh started after this one's `generation` was taken (#5097). + async function updateAllowedDirectoriesFromRoots( + requestedRoots: Root[], + generation: number, + ) { const validatedRootDirs = await getValidRootDirectories(requestedRoots); + if (generation !== rootsGeneration) { + console.error( + "Discarded a stale roots/list answer: a newer roots update has started", + ); + return; + } if (validatedRootDirs.length > 0) { allowedDirectories = [...validatedRootDirs]; rootsInForce = true; @@ -783,12 +799,13 @@ export function createServer( server.server.setNotificationHandler( "notifications/roots/list_changed", async () => { + const generation = ++rootsGeneration; try { // Request the updated roots list from the client const response = await server.server.listRoots(); /* v8 ignore else -- the SDK validates the roots/list result against ListRootsResultSchema, which requires roots, so the implicit else cannot run */ if (response && "roots" in response) { - await updateAllowedDirectoriesFromRoots(response.roots); + await updateAllowedDirectoriesFromRoots(response.roots, generation); } } catch (error) { console.error( @@ -809,11 +826,12 @@ export function createServer( const clientCapabilities = server.server.getClientCapabilities(); if (clientCapabilities?.roots) { + const generation = ++rootsGeneration; try { const response = await server.server.listRoots(); /* v8 ignore else -- the SDK validates the roots/list result against ListRootsResultSchema, which requires roots, so the else cannot run */ if (response && "roots" in response) { - await updateAllowedDirectoriesFromRoots(response.roots); + await updateAllowedDirectoriesFromRoots(response.roots, generation); } else { console.error( "Client returned no roots set, keeping current settings", From 564e84d6ae05061ad269cb26511bdd2899e73224 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 10 Oct 2026 03:19:13 -0400 Subject: [PATCH 054/117] fix(filesystem): keep tool calls waiting for the update that overtook the initial roots When a roots/list_changed overtakes the initial roots/list, the initial refresh's stale answer is discarded, but it used to release the tool calls waiting on the initial load, which then ran against the command-line directories before the newer roots applied. The overtaking refresh now replaces the initialization gate while the initial load is pending, and waiting calls follow it until it settles (Copilot on #5098). Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/filesystem/__tests__/server-roots.test.ts | 77 +++++++++++++++++++ src/filesystem/server.ts | 46 +++++++---- 2 files changed, 110 insertions(+), 13 deletions(-) diff --git a/src/filesystem/__tests__/server-roots.test.ts b/src/filesystem/__tests__/server-roots.test.ts index 8e100551dd..4a36b5308a 100644 --- a/src/filesystem/__tests__/server-roots.test.ts +++ b/src/filesystem/__tests__/server-roots.test.ts @@ -417,6 +417,83 @@ describe("roots/list_changed", () => { expect(await allowedDirectoriesOf(client)).toEqual([cliDir]); }); + // #5097: a call waiting for the initial roots must not be released by the + // stale initial answer; it waits for the update that overtook it. + it("holds waiting tool calls until the update that overtook the initial load applies (#5097)", async () => { + let calls = 0; + let releaseInitial!: () => void; + let releaseUpdate!: () => void; + const initialHeld = new Promise( + (resolve) => (releaseInitial = resolve), + ); + const updateHeld = new Promise( + (resolve) => (releaseUpdate = resolve), + ); + const { client } = await connectTracked([cliDir], { + capabilities: ROOTS, + listRoots: async () => { + calls += 1; + await (calls === 1 ? initialHeld : updateHeld); + return [rootOf(rootDir)]; + }, + }); + await vi.waitFor(() => expect(calls).toBe(1)); + await client.sendRootsListChanged(); + await vi.waitFor(() => expect(calls).toBe(2)); + + releaseInitial(); + await vi.waitFor(() => + expect(stderr).toHaveBeenCalledWith( + "Discarded a stale roots/list answer: a newer roots update has started", + ), + ); + // Asked before the update has answered: it must not run against the + // command-line directory in the meantime. + let settled = false; + const pending = call(client, "list_directory", { path: cliDir }).then( + (result) => { + settled = true; + return result; + }, + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(settled).toBe(false); + + releaseUpdate(); + expect(textOf(await pending)).toMatch(/^Access denied/); + expect(await allowedDirectoriesOf(client)).toEqual([rootDir]); + }); + + it("releases waiting tool calls when the update that overtook the initial load fails (#5097)", async () => { + let calls = 0; + let releaseInitial!: () => void; + const initialHeld = new Promise( + (resolve) => (releaseInitial = resolve), + ); + const { client } = await connectTracked([cliDir], { + capabilities: ROOTS, + listRoots: async () => { + calls += 1; + if (calls === 1) { + await initialHeld; + return [rootOf(rootDir)]; + } + throw new Error("gone"); + }, + }); + await vi.waitFor(() => expect(calls).toBe(1)); + await client.sendRootsListChanged(); + await vi.waitFor(() => + expect(stderr).toHaveBeenCalledWith( + "Failed to request roots from client:", + "gone", + ), + ); + + releaseInitial(); + expect(await allowedDirectoriesOf(client)).toEqual([cliDir]); + }); + it("logs and keeps the current directories when the re-fetch fails", async () => { let fail = false; const { client } = await connectTracked([], { diff --git a/src/filesystem/server.ts b/src/filesystem/server.ts index 0dc851819d..7c64f4f4f5 100644 --- a/src/filesystem/server.ts +++ b/src/filesystem/server.ts @@ -193,6 +193,12 @@ export function createServer( // command-line directories (#3204). It is replaced in oninitialized, which // the SDK runs before any request sent after notifications/initialized. let initialization: Promise = Promise.resolve(); + // True from the initial roots/list until a refresh's answer has been + // applied or the newest refresh has failed. A roots/list_changed that + // overtakes the initial load while this holds replaces `initialization`, so + // waiting tool calls keep waiting for the refresh that will actually apply + // rather than running against the command-line directories (#5097). + let initialRootsPending = false; const registerTool = server.registerTool.bind(server); server.registerTool = (( name: string, @@ -200,7 +206,11 @@ export function createServer( handler: (...args: unknown[]) => unknown, ) => registerTool(name, config, (async (...args: unknown[]) => { - await initialization; + let awaited: Promise; + do { + awaited = initialization; + await awaited; + } while (awaited !== initialization); return handler(...args); }) as never)) as typeof server.registerTool; @@ -800,19 +810,26 @@ export function createServer( "notifications/roots/list_changed", async () => { const generation = ++rootsGeneration; - try { - // Request the updated roots list from the client - const response = await server.server.listRoots(); - /* v8 ignore else -- the SDK validates the roots/list result against ListRootsResultSchema, which requires roots, so the implicit else cannot run */ - if (response && "roots" in response) { - await updateAllowedDirectoriesFromRoots(response.roots, generation); + const refresh = (async () => { + try { + // Request the updated roots list from the client + const response = await server.server.listRoots(); + /* v8 ignore else -- the SDK validates the roots/list result against ListRootsResultSchema, which requires roots, so the implicit else cannot run */ + if (response && "roots" in response) { + await updateAllowedDirectoriesFromRoots(response.roots, generation); + } + } catch (error) { + console.error( + "Failed to request roots from client:", + error instanceof Error ? error.message : String(error), + ); + } finally { + if (generation === rootsGeneration) initialRootsPending = false; } - } catch (error) { - console.error( - "Failed to request roots from client:", - error instanceof Error ? error.message : String(error), - ); - } + })(); + // Overtaking the initial load: tool calls wait for this refresh instead. + if (initialRootsPending) initialization = refresh; + await refresh; }, ); @@ -827,6 +844,7 @@ export function createServer( if (clientCapabilities?.roots) { const generation = ++rootsGeneration; + initialRootsPending = true; try { const response = await server.server.listRoots(); /* v8 ignore else -- the SDK validates the roots/list result against ListRootsResultSchema, which requires roots, so the else cannot run */ @@ -842,6 +860,8 @@ export function createServer( "Failed to request initial roots from client:", error instanceof Error ? error.message : String(error), ); + } finally { + if (generation === rootsGeneration) initialRootsPending = false; } } else { if (allowedDirectories.length > 0) { From 11394a40763543e9a8c2b3c21709bdbf61dc4d28 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 10 Oct 2026 03:56:52 -0400 Subject: [PATCH 055/117] test(filesystem): assert waiting calls are released before the initial answer arrives The failed-overtaking-refresh test released the held initial roots/list before asserting, so it would also pass if callers stayed blocked on it. Assert first, then release for cleanup (Copilot on #5098). Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/filesystem/__tests__/server-roots.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/filesystem/__tests__/server-roots.test.ts b/src/filesystem/__tests__/server-roots.test.ts index 4a36b5308a..30e2236d2d 100644 --- a/src/filesystem/__tests__/server-roots.test.ts +++ b/src/filesystem/__tests__/server-roots.test.ts @@ -490,8 +490,10 @@ describe("roots/list_changed", () => { ), ); - releaseInitial(); + // Asked while the initial roots/list is still unanswered: the failed update + // releases waiting calls instead of leaving them blocked on it. expect(await allowedDirectoriesOf(client)).toEqual([cliDir]); + releaseInitial(); }); it("logs and keeps the current directories when the re-fetch fails", async () => { From fc5e9c921c73dc3e007a7f960cd84e9496b304fc Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 10 Oct 2026 04:06:02 -0400 Subject: [PATCH 056/117] test(filesystem): wait for the stale initial answer before teardown The failed-overtaking-refresh test returned right after releasing the held initial roots/list, so its answer handling and root validation could race afterEach's teardown. Wait for the stale answer to be discarded first (Copilot on #5099). Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/filesystem/__tests__/server-roots.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/filesystem/__tests__/server-roots.test.ts b/src/filesystem/__tests__/server-roots.test.ts index 30e2236d2d..55f1fc5f3c 100644 --- a/src/filesystem/__tests__/server-roots.test.ts +++ b/src/filesystem/__tests__/server-roots.test.ts @@ -493,7 +493,13 @@ describe("roots/list_changed", () => { // Asked while the initial roots/list is still unanswered: the failed update // releases waiting calls instead of leaving them blocked on it. expect(await allowedDirectoriesOf(client)).toEqual([cliDir]); + // Let the stale initial answer finish before teardown removes its roots. releaseInitial(); + await vi.waitFor(() => + expect(stderr).toHaveBeenCalledWith( + "Discarded a stale roots/list answer: a newer roots update has started", + ), + ); }); it("logs and keeps the current directories when the re-fetch fails", async () => { From 92f9558fbf0dbd0f1fb67d223a02c3ad5c3ad6bf Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 10 Oct 2026 04:24:52 -0400 Subject: [PATCH 057/117] fix(filesystem): hold tool calls while any roots refresh is pending A runtime roots/list_changed refresh now replaces the initialization barrier, as the initial load and an overtaking refresh already did, so tool calls that arrive while its roots/list is pending wait for the result instead of running against the previous allow-list, which could still include a root the client had just withdrawn. The initialRootsPending flag is no longer needed. Closes #5101 Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .../filesystem-roots-refresh-barrier.md | 5 +++ src/filesystem/README.md | 1 + src/filesystem/__tests__/server-roots.test.ts | 39 +++++++++++++++++++ src/filesystem/server.ts | 31 ++++++--------- 4 files changed, 57 insertions(+), 19 deletions(-) create mode 100644 .changeset/filesystem-roots-refresh-barrier.md diff --git a/.changeset/filesystem-roots-refresh-barrier.md b/.changeset/filesystem-roots-refresh-barrier.md new file mode 100644 index 0000000000..9410af03bb --- /dev/null +++ b/.changeset/filesystem-roots-refresh-barrier.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/server-filesystem": patch +--- + +Tool calls that arrive while a `roots/list_changed` refresh is fetching the client's roots now wait for it, as they already did for the initial roots, so a call can no longer reach a root the client has just withdrawn while the answer is pending (#5101). diff --git a/src/filesystem/README.md b/src/filesystem/README.md index 056449ac86..bcedb5d0a9 100644 --- a/src/filesystem/README.md +++ b/src/filesystem/README.md @@ -51,6 +51,7 @@ The server's directory access control follows this flow: - Tool calls that arrive before the initial roots are loaded wait for them, so they are checked against the client's roots - **On runtime updates**: Client can send `notifications/roots/list_changed` - Server requests updated roots and replaces allowed directories again + - Tool calls that arrive while an update is being fetched wait for it, so none runs against a root the client has just withdrawn - Once the client's roots are in force, an update with no valid roots revokes access: every path is refused ("Access denied - no allowed directories") until the client exposes a root again. Before any root has been in force, such an update keeps the command-line directories 4. **Fallback Behavior** (if client doesn't support roots) diff --git a/src/filesystem/__tests__/server-roots.test.ts b/src/filesystem/__tests__/server-roots.test.ts index 55f1fc5f3c..fa97969f9a 100644 --- a/src/filesystem/__tests__/server-roots.test.ts +++ b/src/filesystem/__tests__/server-roots.test.ts @@ -502,6 +502,45 @@ describe("roots/list_changed", () => { ); }); + // #5101: a call made while a runtime refresh is pending waits for it, so it + // cannot reach a root the client has just withdrawn. + it("holds tool calls while a runtime refresh is pending (#5101)", async () => { + let calls = 0; + let releaseUpdate!: () => void; + const updateHeld = new Promise( + (resolve) => (releaseUpdate = resolve), + ); + const { client } = await connectTracked([], { + capabilities: ROOTS, + listRoots: async () => { + calls += 1; + if (calls === 1) return [rootOf(rootDir)]; + await updateHeld; // the withdrawal is answered slowly + return []; + }, + }); + await vi.waitFor(async () => + expect(await allowedDirectoriesOf(client)).toEqual([rootDir]), + ); + + await client.sendRootsListChanged(); + await vi.waitFor(() => expect(calls).toBe(2)); + let settled = false; + const pending = call(client, "list_directory", { path: rootDir }).then( + (result) => { + settled = true; + return result; + }, + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(settled).toBe(false); + + releaseUpdate(); + expect(textOf(await pending)).toMatch( + /^Access denied - no allowed directories/, + ); + }); + it("logs and keeps the current directories when the re-fetch fails", async () => { let fail = false; const { client } = await connectTracked([], { diff --git a/src/filesystem/server.ts b/src/filesystem/server.ts index 7c64f4f4f5..abd0815b41 100644 --- a/src/filesystem/server.ts +++ b/src/filesystem/server.ts @@ -186,19 +186,16 @@ export function createServer( version: SERVER_VERSION, }); - // Settles once the post-initialize setup (fetching the client's initial - // roots) has finished, successfully or not; it never rejects. Every tool call - // waits for it, so a call that arrives while the initial roots/list is still - // outstanding is checked against the client's roots rather than against the - // command-line directories (#3204). It is replaced in oninitialized, which - // the SDK runs before any request sent after notifications/initialized. + // Settles once the newest roots refresh has finished, successfully or not; it + // never rejects. Every tool call waits for it, so a call that arrives while + // roots are being fetched is checked against the roots that come back rather + // than the ones being replaced: the command-line directories while the + // initial roots/list is outstanding (#3204), or a root the client has just + // withdrawn while a roots/list_changed refresh is (#5101). The initial load + // (oninitialized, which the SDK runs before any request sent after + // notifications/initialized) and every refresh replace it, and a waiting + // call follows each replacement until it stops changing (#5097). let initialization: Promise = Promise.resolve(); - // True from the initial roots/list until a refresh's answer has been - // applied or the newest refresh has failed. A roots/list_changed that - // overtakes the initial load while this holds replaces `initialization`, so - // waiting tool calls keep waiting for the refresh that will actually apply - // rather than running against the command-line directories (#5097). - let initialRootsPending = false; const registerTool = server.registerTool.bind(server); server.registerTool = (( name: string, @@ -823,12 +820,11 @@ export function createServer( "Failed to request roots from client:", error instanceof Error ? error.message : String(error), ); - } finally { - if (generation === rootsGeneration) initialRootsPending = false; } })(); - // Overtaking the initial load: tool calls wait for this refresh instead. - if (initialRootsPending) initialization = refresh; + // Tool calls wait for this refresh, so none runs against a root the + // client has just withdrawn while its answer is pending (#5101). + initialization = refresh; await refresh; }, ); @@ -844,7 +840,6 @@ export function createServer( if (clientCapabilities?.roots) { const generation = ++rootsGeneration; - initialRootsPending = true; try { const response = await server.server.listRoots(); /* v8 ignore else -- the SDK validates the roots/list result against ListRootsResultSchema, which requires roots, so the else cannot run */ @@ -860,8 +855,6 @@ export function createServer( "Failed to request initial roots from client:", error instanceof Error ? error.message : String(error), ); - } finally { - if (generation === rootsGeneration) initialRootsPending = false; } } else { if (allowedDirectories.length > 0) { From dcaa6fe36081953c371cd41cb55b124219c6191d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 10 Oct 2026 04:35:46 -0400 Subject: [PATCH 058/117] fix(filesystem): install each roots refresh barrier before its roots/list is sent Each roots refresh (and the initial load) now replaces the initialization barrier before it sends roots/list, a microtask later, rather than just after. The SDK currently dispatches the client's roots/list handler asynchronously, so a newer change announced from that handler already lands after the older barrier is installed (verified: removing the deferral fails no test); the ordering is now guaranteed by the server rather than by that SDK detail (Copilot on #5103). Tests: a call queued behind a runtime refresh that fails is released against the current roots (Copilot on #5102); a refresh whose roots/list answer announces another change keeps the newest barrier (documents the case; passes with or without the deferral today). Comment wording fixed (Copilot on #5102). Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/filesystem/__tests__/server-roots.test.ts | 88 +++++++++++++++++++ src/filesystem/server.ts | 22 +++-- 2 files changed, 102 insertions(+), 8 deletions(-) diff --git a/src/filesystem/__tests__/server-roots.test.ts b/src/filesystem/__tests__/server-roots.test.ts index fa97969f9a..e42b9d356f 100644 --- a/src/filesystem/__tests__/server-roots.test.ts +++ b/src/filesystem/__tests__/server-roots.test.ts @@ -541,6 +541,94 @@ describe("roots/list_changed", () => { ); }); + it("releases a call queued behind a runtime refresh that fails (#5101)", async () => { + await fs.writeFile(path.join(rootDir, "a.txt"), "a"); + let calls = 0; + let failUpdate!: () => void; + const updateHeld = new Promise((resolve) => (failUpdate = resolve)); + const { client } = await connectTracked([], { + capabilities: ROOTS, + listRoots: async () => { + calls += 1; + if (calls === 1) return [rootOf(rootDir)]; + await updateHeld; + throw new Error("gone"); + }, + }); + await vi.waitFor(async () => + expect(await allowedDirectoriesOf(client)).toEqual([rootDir]), + ); + + await client.sendRootsListChanged(); + await vi.waitFor(() => expect(calls).toBe(2)); + // Queued while the refresh is still pending, before it fails. + let settled = false; + const pending = call(client, "list_directory", { path: rootDir }).then( + (result) => { + settled = true; + return result; + }, + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(settled).toBe(false); + + failUpdate(); + const result = await pending; + expect(result.isError).toBeFalsy(); + expect(textOf(result)).toBe("[FILE] a.txt"); + expect(stderr).toHaveBeenCalledWith( + "Failed to request roots from client:", + expect.stringContaining("gone"), + ); + }); + + // A client may announce a newer change while answering an older refresh's + // roots/list; the older refresh must not replace the newer one's barrier. + it("keeps the newest barrier when answering a refresh announces another change (#5101)", async () => { + let calls = 0; + let announce: () => Promise = async () => {}; + let releaseNewest!: () => void; + const newestHeld = new Promise( + (resolve) => (releaseNewest = resolve), + ); + const { client } = await connectTracked([], { + capabilities: ROOTS, + listRoots: async () => { + calls += 1; + if (calls === 1) return [rootOf(rootDir)]; + if (calls === 2) { + // Discarded on purpose: the server handles the notification on its + // own schedule, and the test waits for its roots/list instead. + void announce(); + return [rootOf(rootDir)]; + } + await newestHeld; // the newest change withdraws the root, slowly + return []; + }, + }); + announce = () => client.sendRootsListChanged(); + await vi.waitFor(async () => + expect(await allowedDirectoriesOf(client)).toEqual([rootDir]), + ); + + await client.sendRootsListChanged(); + await vi.waitFor(() => expect(calls).toBe(3)); + let settled = false; + const pending = call(client, "list_directory", { path: rootDir }).then( + (result) => { + settled = true; + return result; + }, + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(settled).toBe(false); + + releaseNewest(); + expect(textOf(await pending)).toMatch( + /^Access denied - no allowed directories/, + ); + }); + it("logs and keeps the current directories when the re-fetch fails", async () => { let fail = false; const { client } = await connectTracked([], { diff --git a/src/filesystem/server.ts b/src/filesystem/server.ts index abd0815b41..080be376f5 100644 --- a/src/filesystem/server.ts +++ b/src/filesystem/server.ts @@ -191,10 +191,13 @@ export function createServer( // roots are being fetched is checked against the roots that come back rather // than the ones being replaced: the command-line directories while the // initial roots/list is outstanding (#3204), or a root the client has just - // withdrawn while a roots/list_changed refresh is (#5101). The initial load - // (oninitialized, which the SDK runs before any request sent after - // notifications/initialized) and every refresh replace it, and a waiting - // call follows each replacement until it stops changing (#5097). + // withdrawn while a roots/list_changed refresh is pending (#5101). The + // initial load (oninitialized, which the SDK runs before any request sent + // after notifications/initialized) and every refresh replace it, and a + // waiting call follows each replacement until it stops changing (#5097). + // Each one is installed before its roots/list is sent: a transport that + // delivers that request synchronously can let the client announce a newer + // change at once, and that newer barrier must not be overwritten by this one. let initialization: Promise = Promise.resolve(); const registerTool = server.registerTool.bind(server); server.registerTool = (( @@ -807,7 +810,9 @@ export function createServer( "notifications/roots/list_changed", async () => { const generation = ++rootsGeneration; - const refresh = (async () => { + // Deferred a microtask, so the barrier below is installed before + // roots/list is sent (see `initialization` above). + const refresh = Promise.resolve().then(async () => { try { // Request the updated roots list from the client const response = await server.server.listRoots(); @@ -821,7 +826,7 @@ export function createServer( error instanceof Error ? error.message : String(error), ); } - })(); + }); // Tool calls wait for this refresh, so none runs against a root the // client has just withdrawn while its answer is pending (#5101). initialization = refresh; @@ -830,9 +835,10 @@ export function createServer( ); // Handles post-initialization setup, specifically checking for and fetching - // MCP roots. Tool calls wait for it (see `initialization` above). + // MCP roots. Tool calls wait for it (see `initialization` above), which is + // installed before initialize() sends its roots/list. server.server.oninitialized = () => { - initialization = initialize(); + initialization = Promise.resolve().then(initialize); }; async function initialize(): Promise { From 43980dccb757098143a11b5675a5a1262161c9b1 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 10 Oct 2026 04:47:53 -0400 Subject: [PATCH 059/117] fix(filesystem): claim the initial roots load's generation before deferring it Deferring initialize() a microtask (so its barrier is installed before roots/list is sent) also deferred its generation claim. The initial load now takes its generation synchronously in oninitialized and only its request is deferred, so a roots/list_changed handled before that request runs always counts as newer (Copilot on #5102). The SDK currently runs the deferred initial load before a later notification's handler, so this ordering is not reachable today (moving the claim back fails no test); the claim makes it explicit. New test: an update announced right after initialize wins over the held initial roots. Also renames the re-entrant announcement test. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/filesystem/__tests__/server-roots.test.ts | 33 ++++++++++++++++++- src/filesystem/server.ts | 10 +++--- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/filesystem/__tests__/server-roots.test.ts b/src/filesystem/__tests__/server-roots.test.ts index e42b9d356f..4adbff48a0 100644 --- a/src/filesystem/__tests__/server-roots.test.ts +++ b/src/filesystem/__tests__/server-roots.test.ts @@ -584,7 +584,7 @@ describe("roots/list_changed", () => { // A client may announce a newer change while answering an older refresh's // roots/list; the older refresh must not replace the newer one's barrier. - it("keeps the newest barrier when answering a refresh announces another change (#5101)", async () => { + it("keeps the newest barrier when a client's roots/list answer announces another change (#5101)", async () => { let calls = 0; let announce: () => Promise = async () => {}; let releaseNewest!: () => void; @@ -629,6 +629,37 @@ describe("roots/list_changed", () => { ); }); + // An update handled before the deferred initial roots/list runs must still + // count as newer than the initial load (#5101). + it("applies an update announced right after initialize over the initial roots (#5101)", async () => { + let calls = 0; + let releaseInitial!: () => void; + const initialHeld = new Promise( + (resolve) => (releaseInitial = resolve), + ); + const conn = connectTracked([], { + capabilities: ROOTS, + listRoots: async () => { + calls += 1; + if (calls === 1) { + await initialHeld; + return [rootOf(rootDir)]; + } + return [rootOf(cliDir)]; + }, + }); + const { client } = await conn; + await client.sendRootsListChanged(); + await vi.waitFor(() => expect(calls).toBe(2)); + releaseInitial(); + await vi.waitFor(() => + expect(stderr).toHaveBeenCalledWith( + "Discarded a stale roots/list answer: a newer roots update has started", + ), + ); + expect(await allowedDirectoriesOf(client)).toEqual([cliDir]); + }); + it("logs and keeps the current directories when the re-fetch fails", async () => { let fail = false; const { client } = await connectTracked([], { diff --git a/src/filesystem/server.ts b/src/filesystem/server.ts index 080be376f5..fb472af35c 100644 --- a/src/filesystem/server.ts +++ b/src/filesystem/server.ts @@ -836,16 +836,18 @@ export function createServer( // Handles post-initialization setup, specifically checking for and fetching // MCP roots. Tool calls wait for it (see `initialization` above), which is - // installed before initialize() sends its roots/list. + // installed before initialize() sends its roots/list. The initial load claims + // its generation here, synchronously, so a roots/list_changed handled before + // the deferred request runs takes a newer one and is not discarded as stale. server.server.oninitialized = () => { - initialization = Promise.resolve().then(initialize); + const generation = ++rootsGeneration; + initialization = Promise.resolve().then(() => initialize(generation)); }; - async function initialize(): Promise { + async function initialize(generation: number): Promise { const clientCapabilities = server.server.getClientCapabilities(); if (clientCapabilities?.roots) { - const generation = ++rootsGeneration; try { const response = await server.server.listRoots(); /* v8 ignore else -- the SDK validates the roots/list result against ListRootsResultSchema, which requires roots, so the else cannot run */ From 3e41913b7bce72640b846254cf2129528ad5ace2 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 10 Oct 2026 04:56:42 -0400 Subject: [PATCH 060/117] fix(filesystem): wake calls waiting on a replaced roots refresh Tool calls waited on the refresh promise itself, so a call that started waiting on refresh A stayed blocked on A's roots/list even after a newer refresh B had installed itself and applied the client's current roots; a slow or unanswered A stalled calls until its request timed out. The barrier is now separate from the refresh: each refresh installs a new barrier before it sends roots/list and settles it when it finishes, and installing one settles the barrier it replaces, so waiting calls wake and move on to the newest. This also makes the microtask deferral from the previous commits unnecessary (the barrier is installed synchronously). New test: a call waiting on an unanswered older refresh completes once a newer one finishes; it times out with the previous barrier (Copilot on #5103). Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/filesystem/__tests__/server-roots.test.ts | 42 +++++++++ src/filesystem/server.ts | 87 ++++++++++--------- 2 files changed, 87 insertions(+), 42 deletions(-) diff --git a/src/filesystem/__tests__/server-roots.test.ts b/src/filesystem/__tests__/server-roots.test.ts index 4adbff48a0..30cddff36a 100644 --- a/src/filesystem/__tests__/server-roots.test.ts +++ b/src/filesystem/__tests__/server-roots.test.ts @@ -660,6 +660,48 @@ describe("roots/list_changed", () => { expect(await allowedDirectoriesOf(client)).toEqual([cliDir]); }); + // A call waiting on an older refresh must not stay blocked on it once a newer + // refresh has applied the client's current roots (#5101). + it("wakes a call waiting on an older refresh once a newer one finishes (#5101)", async () => { + await fs.writeFile(path.join(rootDir, "a.txt"), "a"); + let calls = 0; + let releaseOlder!: () => void; + const olderHeld = new Promise((resolve) => (releaseOlder = resolve)); + const { client } = await connectTracked([], { + capabilities: ROOTS, + listRoots: async () => { + calls += 1; + if (calls === 1) return [rootOf(rootDir)]; + if (calls === 2) { + await olderHeld; // not answered until the end of the test + return []; + } + return [rootOf(rootDir)]; + }, + }); + await vi.waitFor(async () => + expect(await allowedDirectoriesOf(client)).toEqual([rootDir]), + ); + + await client.sendRootsListChanged(); + await vi.waitFor(() => expect(calls).toBe(2)); + const pending = call(client, "list_directory", { path: rootDir }); + await new Promise((resolve) => setTimeout(resolve, 50)); + await client.sendRootsListChanged(); + + expect(textOf(await pending)).toBe("[FILE] a.txt"); + expect(calls).toBe(3); + + // Cleanup: the older answer arrives last and is discarded as stale. + releaseOlder(); + await vi.waitFor(() => + expect(stderr).toHaveBeenCalledWith( + "Discarded a stale roots/list answer: a newer roots update has started", + ), + ); + expect(await allowedDirectoriesOf(client)).toEqual([rootDir]); + }); + it("logs and keeps the current directories when the re-fetch fails", async () => { let fail = false; const { client } = await connectTracked([], { diff --git a/src/filesystem/server.ts b/src/filesystem/server.ts index fb472af35c..bd0fae38b2 100644 --- a/src/filesystem/server.ts +++ b/src/filesystem/server.ts @@ -186,19 +186,25 @@ export function createServer( version: SERVER_VERSION, }); - // Settles once the newest roots refresh has finished, successfully or not; it - // never rejects. Every tool call waits for it, so a call that arrives while - // roots are being fetched is checked against the roots that come back rather - // than the ones being replaced: the command-line directories while the - // initial roots/list is outstanding (#3204), or a root the client has just - // withdrawn while a roots/list_changed refresh is pending (#5101). The - // initial load (oninitialized, which the SDK runs before any request sent - // after notifications/initialized) and every refresh replace it, and a - // waiting call follows each replacement until it stops changing (#5097). - // Each one is installed before its roots/list is sent: a transport that - // delivers that request synchronously can let the client announce a newer - // change at once, and that newer barrier must not be overwritten by this one. - let initialization: Promise = Promise.resolve(); + // The barrier every tool call waits on, so a call that arrives while roots are + // being fetched is checked against the roots that come back rather than the + // ones being replaced: the command-line directories while the initial + // roots/list is outstanding (#3204), or a root the client has just withdrawn + // while a roots/list_changed refresh is pending (#5101). Each refresh (the + // initial load included) installs a new barrier before it sends roots/list, + // and settles it when it finishes, successfully or not. Installing one also + // settles the barrier it replaces, so calls waiting on an older refresh wake + // at once and move on to the newer one instead of waiting for the older + // one's answer (#5097, #5101). + let barrier = { settled: Promise.resolve(), settle: () => {} }; + function installBarrier(): () => void { + let settle!: () => void; + const settled = new Promise((resolve) => (settle = resolve)); + const replaced = barrier; + barrier = { settled, settle }; + replaced.settle(); + return settle; + } const registerTool = server.registerTool.bind(server); server.registerTool = (( name: string, @@ -206,11 +212,11 @@ export function createServer( handler: (...args: unknown[]) => unknown, ) => registerTool(name, config, (async (...args: unknown[]) => { - let awaited: Promise; + let awaited: typeof barrier; do { - awaited = initialization; - await awaited; - } while (awaited !== initialization); + awaited = barrier; + await awaited.settled; + } while (awaited !== barrier); return handler(...args); }) as never)) as typeof server.registerTool; @@ -810,38 +816,35 @@ export function createServer( "notifications/roots/list_changed", async () => { const generation = ++rootsGeneration; - // Deferred a microtask, so the barrier below is installed before - // roots/list is sent (see `initialization` above). - const refresh = Promise.resolve().then(async () => { - try { - // Request the updated roots list from the client - const response = await server.server.listRoots(); - /* v8 ignore else -- the SDK validates the roots/list result against ListRootsResultSchema, which requires roots, so the implicit else cannot run */ - if (response && "roots" in response) { - await updateAllowedDirectoriesFromRoots(response.roots, generation); - } - } catch (error) { - console.error( - "Failed to request roots from client:", - error instanceof Error ? error.message : String(error), - ); + const settle = installBarrier(); + try { + // Request the updated roots list from the client + const response = await server.server.listRoots(); + /* v8 ignore else -- the SDK validates the roots/list result against ListRootsResultSchema, which requires roots, so the implicit else cannot run */ + if (response && "roots" in response) { + await updateAllowedDirectoriesFromRoots(response.roots, generation); } - }); - // Tool calls wait for this refresh, so none runs against a root the - // client has just withdrawn while its answer is pending (#5101). - initialization = refresh; - await refresh; + } catch (error) { + console.error( + "Failed to request roots from client:", + error instanceof Error ? error.message : String(error), + ); + } finally { + settle(); + } }, ); // Handles post-initialization setup, specifically checking for and fetching - // MCP roots. Tool calls wait for it (see `initialization` above), which is - // installed before initialize() sends its roots/list. The initial load claims - // its generation here, synchronously, so a roots/list_changed handled before - // the deferred request runs takes a newer one and is not discarded as stale. + // MCP roots. The initial load claims its generation and installs its barrier + // here, synchronously, before any roots/list_changed handled afterwards can + // claim a newer one. server.server.oninitialized = () => { const generation = ++rootsGeneration; - initialization = Promise.resolve().then(() => initialize(generation)); + const settle = installBarrier(); + // Not awaited: oninitialized is synchronous. Tool calls wait on the + // barrier, which settles when initialize() finishes; it never rejects. + void initialize(generation).finally(settle); }; async function initialize(generation: number): Promise { From b4d3a6fb5729c9040a597ed3f9cc31a96f72153d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 10 Oct 2026 12:53:27 -0400 Subject: [PATCH 061/117] ci(release): publish with Node 24's bundled npm instead of installing one OIDC trusted publishing needs npm >= 11.5.1, and Node 22 bundles npm 10.x, so publish-npm installed npm@11.20.0 next to id-token: write. It now runs an exactly pinned Node 24.21.0, whose bundled npm (11.19.0) meets the floor, and installs nothing; a step fails the job before publishing if the bundled npm is ever below 11.5.1. The build jobs keep Node 22. The release skill's note on pinning moves from the npm CLI to the Node release. Closes #5104 Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .claude/skills/release/SKILL.md | 8 ++++++-- .github/workflows/release.yml | 31 +++++++++++++++++++++++-------- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 0b07c4b407..c567f3429d 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -568,5 +568,9 @@ ACTION=actions/checkout TAG=v6.1.0 echo "uses: $ACTION@$(gh api "repos/$ACTION/commits/$TAG" --jq .sha) # $TAG" ``` -The same goes for the npm CLI the publish job installs (`npm@x.y.z` in -`release.yml`): it is pinned exactly, and bumped deliberately. +The same goes for the Node release the npm publish job runs (`node-version` in +`publish-npm`, `release.yml`): it is pinned exactly, because its bundled npm is +the CLI that publishes, and it stays on a release whose bundled npm is 11.5.1 +or later (the OIDC floor; the job checks it before publishing). Bump it +deliberately, from a Node release whose npm version you have read in +`https://nodejs.org/dist/index.json`. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0729df0753..f47a698260 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -213,18 +213,33 @@ jobs: steps: # No `cache: npm`: there is no checkout, so no lockfile to key it on, and # nothing here installs dependencies to cache. + # OIDC trusted publishing requires npm >= 11.5.1. Node 22 bundles npm + # 10.x, so this job runs Node 24, whose bundled npm meets the floor, and + # installs nothing: no package is fetched from the registry and run + # next to `id-token: write` (#5104). Pinned EXACTLY, not to a major: the + # runtime is the one thing this job trusts besides the pinned actions, + # and a range would let whatever 24.x is current at release time in. + # The build jobs keep Node 22; this job only reads a manifest and runs + # `npm publish` on the verified tarball. - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 22 + node-version: 24.21.0 registry-url: "https://registry.npmjs.org" - # OIDC trusted publishing requires npm >= 11.5.1; Node 22's bundled npm - # is 10.x. Pinned EXACTLY, not to a range: this install runs in the job - # that holds `id-token: write`, so it is the one package this job - # trusts, and a range would let whatever 11.x is current at release time - # into it. `--ignore-scripts` keeps even its lifecycle scripts out. - - name: Install the pinned npm CLI (OIDC trusted publishing) - run: npm install -g --ignore-scripts npm@11.20.0 + # Fails closed before anything is published if the bundled npm is ever + # below the OIDC floor (a different Node pin, or a runner override). + - name: Check the bundled npm supports OIDC trusted publishing + run: | + NPM_VERSION="$(npm --version)" + echo "node $(node --version), npm $NPM_VERSION" + node -e ' + const [a, b, c] = process.argv[1].split(".").map(Number); + const ok = a > 11 || (a === 11 && (b > 5 || (b === 5 && c >= 1))); + if (!ok) { + console.error("npm " + process.argv[1] + " is below 11.5.1, the floor for OIDC trusted publishing"); + process.exit(1); + } + ' "$NPM_VERSION" - name: Download the artifact built by the build job uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 From 4d13264593602076f9e75df83d767d3e1c858c03 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 10 Oct 2026 13:02:38 -0400 Subject: [PATCH 062/117] test(release): pin the publish job's npm floor check and its no-install shape scripts/release-npm-floor.test.mjs reads publish-npm out of release.yml, as release-dist-tag.test.mjs does, and runs the npm floor step with a stub npm on PATH: 11.5.1, 11.6.0, 11.19.0 and 12.0.0 pass, 11.5.0, 11.4.9, 10.9.9 and 9.0.0 fail. It also asserts an exact Node pin whose bundled npm meets the floor, the check ahead of the download and the publish, and no step that installs or runs a fetched package (comment lines ignored). The previous workflow fails all five; loosening the floor to 11.5.0 fails the refusal test (Copilot on #5105 and #5106). Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- scripts/release-npm-floor.test.mjs | 162 +++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 scripts/release-npm-floor.test.mjs diff --git a/scripts/release-npm-floor.test.mjs b/scripts/release-npm-floor.test.mjs new file mode 100644 index 0000000000..8247413e5e --- /dev/null +++ b/scripts/release-npm-floor.test.mjs @@ -0,0 +1,162 @@ +// Regression tests for how `release.yml`'s `publish-npm` job gets its npm (#5104). +// +// That job holds the OIDC credential, so it installs nothing: it runs an +// exactly pinned Node whose bundled npm meets the floor for OIDC trusted +// publishing (11.5.1), and a step fails the job before anything is published +// if that npm is ever below the floor. The step's comparator is inline shell +// and JavaScript in the workflow file, which runs only after a Release is +// published, so a later edit that let 11.5.0 through would otherwise surface +// at release time. +// +// As in release-dist-tag.test.mjs, these tests read the step out of the +// workflow file and run THAT, with `npm` on PATH replaced by a stub that +// reports a chosen version. They also pin the job's shape: an exact Node +// pin, the check ahead of the download and the publish, and no step that +// installs a package. +// +// The step's shell is bash, so those tests are skipped where there is none. +// Run via `npm run test:scripts`. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + chmodSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { parse } from "yaml"; + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); + +const CHECK_STEP = "Check the bundled npm supports OIDC trusted publishing"; + +/** The `publish-npm` job's steps, as the workflow file declares them. */ +function publishSteps() { + const workflow = parse( + readFileSync( + path.join(repoRoot, ".github", "workflows", "release.yml"), + "utf8", + ), + ); + const job = workflow.jobs["publish-npm"]; + assert.ok(job, "release.yml has no publish-npm job"); + return job.steps; +} + +const hasBash = + process.platform !== "win32" && + spawnSync("bash", ["-c", "exit 0"]).status === 0; + +/** + * Run the workflow's npm floor check with `npm --version` reporting `version`. + * + * @param {string} version what the stub npm reports + * @returns {{ status: number | null, stderr: string }} + */ +function runCheck(version) { + const step = publishSteps().find((s) => s.name === CHECK_STEP); + assert.ok(step, `publish-npm has no \`${CHECK_STEP}\` step`); + const dir = mkdtempSync(path.join(tmpdir(), "npm-floor-")); + try { + const stub = path.join(dir, "npm"); + writeFileSync(stub, `#!/bin/sh\necho "${version}"\n`); + chmodSync(stub, 0o755); + const result = spawnSync("bash", ["-e", "-c", step.run], { + encoding: "utf8", + env: { + ...process.env, + PATH: `${dir}${path.delimiter}${process.env.PATH}`, + }, + }); + return { status: result.status, stderr: result.stderr }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +test( + "the npm floor check accepts 11.5.1 and later", + { skip: !hasBash && "needs bash" }, + () => { + for (const version of ["11.5.1", "11.6.0", "11.19.0", "12.0.0"]) { + const { status, stderr } = runCheck(version); + assert.equal(status, 0, `npm ${version} was refused: ${stderr}`); + } + }, +); + +test( + "the npm floor check refuses anything below 11.5.1", + { skip: !hasBash && "needs bash" }, + () => { + for (const version of ["11.5.0", "11.4.9", "10.9.9", "9.0.0"]) { + const { status, stderr } = runCheck(version); + assert.notEqual(status, 0, `npm ${version} was accepted`); + assert.match(stderr, /below 11\.5\.1/); + } + }, +); + +test("publish-npm pins Node exactly, to a release whose npm meets the floor", () => { + const setup = publishSteps().find((s) => + String(s.uses ?? "").startsWith("actions/setup-node@"), + ); + assert.ok(setup, "publish-npm has no actions/setup-node step"); + const pin = String(setup.with?.["node-version"] ?? ""); + assert.match( + pin, + /^\d+\.\d+\.\d+$/, + `node-version ${pin} is not an exact x.y.z pin`, + ); + // Node 24.5.0 is the first release whose bundled npm is 11.5.1. + const [major, minor] = pin.split(".").map(Number); + assert.ok( + major > 24 || (major === 24 && minor >= 5), + `Node ${pin} bundles an npm below the OIDC floor`, + ); +}); + +test("publish-npm checks npm before it downloads or publishes anything", () => { + const names = publishSteps().map((s) => s.name ?? s.uses); + const check = names.indexOf(CHECK_STEP); + assert.notEqual(check, -1, `publish-npm has no \`${CHECK_STEP}\` step`); + const download = names.findIndex((n) => + String(n).startsWith("Download the artifact"), + ); + const publish = names.indexOf("Publish package"); + assert.ok( + download !== -1 && publish !== -1, + "publish-npm lost its download or publish step", + ); + assert.ok( + check < download && check < publish, + `the npm check runs after: ${names.join(" > ")}`, + ); +}); + +test("publish-npm installs no package next to the OIDC credential", () => { + const install = + /\b(?:npm|pnpm|yarn)\s+(?:install|i|ci|add|exec|dlx)\b|\bnpx\b|\bcorepack\b|\bpip3?\s+install\b|\buvx?\b/; + for (const step of publishSteps()) { + if (!step.run) continue; + // Shell comments may name these tools in prose; only commands count. + const commands = step.run + .split("\n") + .filter((line) => !line.trim().startsWith("#")) + .join("\n"); + assert.doesNotMatch( + commands, + install, + `publish-npm step \`${step.name}\` installs or runs a fetched package`, + ); + } +}); From 28b468edb75b9b763fda951b90a726f1235afbfa Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 10 Oct 2026 13:05:44 -0400 Subject: [PATCH 063/117] test(release): pin the ./ tarball path npm publish needs npm publish reads a bare dir/file argument as GitHub owner/repo shorthand and tries to clone it over SSH; the MCP Inspector's first release through the same split publish job failed that way (modelcontextprotocol/inspector#2551). release.yml already passes ./release-artifact/*.tgz; this asserts it, so the ./ cannot be lost in an edit. Reproduced locally with Node 24.21.0's npm: the bare path exits 128 on 'git ls-remote ssh://git@github.com/release-artifact/...', the ./ path dry-run-publishes. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- scripts/release-npm-floor.test.mjs | 32 ++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/scripts/release-npm-floor.test.mjs b/scripts/release-npm-floor.test.mjs index 8247413e5e..e53822133a 100644 --- a/scripts/release-npm-floor.test.mjs +++ b/scripts/release-npm-floor.test.mjs @@ -11,8 +11,12 @@ // As in release-dist-tag.test.mjs, these tests read the step out of the // workflow file and run THAT, with `npm` on PATH replaced by a stub that // reports a chosen version. They also pin the job's shape: an exact Node -// pin, the check ahead of the download and the publish, and no step that -// installs a package. +// pin, the check ahead of the download and the publish, no step that +// installs a package, and the `./` that makes `npm publish` read the tarball +// as a local file. Without it npm reads `release-artifact/x.tgz` as GitHub +// `owner/repo` shorthand and tries to clone it over SSH: the MCP Inspector's +// first release through the same split publish job failed exactly that way +// (modelcontextprotocol/inspector#2551). // // The step's shell is bash, so those tests are skipped where there is none. // Run via `npm run test:scripts`. @@ -160,3 +164,27 @@ test("publish-npm installs no package next to the OIDC credential", () => { ); } }); + +test("publish-npm hands npm the tarball as a ./ path, not a bare dir/file", () => { + const step = publishSteps().find((s) => s.name === "Publish package"); + assert.ok(step, "publish-npm has no `Publish package` step"); + const commands = step.run + .split("\n") + .filter((line) => !line.trim().startsWith("#")) + .join("\n"); + assert.match( + commands, + /^\s*set -- \.\/release-artifact\/\*\.tgz\s*$/m, + "the publish step must resolve the tarball as ./release-artifact/*.tgz", + ); + assert.doesNotMatch( + commands, + /set -- release-artifact\//, + "a bare release-artifact/ path makes npm read the tarball as a GitHub repo", + ); + assert.match( + commands, + /npm publish "\$1"/, + "npm publish must be given the ./ path resolved above", + ); +}); From eda464643a064d1a73c9fca5cbd6d8aeec6b7dc9 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sat, 10 Oct 2026 13:33:02 -0400 Subject: [PATCH 064/117] fix(fetch): annotate the fetch tool The fetch tool set no tool annotations, unlike every tool in the git and time servers, and AGENTS.md requires them on every tool that is added or changed. It now declares readOnlyHint: true, destructiveHint: false, idempotentHint: true and openWorldHint: true (the values proposed on #3572), so clients can apply allow-reads/gate-sends policies to it. Protocol test pins them in tools/list; README lists them. Closes #3572 Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/fetch/README.md | 1 + src/fetch/src/mcp_server_fetch/server.py | 9 +++++++++ src/fetch/tests/test_protocol.py | 7 +++++++ 3 files changed, 17 insertions(+) diff --git a/src/fetch/README.md b/src/fetch/README.md index 2441bd2b89..295512bbbd 100644 --- a/src/fetch/README.md +++ b/src/fetch/README.md @@ -22,6 +22,7 @@ The fetch tool will truncate the response, but by using the `start_index` argume - `max_length` (integer, optional): Maximum number of characters to return (default: 5000) - `start_index` (integer, optional): Start content from this character index (default: 0) - `raw` (boolean, optional): Get raw content without markdown conversion (default: false) + - Annotations: `readOnlyHint: true`, `destructiveHint: false`, `idempotentHint: true`, `openWorldHint: true` (it only reads, and it reaches arbitrary hosts on the internet) ### Prompts diff --git a/src/fetch/src/mcp_server_fetch/server.py b/src/fetch/src/mcp_server_fetch/server.py index 7c338f839e..41824a2099 100644 --- a/src/fetch/src/mcp_server_fetch/server.py +++ b/src/fetch/src/mcp_server_fetch/server.py @@ -40,6 +40,7 @@ PromptMessage, TextContent, Tool, + ToolAnnotations, INVALID_PARAMS, INTERNAL_ERROR, ) @@ -418,6 +419,14 @@ async def serve( Although originally you did not have internet access, and were advised to refuse and tell the user this, this tool now grants you internet access. Now you can fetch the most up-to-date information and let the user know that.""", input_schema=Fetch.model_json_schema(), + # Reads only, changes nothing, and repeating a request has no further + # side effect; it reaches arbitrary hosts on the internet (#3572). + annotations=ToolAnnotations( + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=True, + ), ) async def list_tools( diff --git a/src/fetch/tests/test_protocol.py b/src/fetch/tests/test_protocol.py index 0a2734a19b..325e546301 100644 --- a/src/fetch/tests/test_protocol.py +++ b/src/fetch/tests/test_protocol.py @@ -129,6 +129,13 @@ async def test_list_tools_wire_shape(web: FakeWeb) -> None: ) assert "this tool now grants you internet access" in tool["description"] assert "outputSchema" not in tool + # #3572: the tool declares what it does, for clients' approval policies. + assert tool["annotations"] == { + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True, + } assert tool["inputSchema"] == { "description": "Parameters for fetching a URL.", "properties": { From 5b400a6ec05bd883522665ebeae110bf1a2744ff Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 11 Oct 2026 00:10:05 -0400 Subject: [PATCH 065/117] chore(changeset): restore the SDK v2 majors, drop fixes shipped in 1.0.0 The SDK v2 migration did not ship in 1.0.0, so its four major changesets come back with their original content (0c505a61^). The three filesystem roots changesets describe fixes cherry-picked into 1.0.0 (#5094, #5097, #5101) and would otherwise be listed again in 2.0.0's changelog. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .changeset/everything-sdk-v2.md | 5 +++++ .changeset/filesystem-revoke-empty-roots.md | 5 ----- .changeset/filesystem-roots-refresh-barrier.md | 5 ----- .changeset/filesystem-roots-refresh-order.md | 5 ----- .changeset/filesystem-sdk-v2.md | 5 +++++ .changeset/memory-sdk-v2.md | 5 +++++ .changeset/sequentialthinking-sdk-v2.md | 5 +++++ 7 files changed, 20 insertions(+), 15 deletions(-) create mode 100644 .changeset/everything-sdk-v2.md delete mode 100644 .changeset/filesystem-revoke-empty-roots.md delete mode 100644 .changeset/filesystem-roots-refresh-barrier.md delete mode 100644 .changeset/filesystem-roots-refresh-order.md create mode 100644 .changeset/filesystem-sdk-v2.md create mode 100644 .changeset/memory-sdk-v2.md create mode 100644 .changeset/sequentialthinking-sdk-v2.md diff --git a/.changeset/everything-sdk-v2.md b/.changeset/everything-sdk-v2.md new file mode 100644 index 0000000000..07fe4f38ae --- /dev/null +++ b/.changeset/everything-sdk-v2.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/server-everything": major +--- + +Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`, `@modelcontextprotocol/node`, and `@modelcontextprotocol/server-legacy` for the deprecated HTTP+SSE transport) from `@modelcontextprotocol/sdk` 1.x (#4856). The task tools `simulate-research-query`, `trigger-sampling-request-async` and `trigger-elicitation-request-async` are removed, and the server no longer advertises the `tasks` capability, because SDK v2 dropped the experimental tasks layer they were built on; they return on the tasks extension in #4852. Breaking for clients: the server now requires Node.js 20 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); tool schemas in `tools/list` declare JSON Schema 2020-12 instead of draft-07; and tools no longer advertise `execution.taskSupport`. The optional `state` argument of `args-prompt` now carries its description. diff --git a/.changeset/filesystem-revoke-empty-roots.md b/.changeset/filesystem-revoke-empty-roots.md deleted file mode 100644 index f563e3373a..0000000000 --- a/.changeset/filesystem-revoke-empty-roots.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-filesystem": patch ---- - -A `roots/list_changed` update that leaves no valid root now revokes access once the client's roots are in force, instead of keeping the previous allowed directories, so a client that withdraws its roots withdraws the server's access too (#5094). Every path is then refused with "Access denied - no allowed directories" until the client exposes a root again. Before any root has been in force, such an update still keeps the command-line directories. diff --git a/.changeset/filesystem-roots-refresh-barrier.md b/.changeset/filesystem-roots-refresh-barrier.md deleted file mode 100644 index 9410af03bb..0000000000 --- a/.changeset/filesystem-roots-refresh-barrier.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-filesystem": patch ---- - -Tool calls that arrive while a `roots/list_changed` refresh is fetching the client's roots now wait for it, as they already did for the initial roots, so a call can no longer reach a root the client has just withdrawn while the answer is pending (#5101). diff --git a/.changeset/filesystem-roots-refresh-order.md b/.changeset/filesystem-roots-refresh-order.md deleted file mode 100644 index f1cf2f7c42..0000000000 --- a/.changeset/filesystem-roots-refresh-order.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-filesystem": patch ---- - -Overlapping roots refreshes now take effect in the order they started: when a client sends `roots/list_changed` again before the previous `roots/list` answer arrived (or while the initial roots are still loading), an answer that arrives after a newer refresh has started is discarded instead of overwriting it, so a stale answer can no longer undo a newer update such as a revocation (#5097). diff --git a/.changeset/filesystem-sdk-v2.md b/.changeset/filesystem-sdk-v2.md new file mode 100644 index 0000000000..523a805d57 --- /dev/null +++ b/.changeset/filesystem-sdk-v2.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/server-filesystem": major +--- + +Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`) from `@modelcontextprotocol/sdk` 1.x (#4856). Breaking for clients: the server now requires Node.js 20 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); and tools no longer advertise `execution.taskSupport`. diff --git a/.changeset/memory-sdk-v2.md b/.changeset/memory-sdk-v2.md new file mode 100644 index 0000000000..e1ab5b57df --- /dev/null +++ b/.changeset/memory-sdk-v2.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/server-memory": major +--- + +Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`) from `@modelcontextprotocol/sdk` 1.x (#4856). Breaking for clients: the server now requires Node.js 20 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); tool schemas in `tools/list` declare JSON Schema 2020-12 instead of draft-07; and tools no longer advertise `execution.taskSupport`. diff --git a/.changeset/sequentialthinking-sdk-v2.md b/.changeset/sequentialthinking-sdk-v2.md new file mode 100644 index 0000000000..9e76a69e93 --- /dev/null +++ b/.changeset/sequentialthinking-sdk-v2.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/server-sequential-thinking": major +--- + +Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`) from `@modelcontextprotocol/sdk` 1.x (#4856). Breaking for clients: the server now requires Node.js 20 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); tool schemas in `tools/list` declare JSON Schema 2020-12 instead of draft-07; and tools no longer advertise `execution.taskSupport`. From a8a13d3e2580000a194d1cb677aa6e48f665d9a7 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 11 Oct 2026 00:45:27 -0400 Subject: [PATCH 066/117] ci: guard main so a PR into it brings nothing not already on v2/main Adds scripts/verify-main-source.mjs and the main-source.yml workflow (pull_request_target into main), which fails unless the PR's head commit is on origin/v2/main. GitHub rulesets cannot restrict a PR's source branch, so this check, made required on main, is what enforces the milestone-merge-only rule in AGENTS.md. Closes #5119 Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .claude/skills/release/SKILL.md | 6 +- .github/workflows/main-source.yml | 59 ++++++++++ AGENTS.md | 11 +- docs/quality-gate.md | 2 +- scripts/verify-main-source.mjs | 142 ++++++++++++++++++++++++ scripts/verify-main-source.test.mjs | 164 ++++++++++++++++++++++++++++ 6 files changed, 379 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/main-source.yml create mode 100644 scripts/verify-main-source.mjs create mode 100644 scripts/verify-main-source.test.mjs diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 8f45ccf9b8..54181fa48f 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -256,12 +256,14 @@ This is the one PR in the repository that targets `main`. ⚠️ **It is merged with "Create a merge commit", never squashed or rebased.** A squash writes one new commit that `v2/main` does not contain, so the two branches stop sharing history and the next milestone merge conflicts with -itself. +itself. `main`'s ruleset allows only merge commits, so the other two buttons +are not offered. ⚠️ **Do not click "Update branch" on the merge PR.** GitHub always offers it here, because `main` always holds commits the merge branch lacks (its own earlier merge commits), so it does not mean the PR is out of date. Clicking it -merges `main` into the merge branch: the PR gains a commit of its own, `main` +merges `main` into the merge branch: the PR gains a commit of its own (which +fails the required **Source is v2/main** check, `main-source.yml`), `main` receives two merge commits, and the branch head is no longer the commit the ledger names. If it was clicked, put the branch back on its head from before the click: the update merge's **first parent**, which is the commit the ledger diff --git a/.github/workflows/main-source.yml b/.github/workflows/main-source.yml new file mode 100644 index 0000000000..7a2ee9e5e6 --- /dev/null +++ b/.github/workflows/main-source.yml @@ -0,0 +1,59 @@ +# A pull request into `main` brings nothing that is not already on `v2/main` +# (#5119). `main` only ever receives a milestone's merge PR, a pure merge of +# `v2/main`; GitHub rulesets cannot restrict a PR's source branch, so this +# check, required by the `main` ruleset, is what does. The rule and why it is +# on the head commit rather than the branch name are in +# `scripts/verify-main-source.mjs`. +# +# On `pull_request_target`, unlike dco.yml: it runs `main`'s copy of this +# workflow and of the script, so a PR cannot edit the guard into passing. That +# is safe here because nothing from the PR runs: the checkout is `main`, and +# the PR's head is only fetched as git objects for `git merge-base` to read. +# Never add a step that checks out, installs or runs the PR's code. +# `edited` is there so retargeting a PR onto `main` runs the check. +on: + pull_request_target: + types: [opened, synchronize, reopened, edited] + branches: [main] + +# Read-only, and handed no secret, so this job holds no credential and stays +# outside the SHA-pin rule (`verify:action-pins`). +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + main-source: + timeout-minutes: 5 + name: Source is v2/main + runs-on: ubuntu-latest + steps: + # The base branch (`main`), which `pull_request_target` checks out by + # default; never the PR's ref. + - uses: actions/checkout@v6 + with: + persist-credentials: false + + # The PR's head and `v2/main`, as objects only. A fork PR's head is + # reachable here through `refs/pull//head`. Full history, so + # `git merge-base` can walk both. + - name: Fetch the PR head and v2/main + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + git fetch --no-tags --unshallow origin \ + "+refs/heads/v2/main:refs/remotes/origin/v2/main" \ + "+refs/pull/${PR_NUMBER}/head:refs/remotes/pr/head" + + - uses: actions/setup-node@v7 + with: + node-version: 22 + + # Node built-ins and git only, so no `npm ci`. + - name: Check the head is already on v2/main + env: + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: node scripts/verify-main-source.mjs --head "$HEAD_SHA" diff --git a/AGENTS.md b/AGENTS.md index 7b4abbd327..6af5b1eb19 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,6 +62,7 @@ servers/ ├── .github/workflows/ typescript.yml, python.yml (per-package CI), release.yml (publishes on a GitHub Release), │ version-packages.yml (the changesets PR), prepare-python-release.yml (the CalVer PR), │ claude.yml (@claude mentions), everything-mcp-diff.yml (everything's interface diff on PRs), +│ main-source.yml (a PR into main must bring nothing that is not on v2/main), │ and the scheduled sweeps that file issues: dependency-refresh.yml, dependabot-alerts.yml, sdk-watch.yml ├── .github/ISSUE_TEMPLATE/ Bug and feature issue forms; config.yml routes security and new servers away ├── .github/pull_request_template.md The "issues, not PRs" banner that turns outside PRs away @@ -153,6 +154,10 @@ diagnosing a red stage is the `pre-push-gate` skill. publish artifact into an empty directory and boots it, which needs the network. `release.yml` runs it before anything publishes, and the `release` skill runs it by hand for the ledger. +- **`verify-main-source` is not a gate stage either.** It applies only to a + pull request into `main` (see the merge PR exception under + [Contributing](#contributing)), and every local push targets a v2 branch. + `main-source.yml` runs it. - **`local:*` scripts never run in a workflow, and every CI job declares `timeout-minutes`.** The script tests enforce both (`scripts/lib/workflow-gate.mjs`). A check added to CI is added to @@ -353,8 +358,10 @@ holds for a maintainer's own one-line fix as much as for a feature. default branch a closing keyword would close that issue at merge, before anything is published. The release issue is closed by hand once the Release is published. In place of the checklist it links the **release ledger**. It - is merged with a merge commit, never squashed. The flow is the `release` - skill. + is merged with a merge commit, never squashed. Both are enforced on `main`: + its ruleset allows only merge commits, and requires the **Source is + v2/main** check (`main-source.yml`), which fails a PR whose head carries + any commit not already on `v2/main`. The flow is the `release` skill. - **Exception: the two version PRs that automation opens.** Each changes version metadata only, so neither answers the PR checklist below, and each is tied to its milestone's **release issue** rather than to an issue of its diff --git a/docs/quality-gate.md b/docs/quality-gate.md index 7c197a5b05..85a0354cef 100644 --- a/docs/quality-gate.md +++ b/docs/quality-gate.md @@ -13,7 +13,7 @@ out; everything else points here. | --- | --- | --- | --- | | Inner loop | `npm run validate -w src/`, `npm run validate:py -- ` | Your machine | While iterating on one server | | Pre-push gate | `npm run local:gate` | Your machine, under a lease | Before every push | -| CI | `.github/workflows/typescript.yml`, `python.yml`, `everything-mcp-diff.yml`; `dco.yml` | GitHub, on every push and pull request (`everything-mcp-diff.yml`: when `everything` or what builds it changes); `dco.yml` on pull requests only | Before merge | +| CI | `.github/workflows/typescript.yml`, `python.yml`, `everything-mcp-diff.yml`; `dco.yml`; `main-source.yml` | GitHub, on every push and pull request (`everything-mcp-diff.yml`: when `everything` or what builds it changes); `dco.yml` on pull requests only; `main-source.yml` on pull requests into `main` only, and not mirrored by a gate stage, since no local push targets `main` | Before merge | **`npm run local:gate` runs every check CI runs.** CI splits the same checks into parallel jobs on a fresh install; the gate runs them in sequence on your diff --git a/scripts/verify-main-source.mjs b/scripts/verify-main-source.mjs new file mode 100644 index 0000000000..280f16db1c --- /dev/null +++ b/scripts/verify-main-source.mjs @@ -0,0 +1,142 @@ +#!/usr/bin/env node +// Guard: a pull request into `main` brings nothing that is not already on +// `v2/main` (#5119). +// +// `main` is the release line. The one PR that may target it is a milestone's +// merge PR, a pure merge of `v2/main` (AGENTS.md, "Contributing"). GitHub +// rulesets can require a PR and its approvals, but cannot say where a PR comes +// from, so before this guard a PR from any branch could merge into `main`. +// +// The rule is on the COMMIT, not the branch name: the PR's head must be an +// ancestor of the source (`origin/v2/main`), or the source itself. The +// `release` skill opens the merge PR from `v2/chore/-release-`, +// a branch pushed from `origin/v2/main`, so a check on the head's name would +// refuse the one PR the rule exists to allow. An ancestor rather than only an +// equal commit, because `v2/main` keeps moving while the merge PR is open, and +// the PR is still a pure merge of what it was opened with. +// +// CI runs it from `main-source.yml` on `pull_request_target`, which runs +// `main`'s copy of the workflow and of this script, so a PR cannot edit the +// guard into passing. The PR's head is only fetched as git objects; none of +// its code runs. It is not a `local:gate` stage: every local push targets a +// v2 branch, where the rule does not apply. + +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +/** The branch every commit on `main` must already be on. */ +export const DEFAULT_SOURCE = "origin/v2/main"; + +/** How many of the offending commits the report lists. */ +export const MAX_LISTED = 20; + +/** + * Pure: `--head ` (required) and `--source `. + * + * @param {string[]} argv + */ +export function parseArgs(argv) { + const opts = { source: DEFAULT_SOURCE, head: "" }; + for (let i = 0; i < argv.length; i++) { + const flag = argv[i]; + if (flag !== "--source" && flag !== "--head") + throw new Error(`unknown argument: ${flag}`); + const value = argv[++i]; + if (!value) throw new Error(`${flag} needs a revision`); + opts[flag.slice(2)] = value; + } + if (!opts.head) throw new Error("--head is required"); + return opts; +} + +/** + * Pure: the report for a head that carries commits the source does not. + * + * @param {{ source: string, head: string, commits: string[] }} args + * `commits` is `git log --oneline ..`, one per entry + */ +export function formatReport({ source, head, commits }) { + const n = commits.length; + const listed = commits.slice(0, MAX_LISTED).map((c) => ` ${c}`); + if (n > MAX_LISTED) listed.push(` … and ${n - MAX_LISTED} more`); + return [ + `verify:main-source: ${head} carries ${n} commit${n === 1 ? "" : "s"} that ${source} does not:`, + ...listed, + "", + "A pull request into `main` must be a pure merge of `v2/main`: its head", + "is a commit already on `v2/main`. Land the change on `v2/main` through", + "its own PR, and it reaches `main` with the next milestone merge (the", + "`release` skill).", + ].join("\n"); +} + +function git(args, cwd) { + const r = spawnSync("git", args, { cwd, encoding: "utf8" }); + if (r.error) throw r.error; + return r; +} + +/** + * Check that `head` is `source` or an ancestor of it, in the repository at + * `cwd`. + * + * @returns {{ code: number, output: string }} + */ +export function verifyMainSource({ source, head, cwd }) { + for (const rev of [source, head]) { + if ( + git(["rev-parse", "--verify", "--quiet", `${rev}^{commit}`], cwd).status + ) + return { + code: 1, + output: + `verify:main-source: cannot resolve ${rev}.` + + (rev === DEFAULT_SOURCE + ? " Fetch it (`git fetch origin v2/main`), or name another source with --source." + : ""), + }; + } + const anc = git(["merge-base", "--is-ancestor", head, source], cwd); + if (anc.status === 0) + return { + code: 0, + output: `verify:main-source: ${head} is on ${source}; nothing else comes with it.`, + }; + if (anc.status !== 1) + return { + code: 1, + output: `verify:main-source: git merge-base failed:\n${anc.stderr}`, + }; + const log = git( + ["log", "--oneline", "--no-decorate", `${source}..${head}`], + cwd, + ); + if (log.status) + return { + code: 1, + output: `verify:main-source: git log failed:\n${log.stderr}`, + }; + const commits = log.stdout.split("\n").filter((l) => l.trim() !== ""); + return { code: 1, output: formatReport({ source, head, commits }) }; +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href +) { + let opts; + try { + opts = parseArgs(process.argv.slice(2)); + } catch (e) { + console.error(`verify:main-source: ${e.message}`); + process.exit(2); + } + const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", + ); + const { code, output } = verifyMainSource({ ...opts, cwd: repoRoot }); + (code === 0 ? console.log : console.error)(output); + process.exit(code); +} diff --git a/scripts/verify-main-source.test.mjs b/scripts/verify-main-source.test.mjs new file mode 100644 index 0000000000..fab0b645dc --- /dev/null +++ b/scripts/verify-main-source.test.mjs @@ -0,0 +1,164 @@ +// Tests for `verify-main-source.mjs` (#5119): the arguments and the report, +// then the guard end to end against throwaway repositories shaped like the +// release flow: a merge PR from a branch pushed off `v2/main` passes, as does +// one `v2/main` has since moved past; a branch with a commit of its own, or a +// merge of `v2/main` into something else, fails. Run via `npm run +// test:scripts`. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { devNull, tmpdir } from "node:os"; +import path from "node:path"; +import { + DEFAULT_SOURCE, + MAX_LISTED, + formatReport, + parseArgs, + verifyMainSource, +} from "./verify-main-source.mjs"; + +test("parseArgs: --head is required, --source defaults to origin/v2/main", () => { + assert.deepEqual(parseArgs(["--head", "abc"]), { + source: DEFAULT_SOURCE, + head: "abc", + }); + assert.deepEqual(parseArgs(["--source", "v2/main", "--head", "abc"]), { + source: "v2/main", + head: "abc", + }); + assert.throws(() => parseArgs([]), /--head is required/); + assert.throws(() => parseArgs(["--head"]), /--head needs a revision/); + assert.throws(() => parseArgs(["abc"]), /unknown argument/); +}); + +test("formatReport: names the commits, caps the list, and says where the change belongs", () => { + const commits = Array.from( + { length: MAX_LISTED + 3 }, + (_, i) => `${String(i).padStart(7, "0")} commit ${i}`, + ); + const out = formatReport({ source: "origin/v2/main", head: "abc", commits }); + assert.match( + out, + new RegExp( + `abc carries ${MAX_LISTED + 3} commits that origin/v2/main does not`, + ), + ); + assert.match(out, /0000000 commit 0/); + assert.doesNotMatch(out, new RegExp(`commit ${MAX_LISTED}\\b`)); + assert.match(out, /… and 3 more/); + assert.match(out, /pure merge of `v2\/main`/); + assert.match( + formatReport({ source: "s", head: "h", commits: ["1234567 one"] }), + /carries 1 commit that s does not/, + ); +}); + +// End to end, against a real repository: `main` and `v2` stand in for `main` +// and `origin/v2/main`, sharing a root. + +function repo() { + const dir = mkdtempSync(path.join(tmpdir(), "verify-main-source-")); + const env = { + ...process.env, + GIT_CONFIG_GLOBAL: devNull, + GIT_CONFIG_NOSYSTEM: "1", + GIT_AUTHOR_NAME: "Ada Lovelace", + GIT_AUTHOR_EMAIL: "ada@example.com", + GIT_COMMITTER_NAME: "Ada Lovelace", + GIT_COMMITTER_EMAIL: "ada@example.com", + }; + const git = (...args) => { + const r = spawnSync("git", args, { cwd: dir, env, encoding: "utf8" }); + assert.equal(r.status, 0, `git ${args.join(" ")}: ${r.stderr}`); + return r.stdout.trim(); + }; + git("init", "-q", "-b", "main"); + git("commit", "-q", "--allow-empty", "-m", "root"); + git("switch", "-q", "-c", "v2"); + git("commit", "-q", "--allow-empty", "-m", "v2 work"); + return { + dir, + git, + check: (head) => verifyMainSource({ source: "v2", head, cwd: dir }), + done: () => rmSync(dir, { recursive: true, force: true }), + }; +} + +test("verifyMainSource: a release branch pushed from v2 passes", (t) => { + const r = repo(); + t.after(r.done); + r.git("branch", "release", "v2"); + const { code, output } = r.check("release"); + assert.equal(code, 0, output); + assert.match(output, /release is on v2/); +}); + +test("verifyMainSource: a head v2 has since moved past still passes", (t) => { + const r = repo(); + t.after(r.done); + r.git("branch", "release", "v2"); + r.git("commit", "-q", "--allow-empty", "-m", "later v2 work"); + assert.equal(r.check("release").code, 0); +}); + +test("verifyMainSource: a branch with a commit of its own fails, naming it", (t) => { + const r = repo(); + t.after(r.done); + r.git("switch", "-q", "-c", "topic", "v2"); + r.git("commit", "-q", "--allow-empty", "-m", "sneaky fix"); + const sha = r.git("rev-parse", "--short", "HEAD"); + const { code, output } = r.check("topic"); + assert.equal(code, 1); + assert.match(output, /topic carries 1 commit that v2 does not/); + assert.ok(output.includes(`${sha} sneaky fix`), output); + assert.doesNotMatch(output, /v2 work/); +}); + +test("verifyMainSource: a branch cut from main fails", (t) => { + const r = repo(); + t.after(r.done); + r.git("switch", "-q", "-c", "hotfix", "main"); + r.git("commit", "-q", "--allow-empty", "-m", "hotfix"); + const { code, output } = r.check("hotfix"); + assert.equal(code, 1); + assert.match(output, /hotfix carries 1 commit/); +}); + +test("verifyMainSource: v2 merged into another branch fails, merge commit and all", (t) => { + const r = repo(); + t.after(r.done); + r.git("switch", "-q", "-c", "mixed", "main"); + r.git("commit", "-q", "--allow-empty", "-m", "side work"); + r.git("merge", "-q", "--no-ff", "--no-edit", "v2"); + const { code, output } = r.check("mixed"); + assert.equal(code, 1); + assert.match(output, /mixed carries 2 commits/); + assert.match(output, /side work/); +}); + +test("verifyMainSource: an unresolvable source or head fails and says so", (t) => { + const r = repo(); + t.after(r.done); + const noSource = verifyMainSource({ + source: DEFAULT_SOURCE, + head: "HEAD", + cwd: r.dir, + }); + assert.equal(noSource.code, 1); + assert.match(noSource.output, /cannot resolve origin\/v2\/main\. Fetch it/); + const noHead = r.check("no-such-branch"); + assert.equal(noHead.code, 1); + assert.match(noHead.output, /cannot resolve no-such-branch\.$/); +}); + +test("verifyMainSource: run as a script, a bad argument exits 2", () => { + const r = spawnSync( + process.execPath, + [path.join(import.meta.dirname, "verify-main-source.mjs")], + { encoding: "utf8" }, + ); + assert.equal(r.status, 2); + assert.match(r.stderr, /--head is required/); +}); From 9422c3f2313d18483db8c706c5dfe4e9f2389b1c Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 11 Oct 2026 00:56:16 -0400 Subject: [PATCH 067/117] test: pin main-source.yml's safety properties; name the guard as a gate exception Addresses Copilot round 1 on #5120. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- docs/quality-gate.md | 7 +++- scripts/verify-main-source.test.mjs | 65 ++++++++++++++++++++++++++--- 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/docs/quality-gate.md b/docs/quality-gate.md index 85a0354cef..aa952d2f24 100644 --- a/docs/quality-gate.md +++ b/docs/quality-gate.md @@ -22,13 +22,18 @@ your machine. It is the strongest predictor of a green CI there is here, though not a proof: CI runs on Linux, on a fresh `npm ci`, with Node 22 and each Python server's pinned interpreter. -**The one exception is the Windows legs** (#1149): `test-windows` in both +**There are two exceptions.** The first is the Windows legs (#1149): `test-windows` in both workflows runs each server's test suite (`npm test`, `pytest`) on a `windows-latest` runner. The gate runs the same suites, but only on your machine's OS, so a Windows-only failure (a drive letter, a UNC path, a missing `O_NOFOLLOW`, a locked file) shows up first in CI. When a PR touches platform-specific code, wait for those legs, not just the gate. +The second is **Source is v2/main** (`main-source.yml`), which runs only on a +pull request into `main` and checks that its head is already on `v2/main`. No +local push targets `main`, so the gate has nothing to check there and no stage +mirrors it. + The inner-loop commands are subsets. They are for speed while working, and are never a substitute for the gate. diff --git a/scripts/verify-main-source.test.mjs b/scripts/verify-main-source.test.mjs index fab0b645dc..2f457a7c4c 100644 --- a/scripts/verify-main-source.test.mjs +++ b/scripts/verify-main-source.test.mjs @@ -1,16 +1,17 @@ // Tests for `verify-main-source.mjs` (#5119): the arguments and the report, -// then the guard end to end against throwaway repositories shaped like the -// release flow: a merge PR from a branch pushed off `v2/main` passes, as does +// the guard end to end against throwaway repositories shaped like the +// release flow, a merge PR from a branch pushed off `v2/main` passes, as does // one `v2/main` has since moved past; a branch with a commit of its own, or a -// merge of `v2/main` into something else, fails. Run via `npm run -// test:scripts`. +// merge of `v2/main` into something else, fails. Last, the properties of +// `main-source.yml` its safety rests on. Run via `npm run test:scripts`. import { test } from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { devNull, tmpdir } from "node:os"; import path from "node:path"; +import { parse } from "yaml"; import { DEFAULT_SOURCE, MAX_LISTED, @@ -162,3 +163,57 @@ test("verifyMainSource: run as a script, a bad argument exits 2", () => { assert.equal(r.status, 2); assert.match(r.stderr, /--head is required/); }); + +// The workflow's safety rests on properties of its YAML; pin them, so an edit +// that would run the PR's code, or stop wiring the guard, fails here. +const workflow = parse( + readFileSync( + path.join(import.meta.dirname, "..", ".github/workflows/main-source.yml"), + "utf8", + ), +); +const job = workflow.jobs["main-source"]; + +test("main-source.yml: runs main's copy, on PRs into main only, read-only", () => { + assert.deepEqual(Object.keys(workflow.on), ["pull_request_target"]); + assert.deepEqual(workflow.on.pull_request_target.branches, ["main"]); + assert.ok(workflow.on.pull_request_target.types.includes("edited")); + assert.deepEqual(workflow.permissions, { contents: "read" }); + assert.equal(job.permissions, undefined); + assert.deepEqual(Object.keys(workflow.jobs), ["main-source"]); + assert.equal(job.name, "Source is v2/main"); + assert.ok(job["timeout-minutes"]); +}); + +test("main-source.yml: checks out main, never the PR, and fetches the PR as objects only", () => { + const checkout = job.steps.filter((s) => + /actions\/checkout/.test(s.uses ?? ""), + ); + assert.equal(checkout.length, 1); + assert.equal(checkout[0].with?.ref, undefined); + assert.equal(checkout[0].with?.repository, undefined); + assert.equal(checkout[0].with["persist-credentials"], false); + const runs = job.steps.map((s) => s.run ?? "").join("\n"); + const refspec = '"+refs/pull/${PR_NUMBER}/head:refs/remotes/pr/head"'; + assert.ok(runs.includes(refspec), "the PR head is fetched"); + // Past that fetch, nothing names the PR's ref, or switches to, installs or + // executes a tree. + const rest = runs.replace(refspec, ""); + assert.doesNotMatch(rest, /pr\/head|refs\/pull/); + assert.doesNotMatch(rest, /git (checkout|switch|reset|worktree)|npm |npx /); + // No step reads a secret; expressions reach the shell only through env. + for (const step of job.steps) { + assert.doesNotMatch(JSON.stringify(step), /secrets\./); + assert.doesNotMatch(step.run ?? "", /\$\{\{/); + } +}); + +test("main-source.yml: hands the event's head SHA to the guard", () => { + const step = job.steps.find((s) => + /verify-main-source\.mjs/.test(s.run ?? ""), + ); + assert.ok(step, "a step runs the guard"); + assert.equal(step.env.HEAD_SHA, "${{ github.event.pull_request.head.sha }}"); + assert.match(step.run, /--head "\$HEAD_SHA"/); + assert.doesNotMatch(step.run, /--source/); +}); From c9c6e0e044248e427a89811d1ca449ec9df3809d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 11 Oct 2026 01:05:04 -0400 Subject: [PATCH 068/117] docs(release): the admin escape hatch for a release that cannot be a pure merge Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .claude/skills/release/SKILL.md | 29 +++++++++++++++++++++++++++++ AGENTS.md | 6 +++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 54181fa48f..e6914eb7d9 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -283,6 +283,35 @@ reaches `main` through `v2/main`, so there is nothing on `main` to bring back except the merge commits themselves. `v2/main` ahead of `main` means a release is in flight; that is the normal state. +### A release that cannot be a pure merge + +Sometimes `v2/main` holds work that must not ship yet, and the release has to +come from a branch of its own: a release branch with cherry-picks (1.0.0 +shipped that way, from `v2/release/1.0.0` in #5090), or a hotfix. The required +**Source is v2/main** check fails that PR, by design, and nobody can bypass +it, admins included. The only way through is a deliberate act by a repo +admin: disable the `main: milestone merges only` ruleset for that one merge, +and turn it straight back on. + +```sh +RS=$(gh api repos/modelcontextprotocol/servers/rulesets \ + --jq '.[] | select(.name == "main: milestone merges only") | .id') +[ -n "$RS" ] || echo "ruleset not found by name; list them and look" >&2 +gh api -X PUT "repos/modelcontextprotocol/servers/rulesets/$RS" -f enforcement=disabled +# Merge the PR now, with "Create a merge commit". With the ruleset off, +# squash and rebase are offered again; do not use them. +gh api -X PUT "repos/modelcontextprotocol/servers/rulesets/$RS" -f enforcement=active +gh api "repos/modelcontextprotocol/servers/rulesets/$RS" --jq .enforcement # must print "active" +``` + +- **Only a human admin does this**, and only for a PR whose diff has been + reviewed as the release. Never leave the ruleset disabled between steps. +- **Record it in the release ledger**: the PR, why it could not be a pure + merge, and the times the ruleset was disabled and re-enabled. +- **Then merge the release branch back into `v2/main`** through a PR of its + own (#5118 did this for 1.0.0), so its commits are on `v2/main` and the + next milestone merge is a pure one again. + ## 4. Verify the release candidate, and write the ledger Work from a **dedicated worktree** with its own install, so nothing stale from diff --git a/AGENTS.md b/AGENTS.md index 6af5b1eb19..9f90d72e78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -361,7 +361,11 @@ holds for a maintainer's own one-line fix as much as for a feature. is merged with a merge commit, never squashed. Both are enforced on `main`: its ruleset allows only merge commits, and requires the **Source is v2/main** check (`main-source.yml`), which fails a PR whose head carries - any commit not already on `v2/main`. The flow is the `release` skill. + any commit not already on `v2/main`. That ruleset has no bypass actors; a + release that cannot be a pure merge (a release branch, a hotfix) goes + through only by a repo admin disabling it for that one merge and + re-enabling it at once, which is never automated. The flow is the + `release` skill. - **Exception: the two version PRs that automation opens.** Each changes version metadata only, so neither answers the PR checklist below, and each is tied to its milestone's **release issue** rather than to an issue of its From 8002268bdc62853ea2fe453b8a39a4148506d469 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 11 Oct 2026 01:15:49 -0400 Subject: [PATCH 069/117] docs(release): run the escape hatch's disable, merge and re-enable under one exit trap Addresses Copilot round 3 on #5120. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .claude/skills/release/SKILL.md | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index e6914eb7d9..99090c68c2 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -293,19 +293,39 @@ it, admins included. The only way through is a deliberate act by a repo admin: disable the `main: milestone merges only` ruleset for that one merge, and turn it straight back on. +The disable, the merge and the re-enable run as one command, in a subshell +whose exit trap re-enables the ruleset however it ends: the merge failing, an +error, or Ctrl-C. Approve the PR first; the other rulesets on `main` still +require that. + ```sh +PR= RS=$(gh api repos/modelcontextprotocol/servers/rulesets \ --jq '.[] | select(.name == "main: milestone merges only") | .id') [ -n "$RS" ] || echo "ruleset not found by name; list them and look" >&2 -gh api -X PUT "repos/modelcontextprotocol/servers/rulesets/$RS" -f enforcement=disabled -# Merge the PR now, with "Create a merge commit". With the ruleset off, -# squash and rebase are offered again; do not use them. -gh api -X PUT "repos/modelcontextprotocol/servers/rulesets/$RS" -f enforcement=active +[ -n "$RS" ] && ( + set -e + restore() { + gh api -X PUT "repos/modelcontextprotocol/servers/rulesets/$RS" \ + -f enforcement=active --jq '"ruleset: \(.enforcement)"' + } + trap restore EXIT + trap 'exit 130' INT TERM HUP # exit, so the EXIT trap restores + gh api -X PUT "repos/modelcontextprotocol/servers/rulesets/$RS" \ + -f enforcement=disabled >/dev/null + # --merge: with the ruleset off, squash and rebase are allowed again. + gh pr merge "$PR" --repo modelcontextprotocol/servers --merge +) gh api "repos/modelcontextprotocol/servers/rulesets/$RS" --jq .enforcement # must print "active" ``` +A trap cannot run if the machine itself dies mid-command, so the last line +is not optional: if it prints anything but `active`, set it back by hand +(Settings → Rules → Rulesets) before doing anything else. + - **Only a human admin does this**, and only for a PR whose diff has been - reviewed as the release. Never leave the ruleset disabled between steps. + reviewed as the release. Never disable the ruleset outside the command + above. - **Record it in the release ledger**: the PR, why it could not be a pure merge, and the times the ruleset was disabled and re-enabled. - **Then merge the release branch back into `v2/main`** through a PR of its From 38fa696473ec9d83c87b470155693c174440c15d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 11 Oct 2026 01:40:37 -0400 Subject: [PATCH 070/117] feat(fetch,git,time): serve the 2026-07-28 era beside 2025-11-25 Each Python server's serve() now drives Server.run(), the SDK's dual-era loop, instead of the legacy-only serve_loop: a 2026-07-28 client is served sessionlessly (server/discover, per-request envelopes) and a 2025-11-25 client through the initialize handshake, the client's first request deciding which. No handler reads the session or calls back to the client, so tool and prompt behavior is identical in both eras; the SDK fills in the 2026-07-28 result fields (resultType, ttlMs, cacheScope). Tests: a tests/test_eras.py per server runs a real mcp 2.x Client, with mode pinned, against serve() over the stdio JSON-RPC path in each era. test_modern_era_is_not_served, #4851's placeholder, becomes a raw-wire pin of the server/discover answer. READMEs and the client-smoke skill say the Python servers serve both eras. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .claude/skills/client-smoke/SKILL.md | 26 +-- src/fetch/README.md | 6 +- src/fetch/src/mcp_server_fetch/server.py | 25 ++- src/fetch/tests/test_eras.py | 182 +++++++++++++++++++++ src/fetch/tests/test_protocol.py | 39 +++-- src/git/README.md | 9 +- src/git/src/mcp_server_git/server.py | 25 ++- src/git/tests/test_eras.py | 198 +++++++++++++++++++++++ src/git/tests/test_protocol.py | 36 +++-- src/git/tests/test_server.py | 11 +- src/time/README.md | 7 +- src/time/src/mcp_server_time/server.py | 25 ++- src/time/tests/test_eras.py | 157 ++++++++++++++++++ src/time/tests/test_protocol.py | 36 +++-- 14 files changed, 673 insertions(+), 109 deletions(-) create mode 100644 src/fetch/tests/test_eras.py create mode 100644 src/git/tests/test_eras.py create mode 100644 src/time/tests/test_eras.py diff --git a/.claude/skills/client-smoke/SKILL.md b/.claude/skills/client-smoke/SKILL.md index a650902175..cbe9a60222 100644 --- a/.claude/skills/client-smoke/SKILL.md +++ b/.claude/skills/client-smoke/SKILL.md @@ -172,10 +172,14 @@ CLI) or **Server Settings → Protocol Era** (the web UI): | `modern` | Pins 2026-07-28 through `server/discover`, with no fallback | | `auto` | Probes `server/discover`, and falls back to `initialize` | -⚠️ **Only the legacy era can be exercised against these servers today.** The -Python servers are on `mcp` 1.x, whose newest protocol version is 2025-11-25. -The TypeScript servers are on the TS SDK v2 packages (#4856) but still serve -only the `initialize` handshake. None implements `server/discover`. So: +**The Python servers serve both eras** (#4853): run every command twice, with +`--protocol-era legacy` and `--protocol-era modern`, and expect both to +succeed. A 2026-07-28 result carries `io.modelcontextprotocol/serverInfo` in +its `_meta`, which is the quick way to see which era answered. + +⚠️ **The TypeScript servers serve only the legacy era today.** They are on the +TS SDK v2 packages (#4856) but still serve only the `initialize` handshake, +with no `server/discover`. Against them: - `--protocol-era modern` fails, by design, with `Version negotiation failed: the server did not offer pinned protocol version 2026-07-28 via @@ -185,15 +189,11 @@ only the `initialize` handshake. None implements `server/discover`. So: connection and proves nothing about 2026-07-28. The both-eras check that `/pr-flow` asks for (#4857) therefore has one half -that can pass today. For a server that does not yet serve the modern era, -record the legacy run, and say in the PR that the server does not yet speak -2026-07-28 rather than leaving the modern era unmentioned. Moving a server to -a v2 SDK (#4856 for TypeScript, #4851 for Python) does not change that by -itself: those keep the legacy wire behavior. Modern-era support arrives with -#4852 (TypeScript) and #4853 (Python). Once a server has it, run every command -twice, with `--protocol-era legacy` and `--protocol-era modern`, and expect -both to succeed. Do not use `auto` for evidence in either case: it -does not say which era answered. +that can pass for a TypeScript server today. Record the legacy run, and say in +the PR that the server does not yet speak 2026-07-28 rather than leaving the +modern era unmentioned. Modern-era support for them arrives with #4852; from +then on, run both eras as for the Python servers. Do not use `auto` for +evidence in either case: it does not say which era answered. ## The Inspector web UI diff --git a/src/fetch/README.md b/src/fetch/README.md index 295512bbbd..7d83961207 100644 --- a/src/fetch/README.md +++ b/src/fetch/README.md @@ -31,6 +31,10 @@ The fetch tool will truncate the response, but by using the `start_index` argume - Arguments: - `url` (string, required): URL to fetch +### Protocol versions + +The server speaks both eras of the Model Context Protocol over stdio, with nothing to configure: a **2026-07-28** client (discovered with `server/discover`, no session) and a **2025-11-25** client (the `initialize` handshake). The client's first request decides which. Tools and prompts behave the same in both. Methods the 2026-07-28 specification removes, such as `ping`, are answered only on a 2025-11-25 connection. + ## Installation Optionally: Install node.js, this will cause the fetch server to use a different HTML simplifier that is more robust. @@ -259,7 +263,7 @@ uv sync --frozen --all-extras --dev uv run --frozen pytest ``` -The tests drive `serve()` through an MCP `ClientSession` in-process, with HTTP mocked, so they need no network. The HTML extraction tests need Node.js on `PATH` (readabilipy uses Readability.js when Node is present and a pure-Python fallback when it is not). +The tests drive `serve()` through an MCP `ClientSession` in-process (and, in `tests/test_eras.py`, through a `Client` as a 2026-07-28 and as a 2025-11-25 client), with HTTP mocked, so they need no network. The HTML extraction tests need Node.js on `PATH` (readabilipy uses Readability.js when Node is present and a pure-Python fallback when it is not). Coverage, measured per file with branch coverage: diff --git a/src/fetch/src/mcp_server_fetch/server.py b/src/fetch/src/mcp_server_fetch/server.py index 41824a2099..4447f81e88 100644 --- a/src/fetch/src/mcp_server_fetch/server.py +++ b/src/fetch/src/mcp_server_fetch/server.py @@ -7,8 +7,10 @@ # Built on the MCP Python SDK v2's low-level `Server` (#4851). That SDK no # longer validates tool arguments or folds tool exceptions into `isError` # results, so `call_tool` does both with SDK v1's messages, keeping tool -# results as they were on v1. `serve()` runs the legacy (2025-11-25) handshake -# loop only; serving 2026-07-28 is #4853. +# results as they were on v1. `serve()` serves both protocol eras (#4853): a +# 2025-11-25 client through the initialize handshake, and a 2026-07-28 client +# sessionlessly. No handler reads the session or calls back to the client +# (roots, sampling, elicitation, logging), so nothing differs between them. import asyncio import ipaddress @@ -24,7 +26,6 @@ import readabilipy.simple_json from mcp.shared.exceptions import MCPError from mcp.server import Server, ServerRequestContext -from mcp.server.runner import serve_loop from mcp.server.stdio import stdio_server from mcp.types import ( CallToolRequestParams, @@ -558,15 +559,9 @@ async def get_prompt( options = server.create_initialization_options() async with stdio_server() as (read_stream, write_stream): - # Legacy era only. Server.run() would also serve 2026-07-28 (its - # dual-era loop answers server/discover and per-request envelopes); - # adopting that era is #4853, so this port (#4851) serves the - # handshake loop alone, the only era it served on SDK v1. - async with server.lifespan(server) as lifespan_state: - await serve_loop( - server, - read_stream, - write_stream, - lifespan_state=lifespan_state, - init_options=options, - ) + # Both eras (#4853): Server.run()'s dual-era loop lets the client's + # first request decide. `initialize` gets the 2025-11-25 handshake; + # `server/discover` or a request carrying a 2026-07-28 envelope gets + # the modern, sessionless era, with its required result fields + # (resultType, ttlMs, cacheScope) filled in by the SDK. + await server.run(read_stream, write_stream, options) diff --git a/src/fetch/tests/test_eras.py b/src/fetch/tests/test_eras.py new file mode 100644 index 0000000000..88169b75ac --- /dev/null +++ b/src/fetch/tests/test_eras.py @@ -0,0 +1,182 @@ +"""Both protocol eras, driven through a real ``mcp`` 2.x ``Client`` (#4853). + +serve() answers a 2025-11-25 client through the initialize handshake and a +2026-07-28 client sessionlessly, the client's first request deciding which. +Each test here pins the client's ``mode`` explicitly (never "auto" alone), so a +test names the era it covers and cannot pass by falling back to the other one. +The client talks to the real ``serve()`` over in-memory streams, the same +JSON-RPC path stdio takes, rather than the SDK's in-process shortcut, which +would bypass serve() and its era dispatch. ``web`` (harness.py) keeps every +request off the network. + +The legacy-era characterization tests in test_protocol.py stay as the +backward-compatibility net; this file adds what differs by era and shows that +the rest does not. +""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Any +from unittest.mock import patch + +import anyio +import httpx +import pytest +from mcp import Client +from mcp.client._transport import TransportStreams +from mcp.shared.exceptions import MCPError +from mcp.shared.memory import create_client_server_memory_streams +from mcp.types import ( + INVALID_PARAMS, + METHOD_NOT_FOUND, + CallToolResult, + TextContent, +) + +from mcp_server_fetch.server import serve + +from .harness import FakeWeb + +PAGE = "https://example.com/page" +MODERN = "2026-07-28" +LEGACY = "2025-11-25" +# Each `mode` a test pins, with the protocol version it must negotiate. +ERAS = [ + pytest.param(MODERN, MODERN, id=MODERN), + pytest.param("legacy", LEGACY, id=LEGACY), +] + + +@asynccontextmanager +async def connect(mode: str, **serve_kwargs: Any) -> AsyncIterator[Client]: + """Run ``serve(**serve_kwargs)`` in-process and yield a ``Client`` in ``mode``.""" + async with create_client_server_memory_streams() as ( + client_streams, + server_streams, + ): + + @asynccontextmanager + async def fake_stdio_server() -> AsyncIterator[Any]: + yield server_streams + + @asynccontextmanager + async def transport() -> AsyncIterator[TransportStreams]: + yield client_streams + + with patch("mcp_server_fetch.server.stdio_server", fake_stdio_server): + async with anyio.create_task_group() as tg: + tg.start_soon(lambda: serve(**serve_kwargs)) + async with Client(transport(), mode=mode) as client: + yield client + tg.cancel_scope.cancel() + + +def plain(body: str) -> httpx.Response: + return httpx.Response( + 200, content=body.encode(), headers={"content-type": "text/plain"} + ) + + +def text_of(result: CallToolResult) -> str: + """The text of a result holding one text block.""" + assert len(result.content) == 1 + block = result.content[0] + assert isinstance(block, TextContent) + return block.text + + +def wire_list(items: Any) -> list[dict[str, Any]]: + return [i.model_dump(by_alias=True, mode="json", exclude_none=True) for i in items] + + +@pytest.mark.parametrize(("mode", "negotiated"), ERAS) +async def test_each_era_is_negotiated(web: FakeWeb, mode: str, negotiated: str) -> None: + async with connect(mode) as client: + assert client.session.protocol_version == negotiated + + +async def test_auto_mode_discovers_the_2026_07_28_era(web: FakeWeb) -> None: + # An auto client probes server/discover first and only falls back to the + # handshake on a server that refuses it; this server no longer does. + async with connect("auto") as client: + assert client.session.protocol_version == MODERN + + +async def test_tools_and_prompts_lists_are_the_same_in_both_eras( + web: FakeWeb, +) -> None: + # Same tools and prompts, same schemas, same order, in either era. + async with connect(MODERN) as client: + modern_tools = wire_list((await client.list_tools()).tools) + modern_prompts = wire_list((await client.list_prompts()).prompts) + async with connect("legacy") as client: + legacy_tools = wire_list((await client.list_tools()).tools) + legacy_prompts = wire_list((await client.list_prompts()).prompts) + assert [t["name"] for t in modern_tools] == ["fetch"] + assert [p["name"] for p in modern_prompts] == ["fetch"] + assert modern_tools == legacy_tools + assert modern_prompts == legacy_prompts + + +@pytest.mark.parametrize(("mode", "negotiated"), ERAS) +async def test_tool_call_succeeds_in_each_era( + web: FakeWeb, mode: str, negotiated: str +) -> None: + web.add(PAGE, plain("hello from both eras")) + async with connect(mode) as client: + result = await client.call_tool("fetch", {"url": PAGE}) + assert result.is_error is False + assert result.result_type == "complete" + assert text_of(result).endswith(f"Contents of {PAGE}:\nhello from both eras") + + +@pytest.mark.parametrize(("mode", "negotiated"), ERAS) +async def test_prompt_succeeds_in_each_era( + web: FakeWeb, mode: str, negotiated: str +) -> None: + web.add(PAGE, plain("prompted")) + async with connect(mode) as client: + result = await client.get_prompt("fetch", {"url": PAGE}) + assert result.description == f"Contents of {PAGE}" + content = result.messages[0].content + assert isinstance(content, TextContent) + assert content.text.endswith("prompted") + + +@pytest.mark.parametrize(("mode", "negotiated"), ERAS) +async def test_tool_failure_is_an_is_error_result_in_each_era( + web: FakeWeb, mode: str, negotiated: str +) -> None: + async with connect(mode) as client: + missing = await client.call_tool("fetch", {}) + unknown = await client.call_tool("no_such_tool", {}) + assert missing.is_error is True + assert text_of(missing) == "Input validation error: 'url' is a required property" + assert unknown.is_error is True + assert text_of(unknown) == "Unknown tool: no_such_tool" + + +@pytest.mark.parametrize(("mode", "negotiated"), ERAS) +async def test_prompt_failure_is_a_json_rpc_error_in_each_era( + web: FakeWeb, mode: str, negotiated: str +) -> None: + # get_prompt raises MCPError, which reaches the client as a JSON-RPC error + # rather than a result, and an undeclared method is one too. + async with connect(mode) as client: + with pytest.raises(MCPError) as unknown_prompt: + await client.get_prompt("no_such_prompt", {"url": PAGE}) + with pytest.raises(MCPError) as no_resources: + await client.list_resources() + assert unknown_prompt.value.error.code == INVALID_PARAMS + assert unknown_prompt.value.error.message == "Unknown prompt: no_such_prompt" + assert no_resources.value.error.code == METHOD_NOT_FOUND + + +async def test_ping_is_served_only_in_the_legacy_era(web: FakeWeb) -> None: + # ping is removed in 2026-07-28; the legacy era keeps answering it. + async with connect("legacy") as client: + await client.session.send_ping() + async with connect(MODERN) as client: + with pytest.raises(MCPError) as excinfo: + await client.session.send_ping() + assert excinfo.value.error.code == METHOD_NOT_FOUND diff --git a/src/fetch/tests/test_protocol.py b/src/fetch/tests/test_protocol.py index 325e546301..2c00dc2760 100644 --- a/src/fetch/tests/test_protocol.py +++ b/src/fetch/tests/test_protocol.py @@ -29,7 +29,7 @@ from mcp.shared.exceptions import MCPError from mcp.shared.memory import create_client_server_memory_streams from mcp.shared.message import SessionMessage -from mcp.types import INVALID_PARAMS, METHOD_NOT_FOUND, CallToolResult, JSONRPCRequest +from mcp.types import INVALID_PARAMS, CallToolResult, JSONRPCRequest from mcp_server_fetch.server import ( DEFAULT_USER_AGENT_AUTONOMOUS, @@ -1019,16 +1019,16 @@ async def test_get_prompt_does_not_validate_the_url( # --------------------------------------------------------------------------- -async def test_modern_era_is_not_served() -> None: - # #4851: the SDK v2 port serves the 2025-11-25 handshake era only. - # Server.run() would also open a 2026-07-28 connection for a request that - # carries the per-request `_meta` envelope; adopting that era is #4853. - # Until then such a request is refused as an unknown method. +async def test_server_discover_answers_the_2026_07_28_era() -> None: + # #4853: serve() serves the 2026-07-28 era beside the 2025-11-25 handshake. + # A `server/discover` carrying the per-request `_meta` envelope opens it. + # Asserted on the raw JSON-RPC response, so the fields the 2026-07-28 + # spec requires on a result (resultType, ttlMs, cacheScope) are pinned as + # the server sends them, not as a client model would default them. Era by + # era behavior through a real client is in test_eras.py. # - # Behavior change in the SDK v2 port: SDK v1 answered every unknown - # method with -32602 "Invalid request parameters" (its request union - # failed to parse); SDK v2 answers -32601 "Method not found", the code - # JSON-RPC defines for it. + # Replaces #4851's test_modern_era_is_not_served, which pinned this request + # being refused with -32601 "Method not found" until this issue landed. discover = JSONRPCRequest( jsonrpc="2.0", id=1, @@ -1063,9 +1063,20 @@ async def fake_stdio_server() -> AsyncIterator[Any]: assert response.message.model_dump(by_alias=True, exclude_none=True) == { "jsonrpc": "2.0", "id": 1, - "error": { - "code": METHOD_NOT_FOUND, - "message": "Method not found", - "data": "server/discover", + "result": { + "capabilities": { + "prompts": {"listChanged": False}, + "tools": {"listChanged": False}, + }, + "supportedVersions": ["2026-07-28"], + "resultType": "complete", + "ttlMs": 0, + "cacheScope": "private", + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": "mcp-fetch", + "version": version("mcp-server-fetch"), + } + }, }, } diff --git a/src/git/README.md b/src/git/README.md index 45e96b8121..43a9d132a2 100644 --- a/src/git/README.md +++ b/src/git/README.md @@ -100,6 +100,12 @@ Please note that mcp-server-git is currently in early development. The functiona - `not_contains` (string, optional): The commit sha that branch should NOT contain. Do not pass anything to this param if no commit sha is specified - Returns: List of branches +### Protocol versions + +The server speaks both eras of the Model Context Protocol over stdio, with nothing to configure: a **2026-07-28** client (discovered with `server/discover`, no session) and a **2025-11-25** client (the `initialize` handshake). The client's first request decides which. Tools behave the same in both. + +Roots is deprecated in 2026-07-28, and this server never asks a client for Roots in either era: repository access comes from `--repository` alone, or, with no `--repository`, from each tool call's `repo_path`. Methods the 2026-07-28 specification removes, such as `ping`, are answered only on a 2025-11-25 connection. + ## Installation ### Using uv (recommended) @@ -295,7 +301,8 @@ help you debug any issues. ### Tests and coverage The test suite drives `serve()` in-process through an MCP `ClientSession` -(see `tests/conftest.py`). From `src/git`: +(see `tests/conftest.py`), and `tests/test_eras.py` runs a `Client` against it +as a 2026-07-28 and as a 2025-11-25 client. From `src/git`: ```bash uv run --frozen pytest # the tests diff --git a/src/git/src/mcp_server_git/server.py b/src/git/src/mcp_server_git/server.py index 90262e8b55..24a3195381 100644 --- a/src/git/src/mcp_server_git/server.py +++ b/src/git/src/mcp_server_git/server.py @@ -6,8 +6,10 @@ # Built on the MCP Python SDK v2's low-level `Server` (#4851). That SDK no # longer validates tool arguments or folds tool exceptions into `isError` # results, so `call_tool` does both with SDK v1's messages, keeping tool -# results as they were on v1. `serve()` runs the legacy (2025-11-25) handshake -# loop only; serving 2026-07-28 is #4853. +# results as they were on v1. `serve()` serves both protocol eras (#4853): a +# 2025-11-25 client through the initialize handshake, and a 2026-07-28 client +# sessionlessly. No handler reads the session or calls back to the client +# (roots, sampling, elicitation, logging), so nothing differs between them. import logging from importlib.metadata import version @@ -15,7 +17,6 @@ from typing import Any, Optional import jsonschema from mcp.server import Server, ServerRequestContext -from mcp.server.runner import serve_loop from mcp.server.stdio import stdio_server from mcp.types import ( CallToolRequestParams, @@ -772,15 +773,9 @@ async def call_tool( ) options = server.create_initialization_options() async with stdio_server() as (read_stream, write_stream): - # Legacy era only. Server.run() would also serve 2026-07-28 (its - # dual-era loop answers server/discover and per-request envelopes); - # adopting that era is #4853, so this port (#4851) serves the - # handshake loop alone, the only era it served on SDK v1. - async with server.lifespan(server) as lifespan_state: - await serve_loop( - server, - read_stream, - write_stream, - lifespan_state=lifespan_state, - init_options=options, - ) + # Both eras (#4853): Server.run()'s dual-era loop lets the client's + # first request decide. `initialize` gets the 2025-11-25 handshake; + # `server/discover` or a request carrying a 2026-07-28 envelope gets + # the modern, sessionless era, with its required result fields + # (resultType, ttlMs, cacheScope) filled in by the SDK. + await server.run(read_stream, write_stream, options) diff --git a/src/git/tests/test_eras.py b/src/git/tests/test_eras.py new file mode 100644 index 0000000000..19a4cd72a5 --- /dev/null +++ b/src/git/tests/test_eras.py @@ -0,0 +1,198 @@ +# Both protocol eras, driven through a real `mcp` 2.x `Client` (#4853). +# +# serve() answers a 2025-11-25 client through the initialize handshake and a +# 2026-07-28 client sessionlessly, the client's first request deciding which. +# Each test here pins the client's `mode` explicitly (never "auto" alone), so +# a test names the era it covers and cannot pass by falling back to the other +# one. The client talks to the real `serve()` over in-memory streams, the same +# JSON-RPC path stdio takes, rather than the SDK's in-process shortcut, which +# would bypass serve() and its era dispatch. +# +# Roots is deprecated in 2026-07-28, and a 2026-07-28 client cannot be asked +# for it at all, so repository access must come from `--repository` (or from +# `repo_path` alone when none is set). These clients offer no Roots, and the +# server never asks for them (test_protocol.py pins that on the legacy era). +# +# The legacy-era characterization tests in test_protocol.py stay as the +# backward-compatibility net; this file adds what differs by era and shows +# that the rest does not. + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from pathlib import Path +from typing import Any +from unittest.mock import patch + +import anyio +import git +import pytest +from mcp import Client +from mcp.client._transport import TransportStreams +from mcp.shared.exceptions import MCPError +from mcp.shared.memory import create_client_server_memory_streams +from mcp.types import METHOD_NOT_FOUND, CallToolResult, TextContent + +from mcp_server_git.server import serve + +from conftest import make_repo + +MODERN = "2026-07-28" +LEGACY = "2025-11-25" +# Each `mode` a test pins, with the protocol version it must negotiate. +ERAS = [ + pytest.param(MODERN, MODERN, id=MODERN), + pytest.param("legacy", LEGACY, id=LEGACY), +] + + +@asynccontextmanager +async def connect(mode: str, repository: Path | None) -> AsyncIterator[Client]: + """Run `serve(repository)` in-process and yield a `Client` in `mode`.""" + async with create_client_server_memory_streams() as ( + client_streams, + server_streams, + ): + + @asynccontextmanager + async def fake_stdio_server() -> AsyncIterator[Any]: + yield server_streams + + @asynccontextmanager + async def transport() -> AsyncIterator[TransportStreams]: + yield client_streams + + with patch("mcp_server_git.server.stdio_server", fake_stdio_server): + async with anyio.create_task_group() as tg: + tg.start_soon(serve, repository) + async with Client(transport(), mode=mode) as client: + yield client + tg.cancel_scope.cancel() + + +def text_of(result: CallToolResult) -> str: + """The text of a result holding one text block.""" + assert len(result.content) == 1 + block = result.content[0] + assert isinstance(block, TextContent) + return block.text + + +def tool_wire(tools: Any) -> list[dict[str, Any]]: + return [t.model_dump(by_alias=True, mode="json", exclude_none=True) for t in tools] + + +def repo_dir(repo: git.Repo) -> Path: + return Path(repo.working_dir) + + +@pytest.mark.parametrize(("mode", "negotiated"), ERAS) +async def test_each_era_is_negotiated( + repo: git.Repo, mode: str, negotiated: str +) -> None: + async with connect(mode, repo_dir(repo)) as client: + assert client.session.protocol_version == negotiated + + +async def test_auto_mode_discovers_the_2026_07_28_era(repo: git.Repo) -> None: + # An auto client probes server/discover first and only falls back to the + # handshake on a server that refuses it; this server no longer does. + async with connect("auto", repo_dir(repo)) as client: + assert client.session.protocol_version == MODERN + + +async def test_tools_list_is_the_same_in_both_eras(repo: git.Repo) -> None: + # Same tools, same schemas, same deterministic order, in either era and on + # every call. + async with connect(MODERN, repo_dir(repo)) as client: + modern = tool_wire((await client.list_tools()).tools) + again = tool_wire((await client.list_tools()).tools) + async with connect("legacy", repo_dir(repo)) as client: + legacy = tool_wire((await client.list_tools()).tools) + assert [t["name"] for t in modern][:3] == [ + "git_status", + "git_diff_unstaged", + "git_diff_staged", + ] + assert modern == again == legacy + + +@pytest.mark.parametrize(("mode", "negotiated"), ERAS) +async def test_repository_access_without_roots( + repo: git.Repo, mode: str, negotiated: str +) -> None: + # --repository alone grants access; no Roots are offered or requested. + (repo_dir(repo) / "new.txt").write_text("untracked\n") + async with connect(mode, repo_dir(repo)) as client: + result = await client.call_tool( + "git_status", {"repo_path": str(repo_dir(repo))} + ) + assert result.is_error is False + assert result.result_type == "complete" + assert text_of(result).startswith("Repository status:\n") + assert "new.txt" in text_of(result) + + +async def test_repo_path_alone_grants_access_on_2026_07_28_with_no_repository( + repo: git.Repo, +) -> None: + # With no --repository the server is unconfined, as on the legacy era, and + # still never falls back to asking the client for Roots. + async with connect(MODERN, None) as client: + result = await client.call_tool( + "git_log", {"repo_path": str(repo_dir(repo)), "max_count": 1} + ) + assert result.is_error is False + assert "initial commit" in text_of(result) + + +@pytest.mark.parametrize(("mode", "negotiated"), ERAS) +async def test_repository_confinement_holds_in_each_era( + tmp_path: Path, repo: git.Repo, mode: str, negotiated: str +) -> None: + elsewhere = make_repo(tmp_path / "elsewhere") + try: + async with connect(mode, repo_dir(repo)) as client: + result = await client.call_tool( + "git_status", {"repo_path": elsewhere.working_dir} + ) + finally: + elsewhere.close() + assert result.is_error is True + assert "is outside the allowed repository" in text_of(result) + + +@pytest.mark.parametrize(("mode", "negotiated"), ERAS) +async def test_tool_failure_is_an_is_error_result_in_each_era( + repo: git.Repo, mode: str, negotiated: str +) -> None: + async with connect(mode, repo_dir(repo)) as client: + missing = await client.call_tool("git_status", {}) + unknown = await client.call_tool("no_such_tool", {}) + assert missing.is_error is True + assert text_of(missing) == ( + "Input validation error: 'repo_path' is a required property" + ) + assert unknown.is_error is True + assert text_of(unknown) == "Unknown tool: no_such_tool" + + +@pytest.mark.parametrize(("mode", "negotiated"), ERAS) +async def test_unsupported_method_is_a_json_rpc_error_in_each_era( + repo: git.Repo, mode: str, negotiated: str +) -> None: + # The server declares no resources, so resources/list is a protocol error + # (an MCPError on the client), not an isError result. + async with connect(mode, repo_dir(repo)) as client: + with pytest.raises(MCPError) as excinfo: + await client.list_resources() + assert excinfo.value.error.code == METHOD_NOT_FOUND + + +async def test_ping_is_served_only_in_the_legacy_era(repo: git.Repo) -> None: + # ping is removed in 2026-07-28; the legacy era keeps answering it. + async with connect("legacy", repo_dir(repo)) as client: + await client.session.send_ping() + async with connect(MODERN, repo_dir(repo)) as client: + with pytest.raises(MCPError) as excinfo: + await client.session.send_ping() + assert excinfo.value.error.code == METHOD_NOT_FOUND diff --git a/src/git/tests/test_protocol.py b/src/git/tests/test_protocol.py index c9b8e5b3e6..dbac72c881 100644 --- a/src/git/tests/test_protocol.py +++ b/src/git/tests/test_protocol.py @@ -24,7 +24,7 @@ from mcp.client.session import ClientRequestContext from mcp.shared.memory import create_client_server_memory_streams from mcp.shared.message import SessionMessage -from mcp.types import METHOD_NOT_FOUND, JSONRPCRequest, ListRootsResult, Root +from mcp.types import JSONRPCRequest, ListRootsResult, Root from pydantic import FileUrl from mcp_server_git.server import serve @@ -1161,16 +1161,16 @@ async def test_server_without_client_roots_behaves_the_same(repo: git.Repo): # --------------------------------------------------------------------------- -async def test_modern_era_is_not_served() -> None: - # #4851: the SDK v2 port serves the 2025-11-25 handshake era only. - # Server.run() would also open a 2026-07-28 connection for a request that - # carries the per-request `_meta` envelope; adopting that era is #4853. - # Until then such a request is refused as an unknown method. +async def test_server_discover_answers_the_2026_07_28_era() -> None: + # #4853: serve() serves the 2026-07-28 era beside the 2025-11-25 handshake. + # A `server/discover` carrying the per-request `_meta` envelope opens it. + # Asserted on the raw JSON-RPC response, so the fields the 2026-07-28 + # spec requires on a result (resultType, ttlMs, cacheScope) are pinned as + # the server sends them, not as a client model would default them. Era by + # era behavior through a real client is in test_eras.py. # - # Behavior change in the SDK v2 port: SDK v1 answered every unknown - # method with -32602 "Invalid request parameters" (its request union - # failed to parse); SDK v2 answers -32601 "Method not found", the code - # JSON-RPC defines for it. + # Replaces #4851's test_modern_era_is_not_served, which pinned this request + # being refused with -32601 "Method not found" until this issue landed. discover = JSONRPCRequest( jsonrpc="2.0", id=1, @@ -1205,9 +1205,17 @@ async def fake_stdio_server() -> AsyncIterator[Any]: assert response.message.model_dump(by_alias=True, exclude_none=True) == { "jsonrpc": "2.0", "id": 1, - "error": { - "code": METHOD_NOT_FOUND, - "message": "Method not found", - "data": "server/discover", + "result": { + "capabilities": {"tools": {"listChanged": False}}, + "supportedVersions": ["2026-07-28"], + "resultType": "complete", + "ttlMs": 0, + "cacheScope": "private", + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": "mcp-git", + "version": version("mcp-server-git"), + } + }, }, } diff --git a/src/git/tests/test_server.py b/src/git/tests/test_server.py index 0a6ddb0e08..55e46bb12c 100644 --- a/src/git/tests/test_server.py +++ b/src/git/tests/test_server.py @@ -2,6 +2,7 @@ from pathlib import Path import git from git.exc import BadName +from mcp.server import Server from mcp_server_git.server import ( git_checkout, git_branch, @@ -852,11 +853,11 @@ def test_git_log_date_filtering(test_repository): assert len(valid_result) == 1 -async def test_serve_loop_does_not_raise_exceptions(tmp_path: Path): +async def test_serve_does_not_raise_exceptions(tmp_path: Path): """Verify that serve() runs the server loop without raise_exceptions=True. - serve() drives SDK v2's legacy-only serve_loop rather than Server.run - (#4851), so that is what is patched. + serve() drives Server.run, the SDK's dual-era loop (#4853), so that is + what is patched. """ repo_path = tmp_path / "serve_test_repo" git.Repo.init(repo_path).close() @@ -867,9 +868,7 @@ async def test_serve_loop_does_not_raise_exceptions(tmp_path: Path): mock_stdio.return_value.__aenter__.return_value = (mock_read, mock_write) mock_stdio.return_value.__aexit__.return_value = None - with mock.patch( - "mcp_server_git.server.serve_loop", new_callable=mock.AsyncMock - ) as mock_run: + with mock.patch.object(Server, "run", new_callable=mock.AsyncMock) as mock_run: await serve(repo_path) mock_run.assert_awaited_once() _, kwargs = mock_run.call_args diff --git a/src/time/README.md b/src/time/README.md index 6cb87ee4b6..7103cafc82 100644 --- a/src/time/README.md +++ b/src/time/README.md @@ -22,6 +22,10 @@ Requires MCP Python SDK 2.x (`mcp>=2.2,<3`). A timezone name, here and in `--local-timezone`, must be an exact IANA key, case included: `Europe/Warsaw` is accepted and `europe/warsaw` is rejected on every platform, including macOS, whose case-insensitive filesystem would otherwise resolve it. +### Protocol versions + +The server speaks both eras of the Model Context Protocol over stdio, with nothing to configure: a **2026-07-28** client (discovered with `server/discover`, no session) and a **2025-11-25** client (the `initialize` handshake). The client's first request decides which. Tools behave the same in both. Methods the 2026-07-28 specification removes, such as `ping`, are answered only on a 2025-11-25 connection. + ## Installation ### Using uv (recommended) @@ -279,7 +283,8 @@ npx @modelcontextprotocol/inspector uv run mcp-server-time ## Development Run the tests from `src/time`. They live in `tests/`: `test_protocol.py` drives -the server through an MCP `ClientSession` in-process, `test_entrypoints.py` +the server through an MCP `ClientSession` in-process, `test_eras.py` runs a +`Client` against it as a 2026-07-28 and as a 2025-11-25 client, `test_entrypoints.py` covers `main()`, `python -m mcp_server_time` and the console script, and `test_server.py` unit-tests the time helpers. diff --git a/src/time/src/mcp_server_time/server.py b/src/time/src/mcp_server_time/server.py index 92e2704d7b..4a2cd187ae 100644 --- a/src/time/src/mcp_server_time/server.py +++ b/src/time/src/mcp_server_time/server.py @@ -5,8 +5,10 @@ # Built on the MCP Python SDK v2's low-level `Server` (#4851). That SDK no # longer validates tool arguments or folds tool exceptions into `isError` # results, so `call_tool` does both with SDK v1's messages, keeping tool -# results as they were on v1. `serve()` runs the legacy (2025-11-25) handshake -# loop only; serving 2026-07-28 is #4853. +# results as they were on v1. `serve()` serves both protocol eras (#4853): a +# 2025-11-25 client through the initialize handshake, and a 2026-07-28 client +# sessionlessly. No handler reads the session or calls back to the client +# (roots, sampling, elicitation, logging), so nothing differs between them. from datetime import datetime, timedelta from functools import cache @@ -21,7 +23,6 @@ import jsonschema from mcp.server import Server, ServerRequestContext -from mcp.server.runner import serve_loop from mcp.server.stdio import stdio_server from mcp.types import ( CallToolRequestParams, @@ -325,15 +326,9 @@ async def call_tool( ) options = server.create_initialization_options() async with stdio_server() as (read_stream, write_stream): - # Legacy era only. Server.run() would also serve 2026-07-28 (its - # dual-era loop answers server/discover and per-request envelopes); - # adopting that era is #4853, so this port (#4851) serves the - # handshake loop alone, the only era it served on SDK v1. - async with server.lifespan(server) as lifespan_state: - await serve_loop( - server, - read_stream, - write_stream, - lifespan_state=lifespan_state, - init_options=options, - ) + # Both eras (#4853): Server.run()'s dual-era loop lets the client's + # first request decide. `initialize` gets the 2025-11-25 handshake; + # `server/discover` or a request carrying a 2026-07-28 envelope gets + # the modern, sessionless era, with its required result fields + # (resultType, ttlMs, cacheScope) filled in by the SDK. + await server.run(read_stream, write_stream, options) diff --git a/src/time/tests/test_eras.py b/src/time/tests/test_eras.py new file mode 100644 index 0000000000..5f2e35b45a --- /dev/null +++ b/src/time/tests/test_eras.py @@ -0,0 +1,157 @@ +# Both protocol eras, driven through a real `mcp` 2.x `Client` (#4853). +# +# serve() answers a 2025-11-25 client through the initialize handshake and a +# 2026-07-28 client sessionlessly, the client's first request deciding which. +# Each test here pins the client's `mode` explicitly (never "auto" alone), so +# a test names the era it covers and cannot pass by falling back to the other +# one. The client talks to the real `serve()` over in-memory streams, the same +# JSON-RPC path stdio takes, rather than the SDK's in-process shortcut, which +# would bypass serve() and its era dispatch. +# +# The legacy-era characterization tests in test_protocol.py stay as the +# backward-compatibility net; this file adds what differs by era and shows +# that the rest does not. + +import json +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from typing import Any +from unittest.mock import patch + +import anyio +import pytest +from mcp import Client +from mcp.client._transport import TransportStreams +from mcp.shared.exceptions import MCPError +from mcp.shared.memory import create_client_server_memory_streams +from mcp.types import METHOD_NOT_FOUND, CallToolResult, TextContent + +from mcp_server_time.server import serve + +MODERN = "2026-07-28" +LEGACY = "2025-11-25" +# Each `mode` a test pins, with the protocol version it must negotiate. +ERAS = [ + pytest.param(MODERN, MODERN, id=MODERN), + pytest.param("legacy", LEGACY, id=LEGACY), +] + + +@asynccontextmanager +async def connect( + mode: str, local_timezone: str | None = "UTC" +) -> AsyncIterator[Client]: + """Run `serve(local_timezone)` in-process and yield a `Client` in `mode`.""" + async with create_client_server_memory_streams() as ( + client_streams, + server_streams, + ): + + @asynccontextmanager + async def fake_stdio_server() -> AsyncIterator[Any]: + yield server_streams + + @asynccontextmanager + async def transport() -> AsyncIterator[TransportStreams]: + yield client_streams + + with patch("mcp_server_time.server.stdio_server", fake_stdio_server): + async with anyio.create_task_group() as tg: + tg.start_soon(serve, local_timezone) + async with Client(transport(), mode=mode) as client: + yield client + tg.cancel_scope.cancel() + + +def text_of(result: CallToolResult) -> str: + """The text of a result holding one text block.""" + assert len(result.content) == 1 + block = result.content[0] + assert isinstance(block, TextContent) + return block.text + + +def tool_wire(client_tools: Any) -> list[dict[str, Any]]: + return [ + t.model_dump(by_alias=True, mode="json", exclude_none=True) + for t in client_tools + ] + + +@pytest.mark.parametrize(("mode", "negotiated"), ERAS) +async def test_each_era_is_negotiated(mode: str, negotiated: str) -> None: + async with connect(mode) as client: + assert client.session.protocol_version == negotiated + + +async def test_auto_mode_discovers_the_2026_07_28_era() -> None: + # An auto client probes server/discover first and only falls back to the + # handshake on a server that refuses it; this server no longer does. + async with connect("auto") as client: + assert client.session.protocol_version == MODERN + + +async def test_tools_list_is_the_same_in_both_eras() -> None: + # Same tools, same schemas, same deterministic order, in either era and on + # every call. + async with connect(MODERN) as client: + modern = tool_wire((await client.list_tools()).tools) + again = tool_wire((await client.list_tools()).tools) + async with connect("legacy") as client: + legacy = tool_wire((await client.list_tools()).tools) + assert [t["name"] for t in modern] == ["get_current_time", "convert_time"] + assert modern == again == legacy + + +@pytest.mark.parametrize(("mode", "negotiated"), ERAS) +async def test_tool_call_succeeds_in_each_era(mode: str, negotiated: str) -> None: + # Etc/GMT-5 is UTC+5 all year, so the result does not depend on the date. + async with connect(mode) as client: + result = await client.call_tool( + "convert_time", + {"source_timezone": "UTC", "time": "12:00", "target_timezone": "Etc/GMT-5"}, + ) + assert result.is_error is False + assert result.result_type == "complete" + data = json.loads(text_of(result)) + assert data["target"]["datetime"].endswith("T17:00:00+05:00") + assert data["time_difference"] == "+5.0h" + + +@pytest.mark.parametrize(("mode", "negotiated"), ERAS) +async def test_tool_failure_is_an_is_error_result_in_each_era( + mode: str, negotiated: str +) -> None: + async with connect(mode) as client: + bad_zone = await client.call_tool( + "get_current_time", {"timezone": "Nope/Nowhere"} + ) + unknown = await client.call_tool("no_such_tool", {}) + assert bad_zone.is_error is True + assert "Invalid timezone" in text_of(bad_zone) + assert unknown.is_error is True + assert text_of(unknown) == ( + "Error processing mcp-server-time query: Unknown tool: no_such_tool" + ) + + +@pytest.mark.parametrize(("mode", "negotiated"), ERAS) +async def test_unsupported_method_is_a_json_rpc_error_in_each_era( + mode: str, negotiated: str +) -> None: + # The server declares no resources, so resources/list is a protocol error + # (an MCPError on the client), not an isError result. + async with connect(mode) as client: + with pytest.raises(MCPError) as excinfo: + await client.list_resources() + assert excinfo.value.error.code == METHOD_NOT_FOUND + + +async def test_ping_is_served_only_in_the_legacy_era() -> None: + # ping is removed in 2026-07-28; the legacy era keeps answering it. + async with connect("legacy") as client: + await client.session.send_ping() + async with connect(MODERN) as client: + with pytest.raises(MCPError) as excinfo: + await client.session.send_ping() + assert excinfo.value.error.code == METHOD_NOT_FOUND diff --git a/src/time/tests/test_protocol.py b/src/time/tests/test_protocol.py index 5c2246097e..e4daee6418 100644 --- a/src/time/tests/test_protocol.py +++ b/src/time/tests/test_protocol.py @@ -34,7 +34,7 @@ from mcp import ClientSession from mcp.shared.memory import create_client_server_memory_streams from mcp.shared.message import SessionMessage -from mcp.types import METHOD_NOT_FOUND, JSONRPCRequest +from mcp.types import JSONRPCRequest from pydantic import BaseModel from mcp_server_time.server import serve @@ -930,16 +930,16 @@ async def test_session_survives_errors() -> None: # --------------------------------------------------------------------------- -async def test_modern_era_is_not_served() -> None: - # #4851: the SDK v2 port serves the 2025-11-25 handshake era only. - # Server.run() would also open a 2026-07-28 connection for a request that - # carries the per-request `_meta` envelope; adopting that era is #4853. - # Until then such a request is refused as an unknown method. +async def test_server_discover_answers_the_2026_07_28_era() -> None: + # #4853: serve() serves the 2026-07-28 era beside the 2025-11-25 handshake. + # A `server/discover` carrying the per-request `_meta` envelope opens it. + # Asserted on the raw JSON-RPC response, so the fields the 2026-07-28 + # spec requires on a result (resultType, ttlMs, cacheScope) are pinned as + # the server sends them, not as a client model would default them. Era by + # era behavior through a real client is in test_eras.py. # - # Behavior change in the SDK v2 port: SDK v1 answered every unknown - # method with -32602 "Invalid request parameters" (its request union - # failed to parse); SDK v2 answers -32601 "Method not found", the code - # JSON-RPC defines for it. + # Replaces #4851's test_modern_era_is_not_served, which pinned this request + # being refused with -32601 "Method not found" until this issue landed. discover = JSONRPCRequest( jsonrpc="2.0", id=1, @@ -974,9 +974,17 @@ async def fake_stdio_server() -> AsyncIterator[Any]: assert response.message.model_dump(by_alias=True, exclude_none=True) == { "jsonrpc": "2.0", "id": 1, - "error": { - "code": METHOD_NOT_FOUND, - "message": "Method not found", - "data": "server/discover", + "result": { + "capabilities": {"tools": {"listChanged": False}}, + "supportedVersions": ["2026-07-28"], + "resultType": "complete", + "ttlMs": 0, + "cacheScope": "private", + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "name": "mcp-time", + "version": version("mcp-server-time"), + } + }, }, } From 89d6642e7e174412bed345a00bb03f91e2790b37 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 11 Oct 2026 01:39:53 -0400 Subject: [PATCH 071/117] feat: serve both protocol eras on stdio in filesystem, memory and sequentialthinking Each server's main() now serves its factory through the SDK's serveStdio entry, so the first message a client sends picks the connection's era: a 2026-07-28 client opens with server/discover and carries its version, identity and capabilities in each request's _meta; a 2025-era client opens with initialize, as before. - filesystem: Roots is deprecated as of 2026-07-28 and unreachable there (no initialize, no server-to-client roots/list), so a modern connection uses the command-line directories only. A server started with none answers each modern tool call with an error saying so. 2025-era Roots handling is unchanged. The README now recommends command-line directories (#4844). - memory: on 2026-07-28 graph updates are handed to the listen router, which delivers them only to a subscriptions/listen stream naming the URI; on 2025-era connections they still wait for resources/subscribe. - sequentialthinking: one thought history per connection, on either era. main() reports transport errors on stderr; serveStdio owns the transport. Part of #4852 Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/filesystem/README.md | 30 +-- src/filesystem/__tests__/eras.test.ts | 189 +++++++++++++++++ src/filesystem/__tests__/stdio.test.ts | 6 +- src/filesystem/index.ts | 51 +++-- src/filesystem/server.ts | 35 +++- src/memory/README.md | 5 + src/memory/__tests__/eras.test.ts | 157 ++++++++++++++ src/memory/index.ts | 61 ++++-- src/sequentialthinking/README.md | 1 + .../__tests__/entry-point.test.ts | 22 +- src/sequentialthinking/__tests__/eras.test.ts | 193 ++++++++++++++++++ src/sequentialthinking/index.ts | 37 ++-- 12 files changed, 708 insertions(+), 79 deletions(-) create mode 100644 src/filesystem/__tests__/eras.test.ts create mode 100644 src/memory/__tests__/eras.test.ts create mode 100644 src/sequentialthinking/__tests__/eras.test.ts diff --git a/src/filesystem/README.md b/src/filesystem/README.md index bcedb5d0a9..35cbb2d567 100644 --- a/src/filesystem/README.md +++ b/src/filesystem/README.md @@ -11,26 +11,29 @@ Published on npm as [`@modelcontextprotocol/server-filesystem`](https://www.npmj - Move files/directories - Search files - Get file metadata -- Dynamic directory access control via [Roots](https://modelcontextprotocol.io/docs/learn/client-concepts#roots) +- Directory access control from command-line arguments, with [Roots](https://modelcontextprotocol.io/docs/learn/client-concepts#roots) as a deprecated, 2025-era convenience +- Serves both protocol eras on stdio: 2026-07-28 clients (`server/discover`, no handshake) and 2025-era clients (`initialize`) ## Directory Access Control -The server uses a flexible directory access control system. Directories can be specified via command-line arguments or dynamically via [Roots](https://modelcontextprotocol.io/docs/learn/client-concepts#roots). +The server may touch only its **allowed directories**. Pass them on the command line. A 2025-era client that supports [Roots](https://modelcontextprotocol.io/docs/learn/client-concepts#roots) can also replace them at runtime, but Roots is [deprecated as of protocol version 2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28/deprecated) and a 2026-07-28 connection cannot use it. -### Method 1: Command-line Arguments +### Method 1: Command-line Arguments (Recommended) Specify Allowed directories when starting the server: ```bash mcp-server-filesystem /path/to/dir1 /path/to/dir2 ``` -### Method 2: MCP Roots (Recommended) -MCP clients that support [Roots](https://modelcontextprotocol.io/docs/learn/client-concepts#roots) can dynamically update the Allowed directories. +This is the only method that works on every protocol version, so it is the one to use. + +### Method 2: MCP Roots (Deprecated, 2025-era clients only) +A client that connects with a 2025-era protocol version (the `initialize` handshake) and supports [Roots](https://modelcontextprotocol.io/docs/learn/client-concepts#roots) can dynamically update the Allowed directories. Roots notified by Client to Server, completely replace any server-side Allowed directories when provided. -**Important**: If server starts without command-line arguments AND client doesn't support roots protocol, the server fails at initialization: it logs the reason to stderr, closes the connection and exits with status 1. A client that supports roots but provides none (or none that are valid) keeps the connection, with no allowed directories until it sends `roots/list_changed` with valid roots. +**Important**: If server starts without command-line arguments AND a 2025-era client doesn't support roots protocol, the server fails at initialization: it logs the reason to stderr, closes the connection and exits with status 1. A client that supports roots but provides none (or none that are valid) keeps the connection, with no allowed directories until it sends `roots/list_changed` with valid roots. -This is the recommended method, as this enables runtime directory updates via `roots/list_changed` notifications without server restart, providing a more flexible and modern integration experience. +The 2026-07-28 protocol has no `initialize` handshake and no server-to-client `roots/list` request, so a 2026-07-28 client's roots are never asked for: the command-line directories are the whole allow-list. A server started with none answers every tool call on such a connection with an error saying so. ### How It Works @@ -40,11 +43,12 @@ The server's directory access control follows this flow: - Server starts with directories from command-line arguments (if provided) - If no arguments provided, server starts with empty allowed directories -2. **Client Connection & Initialization** - - Client connects and sends `initialize` request with capabilities - - Server checks if client supports roots protocol (`capabilities.roots`) +2. **Client Connection** + - The client's first message picks the protocol era of the connection + - A **2026-07-28** client sends `server/discover` (or any request carrying the per-request `_meta` envelope): the server uses the command-line directories, and steps 3 and 4 do not apply + - A **2025-era** client sends `initialize` with its capabilities, and the server checks whether it supports roots (`capabilities.roots`) -3. **Roots Protocol Handling** (if client supports roots) +3. **Roots Protocol Handling** (2025-era clients that support roots) - **On initialization**: Server requests roots from client via `roots/list` - Client responds with its configured roots - Server replaces ALL allowed directories with client's roots @@ -54,7 +58,7 @@ The server's directory access control follows this flow: - Tool calls that arrive while an update is being fetched wait for it, so none runs against a root the client has just withdrawn - Once the client's roots are in force, an update with no valid roots revokes access: every path is refused ("Access denied - no allowed directories") until the client exposes a root again. Before any root has been in force, such an update keeps the command-line directories -4. **Fallback Behavior** (if client doesn't support roots) +4. **Fallback Behavior** (2025-era clients that don't support roots) - Server continues using command-line directories only - No dynamic updates possible - With no command-line directories either, the server logs an error, closes the connection and exits with status 1 @@ -64,7 +68,7 @@ The server's directory access control follows this flow: - Use `list_allowed_directories` tool to see current directories - Server requires at least ONE allowed directory to operate -**Note**: The server will only allow operations within directories specified either via `args` or via Roots. +**Note**: The server will only allow operations within directories specified either via `args` or, for a 2025-era client, via Roots. diff --git a/src/filesystem/__tests__/eras.test.ts b/src/filesystem/__tests__/eras.test.ts new file mode 100644 index 0000000000..025ce11ca5 --- /dev/null +++ b/src/filesystem/__tests__/eras.test.ts @@ -0,0 +1,189 @@ +// Access control on both protocol eras (#4852). main() serves createServer() +// through the SDK's serveStdio entry, so the first message a client sends +// picks the connection's era. On 2025-11-25 a client that supports Roots +// replaces the command-line directories after initialize, as before. On +// 2026-07-28 there is no initialize and no server-to-client roots/list, and +// Roots is deprecated, so the command-line directories are the whole +// allow-list: a client's roots are never asked for and never widen it, and a +// server started with none says so on every call. +// +// The SDK-client tests serve createServer() exactly as main() does, over an +// in-memory transport; the raw tests drive main() itself over in-memory +// stdio, so its era wiring is covered too. + +import fs from "fs/promises"; +import path from "path"; +import { PassThrough } from "node:stream"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + Client, + InMemoryTransport, + type CallToolResult, +} from "@modelcontextprotocol/client"; +import { serveStdio } from "@modelcontextprotocol/server/stdio"; +import { main } from "../index.js"; +import { + createServer, + NO_ALLOWED_DIRECTORIES_MODERN_ERROR, +} from "../server.js"; +import { makeTempDir, quietStderr } from "./helpers.js"; + +let allowed: string; +let other: string; +let closers: Array<() => Promise>; + +beforeEach(async () => { + quietStderr(); + allowed = await fs.realpath(await makeTempDir("mcp-fs-era-allowed-")); + other = await fs.realpath(await makeTempDir("mcp-fs-era-other-")); + await fs.writeFile(path.join(other, "secret.txt"), "secret"); + closers = []; +}); + +afterEach(async () => { + for (const close of closers.reverse()) await close(); + vi.restoreAllMocks(); + await fs.rm(allowed, { recursive: true, force: true }); + await fs.rm(other, { recursive: true, force: true }); +}); + +/** + * A client that supports Roots and offers `other` as its only root, connected + * to a server for `directories` and negotiating per `mode`. + */ +async function connect( + directories: string[], + mode: "auto" | "legacy", +): Promise<{ client: Client; rootsAsked: () => number }> { + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const handle = serveStdio(({ era }) => createServer(directories, { era }), { + transport: serverTransport, + }); + const client = new Client( + { name: "fs-era-test", version: "0.0.0" }, + { capabilities: { roots: {} }, versionNegotiation: { mode } }, + ); + let asked = 0; + client.setRequestHandler("roots/list", async () => { + asked++; + return { roots: [{ uri: `file://${other}`, name: "other" }] }; + }); + await client.connect(clientTransport); + closers.push( + () => handle.close(), + () => client.close(), + ); + return { client, rootsAsked: () => asked }; +} + +async function listAllowed(client: Client): Promise { + const result = (await client.callTool({ + name: "list_allowed_directories", + arguments: {}, + })) as CallToolResult; + return (result.content[0] as { text: string }).text; +} + +describe("on 2026-07-28", () => { + it("serves only the command-line directories, and never asks for roots", async () => { + const { client, rootsAsked } = await connect([allowed], "auto"); + expect(client.getProtocolEra()).toBe("modern"); + expect(await listAllowed(client)).toBe(`Allowed directories:\n${allowed}`); + const read = (await client.callTool({ + name: "read_text_file", + arguments: { path: path.join(other, "secret.txt") }, + })) as CallToolResult; + expect(read.isError).toBe(true); + expect((read.content[0] as { text: string }).text).toContain( + "Access denied", + ); + expect(rootsAsked()).toBe(0); + }); + + it("explains on every call that a server with no directories cannot operate", async () => { + const { client } = await connect([], "auto"); + const result = (await client.callTool({ + name: "list_allowed_directories", + arguments: {}, + })) as CallToolResult; + expect(result).toMatchObject({ + isError: true, + content: [{ type: "text", text: NO_ALLOWED_DIRECTORIES_MODERN_ERROR }], + }); + }); +}); + +describe("on 2025-11-25", () => { + it("still lets the client's roots replace the command-line directories", async () => { + const { client, rootsAsked } = await connect([allowed], "legacy"); + expect(client.getProtocolEra()).toBe("legacy"); + await vi.waitFor(() => expect(rootsAsked()).toBe(1)); + expect(await listAllowed(client)).toBe(`Allowed directories:\n${other}`); + }); +}); + +describe("main() over stdio", () => { + const envelope = { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": { roots: {} }, + }; + + /** Start main() and send one raw 2026-07-28 tools/call; resolve its result. */ + async function callOverStdio(args: string[]) { + const stdin = new PassThrough(); + const stdout = new PassThrough(); + stdout.setEncoding("utf8"); + const handle = await main(args, stdin, stdout); + closers.push(() => handle.close()); + const line = new Promise((resolve) => { + let buffered = ""; + stdout.on("data", (chunk: string) => { + buffered += chunk; + const newline = buffered.indexOf("\n"); + if (newline !== -1) resolve(buffered.slice(0, newline)); + }); + }); + stdin.write( + `${JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + _meta: envelope, + name: "list_allowed_directories", + arguments: {}, + }, + })}\n`, + ); + return JSON.parse(await line) as { + result: CallToolResult & { resultType: string }; + }; + } + + it("answers a 2026-07-28 request with no initialize", async () => { + const { result } = await callOverStdio([allowed]); + expect(result.resultType).toBe("complete"); + expect(result.content).toEqual([ + { type: "text", text: `Allowed directories:\n${allowed}` }, + ]); + }); + + it("reports a transport error on stderr", async () => { + const stderr = quietStderr(); + const stdin = new PassThrough(); + const handle = await main([allowed], stdin, new PassThrough()); + closers.push(() => handle.close()); + const failure = new Error("stdin broke"); + stdin.emit("error", failure); + expect(stderr).toHaveBeenCalledWith("Transport error:", failure); + }); + + it("fails each 2026-07-28 call visibly when started with no directories", async () => { + const { result } = await callOverStdio([]); + expect(result.isError).toBe(true); + expect(result.content).toEqual([ + { type: "text", text: NO_ALLOWED_DIRECTORIES_MODERN_ERROR }, + ]); + }); +}); diff --git a/src/filesystem/__tests__/stdio.test.ts b/src/filesystem/__tests__/stdio.test.ts index 9171adcf20..a3442132f5 100644 --- a/src/filesystem/__tests__/stdio.test.ts +++ b/src/filesystem/__tests__/stdio.test.ts @@ -11,7 +11,7 @@ import path from "path"; import { PassThrough } from "node:stream"; import { pathToFileURL } from "url"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { McpServer } from "@modelcontextprotocol/server"; +import type { StdioServerHandle } from "@modelcontextprotocol/server/stdio"; import { isEntryPoint, main, resolveAllowedDirectories } from "../index.js"; import { makeTempDir, quietStderr } from "./helpers.js"; @@ -28,7 +28,7 @@ class StdioSession { readonly stdout = new PassThrough(); private buffered = ""; private readonly responses = new Map(); - server?: McpServer; + server?: StdioServerHandle; constructor() { this.stdout.setEncoding("utf8"); @@ -135,7 +135,7 @@ describe("startup", () => { "At least one directory must be provided by EITHER method for the server to operate.", ); expect(stderr).toHaveBeenCalledWith( - "Started without allowed directories - waiting for client to provide roots via MCP protocol", + "Started without allowed directories - waiting for a 2025-era client to provide roots via MCP protocol", ); }); diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index c112d8f23e..52333497db 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -1,7 +1,9 @@ #!/usr/bin/env node // The stdio entry point of the filesystem server (the package's bin). It -// resolves the allowed directories named on the command line, builds the -// server with createServer() and connects it to stdio. Everything runs inside +// resolves the allowed directories named on the command line and serves +// createServer() on stdio through the SDK's serveStdio entry, which answers +// 2026-07-28 clients (server/discover, no handshake) and 2025-era clients +// (initialize, then Roots) from the same factory (#4852). Everything runs inside // main(), and main() runs only when this file is the process entry point, so // importing the module (as the tests do) starts nothing (#4854). main() takes // the argv and the stdio streams as parameters so the tests can drive the real @@ -12,8 +14,11 @@ import { realpathSync } from "fs"; import fs from "fs/promises"; import path from "path"; import { fileURLToPath } from "url"; -import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; -import type { McpServer } from "@modelcontextprotocol/server"; +import { + serveStdio, + StdioServerTransport, + type StdioServerHandle, +} from "@modelcontextprotocol/server/stdio"; import { normalizePath, expandHome } from "./path-utils.js"; import { createServer } from "./server.js"; @@ -80,42 +85,52 @@ export async function resolveAllowedDirectories( } /** - * Start the server over stdio with the allowed directories in `args`. - * Resolves to the connected server once the transport is listening. + * Serve the server over stdio with the allowed directories in `args`. The + * first message the client sends picks the connection's protocol era, and the + * factory builds the server for it. A transport error, including one that + * stops the transport from starting, is reported on stderr. */ export async function main( args: string[] = process.argv.slice(2), stdin: Readable = process.stdin, stdout: Writable = process.stdout, -): Promise { +): Promise { if (args.length === 0) { console.error( "Usage: mcp-server-filesystem [allowed-directory] [additional-directories...]", ); console.error("Note: Allowed directories can be provided via:"); console.error(" 1. Command-line arguments (shown above)"); - console.error(" 2. MCP roots protocol (if client supports it)"); + console.error( + " 2. MCP roots protocol (deprecated; 2025-era clients that support it)", + ); console.error( "At least one directory must be provided by EITHER method for the server to operate.", ); } const allowedDirectories = await resolveAllowedDirectories(args); - // With no directories and a client without Roots, the server closes the - // connection after initialize (#4992); exit non-zero so the failure shows. - const server = createServer(allowedDirectories, { - onCannotOperate: () => process.exit(1), - }); - - const transport = new StdioServerTransport(stdin, stdout); - await server.connect(transport); + // With no directories and a 2025-era client without Roots, the server + // closes the connection after initialize (#4992); exit non-zero so the + // failure shows. + const handle = serveStdio( + ({ era }) => + createServer(allowedDirectories, { + era, + onCannotOperate: () => process.exit(1), + }), + { + transport: new StdioServerTransport(stdin, stdout), + onerror: (error) => console.error("Transport error:", error), + }, + ); console.error("Secure MCP Filesystem Server running on stdio"); if (allowedDirectories.length === 0) { console.error( - "Started without allowed directories - waiting for client to provide roots via MCP protocol", + "Started without allowed directories - waiting for a 2025-era client to provide roots via MCP protocol", ); } - return server; + return handle; } /** diff --git a/src/filesystem/server.ts b/src/filesystem/server.ts index bd0fae38b2..8878b1ea68 100644 --- a/src/filesystem/server.ts +++ b/src/filesystem/server.ts @@ -150,7 +150,24 @@ async function readFileAsBase64Stream(filePath: string): Promise { export const NO_ALLOWED_DIRECTORIES_ERROR = "Server cannot operate: No allowed directories available. Server was started without command-line directories and client does not support MCP roots protocol. Please either: 1) Start server with directory arguments, or 2) Use a client that supports MCP roots protocol and provides valid root directories."; +/** + * Why the server cannot operate on a 2026-07-28 connection when it was started + * with no allowed directories. That revision has no initialize handshake and + * no server-to-client roots/list, so the client's Roots cannot supply them + * (Roots is deprecated as of 2026-07-28); only the command line can (#4852). + */ +export const NO_ALLOWED_DIRECTORIES_MODERN_ERROR = + "Server cannot operate: No allowed directories available. Server was started without command-line directories, and on protocol version 2026-07-28 the client cannot provide them through MCP Roots. Restart the server with the directories it may access as command-line arguments."; + export interface ServerOptions { + /** + * The protocol era the server will serve (default `legacy`). On a + * 2025-era connection the client's Roots, when it supports them, replace + * the command-line directories after initialize. A 2026-07-28 connection + * has no initialize and no roots/list, so it is limited to the + * command-line directories. + */ + era?: "legacy" | "modern"; /** * Called after the server has closed the connection because it cannot * operate: no directories were given and the client does not support @@ -161,8 +178,8 @@ export interface ServerOptions { /** * Build a filesystem server that may touch only `initialAllowedDirectories` - * (already resolved and normalized by the caller) until the client's Roots - * replace them. + * (already resolved and normalized by the caller), until the client's Roots + * replace them on a 2025-era connection. */ export function createServer( initialAllowedDirectories: string[], @@ -217,6 +234,17 @@ export function createServer( awaited = barrier; await awaited.settled; } while (awaited !== barrier); + // A 2025-era session with no directories closes at initialize (#4992). + // A 2026-07-28 one has no initialize to close at, and its client cannot + // add directories, so every call says why it cannot run. + if (options.era === "modern" && allowedDirectories.length === 0) { + return { + content: [ + { type: "text", text: NO_ALLOWED_DIRECTORIES_MODERN_ERROR }, + ], + isError: true, + }; + } return handler(...args); }) as never)) as typeof server.registerTool; @@ -836,7 +864,8 @@ export function createServer( ); // Handles post-initialization setup, specifically checking for and fetching - // MCP roots. The initial load claims its generation and installs its barrier + // MCP roots. Only a 2025-era connection initializes, so this never runs on + // 2026-07-28, where Roots is unavailable (and deprecated). The initial load claims its generation and installs its barrier // here, synchronously, before any roots/list_changed handled afterwards can // claim a newer one. server.server.oninitialized = () => { diff --git a/src/memory/README.md b/src/memory/README.md index ab911d4885..60db63fdbd 100644 --- a/src/memory/README.md +++ b/src/memory/README.md @@ -136,6 +136,11 @@ Example: - MIME type: `application/json` - Returns the same shape as `read_graph` (entities and relations) - Mutation tools (`create_entities`, `create_relations`, `add_observations`, `delete_entities`, `delete_observations`, `delete_relations`) emit `notifications/resources/updated` for this URI, so subscribed clients see live changes + - How a client subscribes depends on its protocol version: a 2026-07-28 client names the URI in the `resourceSubscriptions` of its `subscriptions/listen` request and gets the updates on that stream; a 2025-era client calls `resources/subscribe` + +## Protocol versions + +The server serves both protocol eras on stdio from the same process. The client's first message picks the era of the connection: a 2026-07-28 client opens with `server/discover` and sends its protocol version, identity and capabilities with each request, and a 2025-era client opens with the `initialize` handshake. The tools and the resource are the same on both. ## Requirements diff --git a/src/memory/__tests__/eras.test.ts b/src/memory/__tests__/eras.test.ts new file mode 100644 index 0000000000..b9097c1a73 --- /dev/null +++ b/src/memory/__tests__/eras.test.ts @@ -0,0 +1,157 @@ +// The memory server on both protocol eras (#4852). main() serves through the +// SDK's serveStdio entry, so the first message a client sends picks the +// connection's era: a 2026-07-28 client opens with server/discover and asks +// for knowledge-graph updates in its subscriptions/listen filter; a +// 2025-11-25 client opens with initialize and asks with resources/subscribe. +// Both are driven over an in-memory transport handed to main(), through the +// SDK Client, and the 2026-07-28 wire fields the client consumes (resultType, +// the cache fields) are read from the raw exchange. + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + Client, + InMemoryTransport, + ProtocolError, + type JSONRPCMessage, +} from "@modelcontextprotocol/client"; +import { main } from "../index.js"; +import { call, makeTempGraph } from "./helpers.js"; + +const URI = "memory://knowledge-graph"; +const alice = { name: "Alice", entityType: "person", observations: [] }; + +describe("both protocol eras", () => { + let originalEnv: string | undefined; + let cleanup: () => Promise; + let closers: Array<() => Promise>; + + beforeEach(async () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + originalEnv = process.env.MEMORY_FILE_PATH; + const graph = await makeTempGraph(); + process.env.MEMORY_FILE_PATH = graph.filePath; + cleanup = graph.cleanup; + closers = []; + }); + + afterEach(async () => { + for (const close of closers.reverse()) await close(); + if (originalEnv === undefined) { + delete process.env.MEMORY_FILE_PATH; + } else { + process.env.MEMORY_FILE_PATH = originalEnv; + } + vi.restoreAllMocks(); + await cleanup(); + }); + + /** A client of a server started by main(), negotiating per `mode`. */ + async function connect(mode: "auto" | "legacy"): Promise { + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const handle = await main(serverTransport); + const client = new Client( + { name: "memory-era-test", version: "0.0.0" }, + { versionNegotiation: { mode } }, + ); + await client.connect(clientTransport); + closers.push( + () => handle.close(), + () => client.close(), + ); + return client; + } + + /** Resolves with the URI of the next notifications/resources/updated. */ + function nextUpdate(client: Client): Promise { + return new Promise((resolve) => { + client.setNotificationHandler( + "notifications/resources/updated", + (notification) => resolve(notification.params.uri), + ); + }); + } + + it("sends a 2026-07-28 client graph updates on its listen stream", async () => { + const client = await connect("auto"); + expect(client.getProtocolEra()).toBe("modern"); + const subscription = await client.listen({ + resourceSubscriptions: [URI], + }); + expect(subscription.honoredFilter.resourceSubscriptions).toEqual([URI]); + const updated = nextUpdate(client); + await call(client, "create_entities", { entities: [alice] }); + expect(await updated).toBe(URI); + await subscription.close(); + }); + + it("sends a 2026-07-28 client no update it did not listen for", async () => { + const client = await connect("auto"); + const updates: string[] = []; + client.setNotificationHandler( + "notifications/resources/updated", + (notification) => { + updates.push(notification.params.uri); + }, + ); + const subscription = await client.listen({ toolsListChanged: true }); + await call(client, "create_entities", { entities: [alice] }); + // A read after the write: any notification it sent is delivered first. + await client.readResource({ uri: URI }); + expect(updates).toEqual([]); + await subscription.close(); + }); + + it("sends a 2025-11-25 client graph updates once it subscribes", async () => { + const client = await connect("legacy"); + expect(client.getProtocolEra()).toBe("legacy"); + await client.subscribeResource({ uri: URI }); + const updated = nextUpdate(client); + await call(client, "create_entities", { entities: [alice] }); + expect(await updated).toBe(URI); + }); + + it.each(["auto", "legacy"] as const)( + "reads the graph back to a %s client", + async (mode) => { + const client = await connect(mode); + await call(client, "create_entities", { entities: [alice] }); + const { contents } = await client.readResource({ uri: URI }); + expect(JSON.parse((contents[0] as { text: string }).text)).toMatchObject({ + entities: [{ name: "Alice" }], + }); + }, + ); + + it("answers a read of an unknown resource with -32602 on 2026-07-28", async () => { + const client = await connect("auto"); + const read = client.readResource({ uri: "memory://nothing-here" }); + await expect(read).rejects.toBeInstanceOf(ProtocolError); + await expect(read).rejects.toMatchObject({ code: -32602 }); + }); + + it("stamps resultType and the cache fields on a 2026-07-28 read", async () => { + const [peer, serverTransport] = InMemoryTransport.createLinkedPair(); + const handle = await main(serverTransport); + closers.push(() => handle.close()); + const response = new Promise((resolve) => { + peer.onmessage = resolve; + }); + await peer.start(); + await peer.send({ + jsonrpc: "2.0", + id: 1, + method: "resources/read", + params: { + uri: URI, + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + }, + }, + }); + expect((await response) as Record).toMatchObject({ + result: { resultType: "complete", ttlMs: 0, cacheScope: "private" }, + }); + }); +}); diff --git a/src/memory/index.ts b/src/memory/index.ts index 0e6bd9ffdc..5d3bdb6273 100644 --- a/src/memory/index.ts +++ b/src/memory/index.ts @@ -3,9 +3,15 @@ // The memory server: a knowledge graph of entities, relations and // observations, persisted as JSONL and served over MCP as nine tools and one // subscribable resource. createServer() builds a server over a graph file so -// tests can run it in-process; main() connects it to stdio, and runs only -// when this file is the process entry point, so importing it starts nothing. -import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; +// tests can run it in-process; main() serves it on stdio through the SDK's +// serveStdio entry, which answers 2026-07-28 clients (server/discover, change +// notifications on subscriptions/listen) and 2025-era clients (initialize, +// resources/subscribe) from the same factory (#4852). main() runs only when +// this file is the process entry point, so importing it starts nothing. +import { + serveStdio, + type StdioServerHandle, +} from "@modelcontextprotocol/server/stdio"; import { McpServer } from "@modelcontextprotocol/server"; import type { Transport } from "@modelcontextprotocol/server"; import { z } from "zod"; @@ -732,10 +738,15 @@ export const SearchNodesQuerySchema = z ); // Build a memory server over the graph file at memoryFilePath: the nine -// tools, the knowledge-graph resource and its subscriptions. main() connects -// the result to stdio; tests connect it to an in-memory transport, so each -// call returns an independent server with its own subscriber set. -export function createServer(memoryFilePath: string): McpServer { +// tools, the knowledge-graph resource and its subscriptions. main() serves the +// result on stdio; tests connect it to an in-memory transport, so each call +// returns an independent server with its own subscriber set. `era` is the +// protocol era the instance will serve, which decides how graph changes are +// announced (see notifyGraphUpdated). +export function createServer( + memoryFilePath: string, + era: "legacy" | "modern" = "legacy", +): McpServer { const knowledgeGraphManager = new KnowledgeGraphManager(memoryFilePath); const server = new McpServer({ @@ -747,10 +758,14 @@ export function createServer(memoryFilePath: string): McpServer { // emit notifications/resources/updated to a client that asked for them. const resourceSubscribers = new Set(); - // Notify subscribers that the knowledge graph resource changed. No-op when the - // client has not subscribed. + // Notify subscribers that the knowledge graph resource changed. A 2025-era + // client subscribes with resources/subscribe, so the notification is sent + // only once it has. On 2026-07-28 that method does not exist: a client asks + // for updates in its subscriptions/listen filter, and serveStdio delivers + // the notification only to a listen stream that named the URI, so it is + // always handed over. function notifyGraphUpdated() { - if (resourceSubscribers.has(RESOURCE_URI)) { + if (era === "modern" || resourceSubscribers.has(RESOURCE_URI)) { // Fire-and-forget: the tool result must not wait on (or fail because of) // the notification, so a delivery failure is logged rather than thrown. server.server @@ -1140,7 +1155,10 @@ export function registerKnowledgeGraphResource( } // Enable clients to subscribe to the knowledge-graph resource and receive -// notifications/resources/updated when mutation tools change the graph. +// notifications/resources/updated when mutation tools change the graph. The +// subscribe and unsubscribe methods exist only on the 2025 protocol era; the +// `subscribe` capability also lets a 2026-07-28 client name the resource in +// its subscriptions/listen filter. export function registerKnowledgeGraphSubscriptions( server: McpServer, resourceSubscribers: Set, @@ -1156,17 +1174,20 @@ export function registerKnowledgeGraphSubscriptions( }); } -// Start the server on stdio, writing the graph to the file -// ensureMemoryFilePath picks. The transport is a parameter so a test can run -// this whole startup path in-process; the bin always uses stdio. -export async function main( - transport: Transport = new StdioServerTransport(), -): Promise { +// Serve the server on stdio, writing the graph to the file +// ensureMemoryFilePath picks. The first message the client sends picks the +// connection's protocol era, and the factory builds the server for it. The +// transport is a parameter so a test can run this whole startup path +// in-process; the bin always uses the process's stdio. A transport error, +// including one that stops the transport from starting, is reported on stderr. +export async function main(transport?: Transport): Promise { const memoryFilePath = await ensureMemoryFilePath(); - const server = createServer(memoryFilePath); - await server.connect(transport); + const handle = serveStdio(({ era }) => createServer(memoryFilePath, era), { + transport, + onerror: (error) => console.error("Transport error:", error), + }); console.error("Knowledge Graph MCP Server running on stdio"); - return server; + return handle; } // True when this module is the program node was asked to run, rather than a diff --git a/src/sequentialthinking/README.md b/src/sequentialthinking/README.md index cbb15f7329..ddb093eb82 100644 --- a/src/sequentialthinking/README.md +++ b/src/sequentialthinking/README.md @@ -11,6 +11,7 @@ Published on npm as [`@modelcontextprotocol/server-sequential-thinking`](https:/ - Branch into alternative paths of reasoning - Adjust the total number of thoughts dynamically - Generate and verify solution hypotheses +- Serves both protocol eras on stdio: 2026-07-28 clients (`server/discover`, no handshake) and 2025-era clients (`initialize`). The thought history lasts as long as the client's connection, on either ## Tool diff --git a/src/sequentialthinking/__tests__/entry-point.test.ts b/src/sequentialthinking/__tests__/entry-point.test.ts index 4ac351e738..9a44fdff6a 100644 --- a/src/sequentialthinking/__tests__/entry-point.test.ts +++ b/src/sequentialthinking/__tests__/entry-point.test.ts @@ -1,5 +1,6 @@ -// The in-process half of the entry point: `main()` connects a server to the -// transport it is given, and `isEntryPoint()` decides whether index.ts was run +// The in-process half of the entry point: `main()` serves the server on the +// transport it is given (through `serveStdio`, which eras.test.ts covers per +// protocol era), and `isEntryPoint()` decides whether index.ts was run // as the binary (and so attaches stdio) or merely imported, as these tests do. // The bootstrap that calls them only runs in a spawned process, which // stdio-smoke.test.ts covers. @@ -38,11 +39,9 @@ describe("main", () => { const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const client = new Client({ name: "main-test", version: "0.0.0" }); + const handle = main(serverTransport); try { - await Promise.all([ - main(serverTransport), - client.connect(clientTransport), - ]); + await client.connect(clientTransport); expect(errorSpy).toHaveBeenCalledWith( "Sequential Thinking MCP Server running on stdio", ); @@ -50,14 +49,17 @@ describe("main", () => { expect(tools.map((t) => t.name)).toEqual(["sequentialthinking"]); } finally { await client.close(); + await handle.close(); } }); - it("rejects when the transport cannot start", async () => { + it("reports a transport that cannot start on stderr", async () => { const [, serverTransport] = InMemoryTransport.createLinkedPair(); - vi.spyOn(serverTransport, "start").mockRejectedValue(new Error("no pipe")); - await expect(main(serverTransport)).rejects.toThrow("no pipe"); - expect(errorSpy).not.toHaveBeenCalled(); + const failure = new Error("no pipe"); + vi.spyOn(serverTransport, "start").mockRejectedValue(failure); + const handle = main(serverTransport); + await handle.close(); + expect(errorSpy).toHaveBeenCalledWith("Transport error:", failure); }); }); diff --git a/src/sequentialthinking/__tests__/eras.test.ts b/src/sequentialthinking/__tests__/eras.test.ts new file mode 100644 index 0000000000..aeb0c701a9 --- /dev/null +++ b/src/sequentialthinking/__tests__/eras.test.ts @@ -0,0 +1,193 @@ +// The server on both protocol eras (#4852). `main()` serves through the SDK's +// `serveStdio` entry, so the first message a client sends picks the +// connection's era: a 2026-07-28 client opens with `server/discover` and puts +// its protocol version, identity and capabilities in each request's `_meta`; +// a 2025-11-25 client opens with the `initialize` handshake. Both are driven +// here over an in-memory transport handed to `main()`, first through the SDK +// Client (what an application sees), then as raw JSON-RPC, because the client +// consumes the 2026-07-28 wire fields (`resultType`, `ttlMs`, `cacheScope`, +// the `serverInfo` stamp) before a caller can read them. + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + Client, + InMemoryTransport, + type JSONRPCMessage, +} from "@modelcontextprotocol/client"; +import { main } from "../index.js"; +import { TOOL_NAME, parseOk, thought, type WireResult } from "./helpers.js"; + +const MODERN = "2026-07-28"; + +/** The per-request `_meta` envelope a 2026-07-28 request carries. */ +function envelope(protocolVersion: string = MODERN): Record { + return { + "io.modelcontextprotocol/protocolVersion": protocolVersion, + "io.modelcontextprotocol/clientInfo": { name: "raw", version: "0.0.0" }, + "io.modelcontextprotocol/clientCapabilities": {}, + }; +} + +/** A raw JSON-RPC peer of a server served by `main()`. */ +async function rawPeer() { + const [peer, serverTransport] = InMemoryTransport.createLinkedPair(); + const handle = main(serverTransport); + const waiting = new Map void>(); + peer.onmessage = (message) => { + if ("id" in message && typeof message.id === "number") { + waiting.get(message.id)?.(message); + } + }; + await peer.start(); + let nextId = 1; + return { + /** Send one request and resolve with the server's response to it. */ + request(method: string, params: Record = {}) { + const id = nextId++; + return new Promise>((resolve) => { + waiting.set(id, (m) => resolve(m as Record)); + void peer.send({ jsonrpc: "2.0", id, method, params }); + }); + }, + close: () => handle.close(), + }; +} + +describe("both protocol eras", () => { + beforeEach(() => { + vi.spyOn(console, "error").mockImplementation(() => {}); + process.env.DISABLE_THOUGHT_LOGGING = "true"; + }); + + afterEach(() => { + delete process.env.DISABLE_THOUGHT_LOGGING; + vi.restoreAllMocks(); + }); + + it.each([ + ["a 2026-07-28 client", "auto", "modern"], + ["a 2025-11-25 client", "legacy", "legacy"], + ] as const)("serves %s from the same factory", async (_, mode, era) => { + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const handle = main(serverTransport); + const client = new Client( + { name: "era-test", version: "0.0.0" }, + { versionNegotiation: { mode } }, + ); + try { + await client.connect(clientTransport); + expect(client.getProtocolEra()).toBe(era); + expect(client.getServerVersion()?.name).toBe( + "sequential-thinking-server", + ); + const { tools } = await client.listTools(); + expect(tools.map((t) => t.name)).toEqual([TOOL_NAME]); + const result = (await client.callTool({ + name: TOOL_NAME, + arguments: thought(), + })) as WireResult; + expect(parseOk(result).thoughtHistoryLength).toBe(1); + } finally { + await client.close(); + await handle.close(); + } + }); + + it("keeps one thought history per connection", async () => { + const connections = await Promise.all( + [0, 1].map(async () => { + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const handle = main(serverTransport); + const client = new Client( + { name: "era-test", version: "0.0.0" }, + { versionNegotiation: { mode: "auto" } }, + ); + await client.connect(clientTransport); + return { client, handle }; + }), + ); + try { + const [a, b] = connections; + await a.client.callTool({ name: TOOL_NAME, arguments: thought() }); + await a.client.callTool({ + name: TOOL_NAME, + arguments: thought({ thoughtNumber: 2 }), + }); + const fromB = (await b.client.callTool({ + name: TOOL_NAME, + arguments: thought(), + })) as WireResult; + expect(parseOk(fromB).thoughtHistoryLength).toBe(1); + } finally { + for (const { client, handle } of connections) { + await client.close(); + await handle.close(); + } + } + }); + + describe("on the 2026-07-28 wire", () => { + it("answers server/discover with the version, capabilities and identity", async () => { + const peer = await rawPeer(); + try { + const response = await peer.request("server/discover", { + _meta: envelope(), + }); + const result = response.result as Record; + expect(result.supportedVersions).toContain(MODERN); + expect(result.capabilities).toMatchObject({ tools: {} }); + expect(result.resultType).toBe("complete"); + expect(result._meta).toMatchObject({ + "io.modelcontextprotocol/serverInfo": { + name: "sequential-thinking-server", + }, + }); + } finally { + await peer.close(); + } + }); + + it("needs no initialize, and stamps resultType and the cache fields", async () => { + const peer = await rawPeer(); + try { + const list = (await peer.request("tools/list", { _meta: envelope() })) + .result as Record; + expect(list).toMatchObject({ + resultType: "complete", + ttlMs: 0, + cacheScope: "private", + }); + const call = ( + await peer.request("tools/call", { + _meta: envelope(), + name: TOOL_NAME, + arguments: thought(), + }) + ).result as Record; + expect(call.resultType).toBe("complete"); + expect(call.structuredContent).toMatchObject({ thoughtNumber: 1 }); + } finally { + await peer.close(); + } + }); + + it("refuses a protocol version it does not serve with -32022", async () => { + const peer = await rawPeer(); + try { + const response = await peer.request("server/discover", { + _meta: envelope("2099-01-01"), + }); + expect(response.error).toMatchObject({ + code: -32022, + data: { requested: "2099-01-01" }, + }); + const error = response.error as { data: { supported: string[] } }; + expect(error.data.supported).toContain(MODERN); + } finally { + await peer.close(); + } + }); + }); +}); diff --git a/src/sequentialthinking/index.ts b/src/sequentialthinking/index.ts index a03d27f469..bf0b63749f 100644 --- a/src/sequentialthinking/index.ts +++ b/src/sequentialthinking/index.ts @@ -2,13 +2,19 @@ // Entry point of the sequential-thinking server. `createServer()` builds a // fully registered server without connecting it, so tests can link it to an -// SDK Client over an in-memory transport (#4854); `main()` connects a given -// transport; and stdio is only attached when this file is run as the binary, -// so importing it from a test never touches the runner's stdin/stdout. +// SDK Client over an in-memory transport (#4854); `main()` serves it through +// the SDK's `serveStdio` entry, which answers both 2026-07-28 clients +// (`server/discover`, no handshake) and 2025-era clients (`initialize`) from +// the same factory (#4852); and stdio is only attached when this file is run +// as the binary, so importing it from a test never touches the runner's +// stdin/stdout. import { realpathSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; +import { + serveStdio, + type StdioServerHandle, +} from "@modelcontextprotocol/server/stdio"; import { McpServer } from "@modelcontextprotocol/server"; import type { Transport } from "@modelcontextprotocol/server"; import { z } from "zod"; @@ -33,6 +39,8 @@ const coercedBoolean = z /** * Build the sequential-thinking server with its one tool registered. Each call * returns an independent server with its own thought history and branches. + * `serveStdio` calls it once per connection, so the history lives exactly as + * long as the client's connection, on either protocol era. */ export function createServer(): McpServer { const server = new McpServer({ @@ -174,11 +182,19 @@ You should: return server; } -/** Connect a new server to `transport` and announce it on stderr. */ -export async function main(transport: Transport): Promise { - const server = createServer(); - await server.connect(transport); +/** + * Serve the server on `transport` (the process's stdio by default) and + * announce it on stderr. The first message the client sends picks the + * connection's protocol era. A transport error, including one that stops the + * transport from starting, is reported on stderr. + */ +export function main(transport?: Transport): StdioServerHandle { + const handle = serveStdio(() => createServer(), { + transport, + onerror: (error) => console.error("Transport error:", error), + }); console.error("Sequential Thinking MCP Server running on stdio"); + return handle; } /** @@ -202,9 +218,6 @@ export function isEntryPoint( /* v8 ignore start -- runs only when this file is the process entry point (the spawned bin), which stdio-smoke.test.ts exercises; v8 cannot see into that child */ if (isEntryPoint(import.meta.url, process.argv[1])) { - main(new StdioServerTransport()).catch((error) => { - console.error("Fatal error running server:", error); - process.exit(1); - }); + main(); } /* v8 ignore stop */ From 27b2d2415cdc0ad13782edb8b6c3cecd7846b78a Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 11 Oct 2026 02:42:35 -0400 Subject: [PATCH 072/117] feat(everything): serve both protocol eras and demonstrate the 2026-07-28 features (WIP: tests) Part of #4852 Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/everything/README.md | 22 +- src/everything/__tests__/harness.ts | 87 +++- .../__tests__/registrations.test.ts | 117 +++-- .../__tests__/sampling-elicitation.test.ts | 2 - src/everything/__tests__/server.test.ts | 2 +- src/everything/docs/instructions.md | 19 +- src/everything/resources/files.ts | 12 +- src/everything/resources/index.ts | 12 +- src/everything/resources/minted.ts | 112 +++++ src/everything/resources/subscriptions.ts | 233 +++------- src/everything/resources/templates.ts | 7 +- src/everything/server/context.ts | 75 ++++ src/everything/server/index.ts | 188 ++++++-- src/everything/server/logging.ts | 119 ++--- src/everything/server/roots.ts | 121 ++---- src/everything/server/tasks.ts | 288 +++++++++++++ src/everything/tools/get-header-params.ts | 111 +++++ src/everything/tools/get-roots-list.ts | 128 +++--- src/everything/tools/gzip-file-as-resource.ts | 35 +- src/everything/tools/index.ts | 102 ++++- .../tools/simulate-research-query.ts | 256 +++++++++++ .../tools/toggle-simulated-logging.ts | 79 ++-- .../tools/toggle-subscriber-updates.ts | 82 ++-- .../trigger-elicitation-request-async.ts | 133 ++++++ .../tools/trigger-elicitation-request.ts | 407 +++++++++--------- src/everything/tools/trigger-list-changed.ts | 64 +++ src/everything/tools/trigger-log-messages.ts | 75 ++++ .../tools/trigger-multi-step-elicitation.ts | 147 +++++++ .../tools/trigger-resource-update.ts | 64 +++ .../tools/trigger-sampling-request-async.ts | 125 ++++++ .../tools/trigger-sampling-request.ts | 111 ++--- .../tools/trigger-url-elicitation.ts | 324 ++++++++------ src/everything/transports/sse.ts | 13 +- src/everything/transports/stdio.ts | 54 ++- src/everything/transports/streamableHttp.ts | 129 +++++- 35 files changed, 2842 insertions(+), 1013 deletions(-) create mode 100644 src/everything/resources/minted.ts create mode 100644 src/everything/server/context.ts create mode 100644 src/everything/server/tasks.ts create mode 100644 src/everything/tools/get-header-params.ts create mode 100644 src/everything/tools/simulate-research-query.ts create mode 100644 src/everything/tools/trigger-elicitation-request-async.ts create mode 100644 src/everything/tools/trigger-list-changed.ts create mode 100644 src/everything/tools/trigger-log-messages.ts create mode 100644 src/everything/tools/trigger-multi-step-elicitation.ts create mode 100644 src/everything/tools/trigger-resource-update.ts create mode 100644 src/everything/tools/trigger-sampling-request-async.ts diff --git a/src/everything/README.md b/src/everything/README.md index 8fa2d2ebdd..e834d6d928 100644 --- a/src/everything/README.md +++ b/src/everything/README.md @@ -1,4 +1,5 @@ # Everything MCP Server + **[Architecture](docs/architecture.md) | [Project Structure](docs/structure.md) | [Startup Process](docs/startup.md) @@ -6,7 +7,6 @@ | [Extension Points](docs/extension.md) | [How It Works](docs/how-it-works.md)** - This MCP server attempts to exercise all the features of the MCP protocol. It is not intended to be a useful server, but rather a test server for builders of MCP clients. It implements prompts, tools, resources, sampling, and more to showcase MCP capabilities. ## Tools, Resources, Prompts, and Other Features @@ -28,10 +28,7 @@ Add to your `claude_desktop_config.json`: "mcpServers": { "everything": { "command": "npx", - "args": [ - "-y", - "@modelcontextprotocol/server-everything" - ] + "args": ["-y", "@modelcontextprotocol/server-everything"] } } } @@ -44,12 +41,7 @@ On Windows, use `cmd /c` to launch `npx`: "mcpServers": { "everything": { "command": "cmd", - "args": [ - "/c", - "npx", - "-y", - "@modelcontextprotocol/server-everything" - ] + "args": ["/c", "npx", "-y", "@modelcontextprotocol/server-everything"] } } } @@ -116,27 +108,33 @@ npm run start:streamableHttp ``` ## Running as an installed package -### Install + +### Install + ```shell npm install -g @modelcontextprotocol/server-everything@latest ``` ### Run the default (stdio) server + ```shell npx @modelcontextprotocol/server-everything ``` ### Or specify stdio explicitly + ```shell npx @modelcontextprotocol/server-everything stdio ``` ### Run the SSE server + ```shell npx @modelcontextprotocol/server-everything sse ``` ### Run the streamable HTTP server + ```shell npx @modelcontextprotocol/server-everything streamableHttp ``` diff --git a/src/everything/__tests__/harness.ts b/src/everything/__tests__/harness.ts index 326058244b..d2d542568c 100644 --- a/src/everything/__tests__/harness.ts +++ b/src/everything/__tests__/harness.ts @@ -8,13 +8,18 @@ * `extra` objects, so the SDK v2 codemod can update these tests with import * and type changes alone. * - * Several server modules keep per-session state in module-level maps keyed by - * the transport's session id (logging, subscriptions, roots, the toggles). - * `InMemoryTransport` has no session id of its own, so every in-memory session - * would share the `undefined` key and leak state between tests. `connect()` - * therefore gives each server transport a fresh `sessionId` before `connect` - * (pass `sessionId: null` to keep it undefined, as stdio does), and `close()` - * runs the server's `cleanup(sessionId)` the way the HTTP transports do. + * `connect()` serves a 2025-era client: the server is connected to the + * transport directly and the client opens with `initialize`. Per-connection + * state lives on the server instance (#4852), so each session is isolated by + * construction; `close()` runs the server's `cleanup()` the way the + * transports do when a connection ends. The transport still gets a session id + * (pass `sessionId: null` to leave it undefined, as stdio does), for the tests + * that look at one. + * + * `connectModern()` serves a 2026-07-28 client: it hands the factory to the + * SDK's `serveStdio` entry over the in-memory pair, as the stdio transport + * does, and the client negotiates the era with `server/discover`, so every + * request carries its capabilities in `_meta`. */ import { randomUUID } from "node:crypto"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; @@ -23,6 +28,7 @@ import type { ContentBlock, ServerNotification, } from "@modelcontextprotocol/client"; +import { serveStdio } from "@modelcontextprotocol/server/stdio"; import { createServer } from "../server/index.js"; /** Options for one in-process session. */ @@ -77,7 +83,60 @@ export async function connect(options: ConnectOptions = {}): Promise { notifications, close: async () => { await client.close(); - cleanup(sessionId); + cleanup(); + }, + }; +} + +/** Options for one in-process 2026-07-28 session. */ +export type ModernConnectOptions = { + /** The capabilities the client declares with each request. */ + capabilities?: ClientCapabilities; + /** Install request handlers on the client before it connects. */ + setup?: (client: Client) => void; +}; + +/** + * Connect a fresh 2026-07-28 client to a server built for the modern era, + * served the way the stdio transport serves it. + */ +export async function connectModern( + options: ModernConnectOptions = {}, +): Promise { + const cleanups: Array<() => void> = []; + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + const handle = serveStdio( + ({ era }) => { + const { server, cleanup } = createServer({ era }); + cleanups.push(cleanup); + return server; + }, + { transport: serverTransport }, + ); + + const client = new Client( + { name: "everything-test-client", version: "0.0.0" }, + { + capabilities: options.capabilities ?? {}, + versionNegotiation: { mode: { pin: "2026-07-28" } }, + }, + ); + const notifications: ServerNotification[] = []; + client.fallbackNotificationHandler = async (notification) => { + notifications.push(notification as ServerNotification); + }; + options.setup?.(client); + await client.connect(clientTransport); + + return { + client, + sessionId: undefined, + notifications, + close: async () => { + await client.close(); + await handle.close(); + for (const cleanup of cleanups) cleanup(); }, }; } @@ -121,10 +180,10 @@ export const ALL_CAPABILITIES: ClientCapabilities = { roots: { listChanged: true }, sampling: {}, elicitation: { form: {}, url: {} }, - tasks: { - requests: { - sampling: { createMessage: {} }, - elicitation: { create: {} }, - }, - }, +}; + +/** Every client capability, plus the tasks extension (2026-07-28). */ +export const MODERN_CAPABILITIES: ClientCapabilities = { + ...ALL_CAPABILITIES, + extensions: { "io.modelcontextprotocol/tasks": {} }, }; diff --git a/src/everything/__tests__/registrations.test.ts b/src/everything/__tests__/registrations.test.ts index f828ce4ffa..7e711e5248 100644 --- a/src/everything/__tests__/registrations.test.ts +++ b/src/everything/__tests__/registrations.test.ts @@ -4,9 +4,30 @@ // (transforming the whole tool tree) could take longer than that. import { describe, it, expect, vi } from "vitest"; import { McpServer } from "@modelcontextprotocol/server"; -import { registerConditionalTools, registerTools } from "../tools/index.js"; +import { + registerConditionalTools, + registerLegacyTools, + registerModernTools, + registerTools, +} from "../tools/index.js"; import { registerPrompts } from "../prompts/index.js"; import { readInstructions, registerResources } from "../resources/index.js"; +import type { ServerContext } from "../server/context.js"; + +/** A server context for the given era, with a publisher that does nothing. */ +function contextFor(era: "legacy" | "modern"): ServerContext { + return { + era, + subscribed: new Set(), + publish: { + toolsChanged: vi.fn(), + promptsChanged: vi.fn(), + resourcesChanged: vi.fn(), + resourceUpdated: vi.fn(), + }, + onCleanup: vi.fn(), + }; +} // Create mock server function createMockServer() { @@ -28,10 +49,10 @@ describe("Registration Index Files", () => { it("should register all standard tools", () => { const mockServer = createMockServer(); - registerTools(mockServer); + registerTools(mockServer, contextFor("legacy")); - // Should register 12 standard tools (non-conditional) - expect(mockServer.registerTool).toHaveBeenCalledTimes(12); + // Should register 14 standard tools (on both eras, non-conditional) + expect(mockServer.registerTool).toHaveBeenCalledTimes(14); // Verify specific tools are registered const registeredTools = vi @@ -47,8 +68,44 @@ describe("Registration Index Files", () => { expect(registeredTools).toContain("get-resource-links"); expect(registeredTools).toContain("get-resource-reference"); expect(registeredTools).toContain("gzip-file-as-resource"); - expect(registeredTools).toContain("toggle-simulated-logging"); - expect(registeredTools).toContain("toggle-subscriber-updates"); + expect(registeredTools).toContain("get-header-params"); + expect(registeredTools).toContain("trigger-list-changed"); + expect(registeredTools).toContain("trigger-log-messages"); + expect(registeredTools).toContain("trigger-resource-update"); + }); + + it("should register the deprecated 2025-era-only tools", () => { + const mockServer = createMockServer(); + + registerLegacyTools(mockServer, contextFor("legacy")); + + const registeredTools = vi + .mocked(mockServer.registerTool) + .mock.calls.map((call) => call[0]); + expect(registeredTools).toEqual([ + "toggle-simulated-logging", + "toggle-subscriber-updates", + ]); + }); + + it("should register the capability-gated and task tools up front for 2026-07-28", () => { + const mockServer = createMockServer(); + + registerModernTools(mockServer, contextFor("modern")); + + const registeredTools = vi + .mocked(mockServer.registerTool) + .mock.calls.map((call) => call[0]); + expect(registeredTools).toEqual([ + "get-roots-list", + "trigger-elicitation-request", + "trigger-multi-step-elicitation", + "trigger-url-elicitation", + "trigger-sampling-request", + "simulate-research-query", + "trigger-sampling-request-async", + "trigger-elicitation-request-async", + ]); }); it("should register conditional tools based on capabilities", () => { @@ -64,18 +121,21 @@ describe("Registration Index Files", () => { }, } as unknown as McpServer; - registerConditionalTools(mockServerWithCapabilities); + registerConditionalTools( + mockServerWithCapabilities, + contextFor("legacy"), + ); - // Should register 4 conditional tools via registerTool when all capabilities - // are present. The task tools went with SDK v2's experimental tasks layer; - // Part 5 (#4852) brings them back. - expect(mockServerWithCapabilities.registerTool).toHaveBeenCalledTimes(4); + // Should register 5 conditional tools via registerTool when all + // capabilities are present. The task tools are 2026-07-28 only (#4852). + expect(mockServerWithCapabilities.registerTool).toHaveBeenCalledTimes(5); const registeredTools = vi .mocked(mockServerWithCapabilities.registerTool) .mock.calls.map((call) => call[0]); expect(registeredTools).toContain("get-roots-list"); expect(registeredTools).toContain("trigger-elicitation-request"); + expect(registeredTools).toContain("trigger-multi-step-elicitation"); expect(registeredTools).toContain("trigger-url-elicitation"); expect(registeredTools).toContain("trigger-sampling-request"); }); @@ -90,7 +150,7 @@ describe("Registration Index Files", () => { }, } as unknown as McpServer; // partial mock: McpServer's private members rule out a structural literal - registerConditionalTools(mockServerBeforeInit); + registerConditionalTools(mockServerBeforeInit, contextFor("legacy")); expect(mockServerBeforeInit.registerTool).not.toHaveBeenCalled(); }); @@ -103,26 +163,25 @@ describe("Registration Index Files", () => { }, } as unknown as McpServer; - registerConditionalTools(mockServerNoCapabilities); + registerConditionalTools(mockServerNoCapabilities, contextFor("legacy")); // Should not register any capability-gated tools when capabilities are missing expect(mockServerNoCapabilities.registerTool).not.toHaveBeenCalled(); }); - it("should register no form-mode elicitation tool for a URL-only client, even with task support", () => { - // Both elicitation tools send form-mode requests, which a client that - // declared only `elicitation.url` cannot answer (#4985). + it("should register no form-mode elicitation tool for a URL-only client", () => { + // The form elicitation tools send form-mode requests, which a client + // that declared only `elicitation.url` cannot answer (#4985). const mockServerUrlOnly = { registerTool: vi.fn(), server: { getClientCapabilities: vi.fn(() => ({ elicitation: { url: {} }, - tasks: { requests: { elicitation: { create: {} } } }, })), }, } as unknown as McpServer; // partial mock: McpServer's private members rule out a structural literal - registerConditionalTools(mockServerUrlOnly); + registerConditionalTools(mockServerUrlOnly, contextFor("legacy")); const registeredTools = vi .mocked(mockServerUrlOnly.registerTool) @@ -163,12 +222,6 @@ describe("Registration Index Files", () => { roots: {}, sampling: {}, elicitation: { form: {}, url: {} }, - tasks: { - requests: { - sampling: { createMessage: {} }, - elicitation: { create: {} }, - }, - }, }; const registeredWith = (capabilities: object): string[] => { @@ -179,7 +232,7 @@ describe("Registration Index Files", () => { }, } as unknown as McpServer; // partial mock: McpServer's private members rule out a structural literal - registerConditionalTools(mockServer); + registerConditionalTools(mockServer, contextFor("legacy")); return vi .mocked(mockServer.registerTool) @@ -243,7 +296,7 @@ describe("Registration Index Files", () => { it("should register resource templates", () => { const mockServer = createMockServer(); - registerResources(mockServer); + registerResources(mockServer, contextFor("legacy")); // Should register at least the 2 resource templates (text and blob) plus file resources expect(mockServer.registerResource).toHaveBeenCalled(); @@ -252,6 +305,18 @@ describe("Registration Index Files", () => { .mock.calls.map((call) => call[0]); expect(registeredResources).toContain("Dynamic Text Resource"); expect(registeredResources).toContain("Dynamic Blob Resource"); + expect(registeredResources).not.toContain("Minted Resource"); + }); + + it("should also serve minted resources on 2026-07-28", () => { + const mockServer = createMockServer(); + + registerResources(mockServer, contextFor("modern")); + + const registeredResources = vi + .mocked(mockServer.registerResource) + .mock.calls.map((call) => call[0]); + expect(registeredResources).toContain("Minted Resource"); }); it("should read instructions from file", () => { diff --git a/src/everything/__tests__/sampling-elicitation.test.ts b/src/everything/__tests__/sampling-elicitation.test.ts index cbb46e370c..31b648913e 100644 --- a/src/everything/__tests__/sampling-elicitation.test.ts +++ b/src/everything/__tests__/sampling-elicitation.test.ts @@ -14,7 +14,6 @@ import type { ElicitRequest, ElicitResult, } from "@modelcontextprotocol/server"; -import { __resetIssuedErrorPathElicitations } from "../tools/trigger-url-elicitation.js"; import { connect, contentOf, @@ -26,7 +25,6 @@ import { let session: Session | undefined; afterEach(async () => { - __resetIssuedErrorPathElicitations(); await session?.close(); session = undefined; }); diff --git a/src/everything/__tests__/server.test.ts b/src/everything/__tests__/server.test.ts index a864088ae6..768a5fcd41 100644 --- a/src/everything/__tests__/server.test.ts +++ b/src/everything/__tests__/server.test.ts @@ -61,6 +61,6 @@ describe("registerSessionResource", () => { describe("syncRoots", () => { it("does nothing for a server whose client has not initialized", async () => { const server = new McpServer({ name: "s", version: "0" }); - expect(await syncRoots(server, "no-client")).toBeUndefined(); + expect(await syncRoots(server)).toBeUndefined(); }); }); diff --git a/src/everything/docs/instructions.md b/src/everything/docs/instructions.md index 2abe5f916a..3c380b6b15 100644 --- a/src/everything/docs/instructions.md +++ b/src/everything/docs/instructions.md @@ -3,25 +3,34 @@ Audience: These instructions are written for an LLM or autonomous agent integrating with the Everything MCP Server. Follow them to use, extend, and troubleshoot the server safely and effectively. +## Protocol Versions + +The server serves two protocol eras. A client on protocol version 2026-07-28 connects with `server/discover` and sends its capabilities with each request; a 2025-era client connects with `initialize`. Some tools exist on one era only, so trust your `tools/list`: + +- 2026-07-28 only: `simulate-research-query`, `trigger-sampling-request-async` and `trigger-elicitation-request-async`, which run as tasks on the `io.modelcontextprotocol/tasks` extension and need a client that declares it +- 2025-era only (deprecated): `toggle-simulated-logging` and `toggle-subscriber-updates` + ## Cross-Feature Relationships - If `get-roots-list` is in your tool list, use it to see client workspace roots before file operations -- `gzip-file-as-resource` creates session-scoped resources accessible only during the current session -- Enable `toggle-simulated-logging` before debugging to see server log messages -- Enable `toggle-subscriber-updates` to receive periodic resource update notifications +- `gzip-file-as-resource` creates resources you can read later: for the current session on a 2025-era connection, or for ten minutes at the returned URI on 2026-07-28 +- Use `trigger-log-messages` to see which log levels reach you; on 2026-07-28 the level is set per request in `_meta` +- Use `trigger-resource-update` and `trigger-list-changed` to see change notifications; on 2026-07-28 they arrive only on a `subscriptions/listen` stream that asked for them ## Constraints & Limitations - `gzip-file-as-resource`: Max fetch size controlled by `GZIP_MAX_FETCH_SIZE` (default 10MB), timeout by `GZIP_MAX_FETCH_TIME_MILLIS` (default 30s), allowed domains by `GZIP_ALLOWED_DOMAINS` -- Session resources are ephemeral and lost when the session ends +- Session resources are ephemeral and lost when the session ends; minted resources expire after ten minutes +- Roots, sampling and logging are deprecated as of protocol version 2026-07-28, as is the HTTP+SSE transport ## Capability-Gated Tools -Registered only if your client declared the capability shown. Otherwise they are absent from `tools/list`, so do not call them: +On a 2025-era connection these are registered only if your client declared the capability shown. Otherwise they are absent from `tools/list`, so do not call them. On 2026-07-28 they are always listed, and a call fails if your request did not declare the capability: - `get-roots-list`: `roots` - `trigger-sampling-request`: `sampling` - `trigger-elicitation-request`: `elicitation.form`, or `elicitation` with no mode +- `trigger-multi-step-elicitation`: `elicitation.form`, or `elicitation` with no mode - `trigger-url-elicitation`: `elicitation.url` ## Operational Patterns diff --git a/src/everything/resources/files.ts b/src/everything/resources/files.ts index 8e051ef362..51329b36b6 100644 --- a/src/everything/resources/files.ts +++ b/src/everything/resources/files.ts @@ -1,8 +1,14 @@ -import { McpServer } from "@modelcontextprotocol/server"; +import { McpServer, type CacheHint } from "@modelcontextprotocol/server"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; import { readdirSync, readFileSync, statSync } from "fs"; +/** How long a client or shared cache may keep a static document. */ +export const STATIC_DOCUMENT_CACHE_HINT: CacheHint = { + ttlMs: 60 * 60_000, + cacheScope: "public", +}; + /** * Register static file resources * - Each file in src/everything/docs is exposed as an individual static resource @@ -46,7 +52,9 @@ export const registerFileResources = (server: McpServer) => { server.registerResource( name, uri, - { mimeType, description }, + // A static document changes only when the server is upgraded, so any + // cache may keep it for an hour (2026-07-28 clients only; #4852). + { mimeType, description, cacheHint: STATIC_DOCUMENT_CACHE_HINT }, async (uri) => { const text = readFileSafe(fullPath); return { diff --git a/src/everything/resources/index.ts b/src/everything/resources/index.ts index b7ec741376..d36d948fd4 100644 --- a/src/everything/resources/index.ts +++ b/src/everything/resources/index.ts @@ -1,17 +1,25 @@ import { McpServer } from "@modelcontextprotocol/server"; +import type { ServerContext } from "../server/context.js"; import { registerResourceTemplates } from "./templates.js"; import { registerFileResources } from "./files.js"; +import { registerMintedResources } from "./minted.js"; import { fileURLToPath } from "url"; import { dirname, join } from "path"; import { readFileSync } from "fs"; /** - * Register the resources with the MCP server. + * Register the resources with the MCP server. A 2026-07-28 server also serves + * the resources tools mint for later reading (see minted.ts). * @param server + * @param context */ -export const registerResources = (server: McpServer) => { +export const registerResources = ( + server: McpServer, + context: ServerContext, +) => { registerResourceTemplates(server); registerFileResources(server); + if (context.era === "modern") registerMintedResources(server); }; /** diff --git a/src/everything/resources/minted.ts b/src/everything/resources/minted.ts new file mode 100644 index 0000000000..6b1ff87297 --- /dev/null +++ b/src/everything/resources/minted.ts @@ -0,0 +1,112 @@ +// Resources a tool creates for a 2026-07-28 client to read later, served from +// handles the server mints (#4852). +// +// On a 2025-era connection, `gzip-file-as-resource` registers the resource it +// creates on the server instance serving the client's connection or session +// (resources/session.ts), and the resource lasts as long as that instance. +// The 2026-07-28 revision has no sessions, and on its HTTP path every request +// is served by a fresh instance, so a resource registered on one would be gone +// before the client could read it. Instead the resource is stored here, under a +// URI that embeds a random id the server mints, and every 2026-07-28 instance +// serves the store through one resource template. The id is the handle: a +// client can read a minted resource only if it was given the URI. Each one +// expires after ten minutes; an expired or unknown URI reads as not found +// (-32602). + +import { randomUUID } from "node:crypto"; +import { + McpServer, + ResourceNotFoundError, + ResourceTemplate, + type ResourceLink, +} from "@modelcontextprotocol/server"; + +/** How long a minted resource stays readable, in milliseconds. */ +export const MINTED_RESOURCE_TTL_MS = 10 * 60_000; + +/** The URI template every minted resource matches. */ +const mintedUriTemplate = "demo://resource/minted/{id}/{name}"; + +type MintedResource = { + name: string; + mimeType: string; + blob: string; + expiresAt: number; +}; + +/** Every live minted resource, by URI, across every server instance. */ +const minted = new Map(); + +/** Drop every minted resource past its expiry. */ +function sweep(now: number): void { + for (const [uri, resource] of minted) { + if (resource.expiresAt <= now) minted.delete(uri); + } +} + +/** + * Store a blob resource under a freshly minted URI, and return a link to it. + * + * @param name - The resource's name, the last segment of its URI. + * @param mimeType - Its MIME type. + * @param blob - Its content, base64-encoded. + */ +export function mintBlobResource( + name: string, + mimeType: string, + blob: string, +): ResourceLink { + const now = Date.now(); + sweep(now); + const uri = `demo://resource/minted/${randomUUID()}/${encodeURIComponent(name)}`; + minted.set(uri, { + name, + mimeType, + blob, + expiresAt: now + MINTED_RESOURCE_TTL_MS, + }); + return { + type: "resource_link", + uri, + name, + mimeType, + description: `Created by a tool call; readable for ${MINTED_RESOURCE_TTL_MS / 60_000} minutes at this URI`, + }; +} + +/** + * Register the template that serves minted resources, on a 2026-07-28 + * server. It lists nothing: a minted resource is reached by the URI a tool + * returned. + * + * @param server - The 2026-07-28 server to serve them on. + */ +export function registerMintedResources(server: McpServer): void { + server.registerResource( + "Minted Resource", + new ResourceTemplate(mintedUriTemplate, { list: undefined }), + { + description: + "A resource a tool created for this client, such as gzip-file-as-resource's output. Readable for ten minutes at the URI the tool returned.", + }, + async (uri) => { + sweep(Date.now()); + const resource = minted.get(uri.toString()); + if (!resource) throw new ResourceNotFoundError(uri.toString()); + return { + contents: [ + { + uri: uri.toString(), + mimeType: resource.mimeType, + blob: resource.blob, + }, + ], + }; + }, + ); +} + +/** Forget every minted resource. For tests. */ +export function clearMintedResources(): void { + minted.clear(); +} diff --git a/src/everything/resources/subscriptions.ts b/src/everything/resources/subscriptions.ts index 510254ed00..ef29d60cc4 100644 --- a/src/everything/resources/subscriptions.ts +++ b/src/everything/resources/subscriptions.ts @@ -1,191 +1,76 @@ -import { McpServer } from "@modelcontextprotocol/server"; - -// Track subscriber session id lists by URI -const subscriptions: Map> = new Map< - string, - Set ->(); +// 2025-era per-resource subscriptions, and the simulated updates +// `toggle-subscriber-updates` sends for them. +// +// `resources/subscribe` and `resources/unsubscribe` exist only on the 2025 +// protocol era. A 2026-07-28 client names the resources it wants updates for +// in its `subscriptions/listen` filter instead, and the serving entry +// delivers each `notifications/resources/updated` only to a stream that asked +// for it (`trigger-resource-update` shows that). The subscribed set belongs to +// the server instance serving the client's connection or session, so no +// session map is needed (#4852). -// Interval to send notifications to subscribers -const subsUpdateIntervals: Map = - new Map(); +import { McpServer } from "@modelcontextprotocol/server"; /** - * Sets up the subscription and unsubscription handlers for the provided server. - * - * The function defines two request handlers: - * 1. A `Subscribe` handler that allows clients to subscribe to specific resource URIs. - * 2. An `Unsubscribe` handler that allows clients to unsubscribe from specific resource URIs. - * - * The `Subscribe` handler performs the following actions: - * - Extracts the URI and session ID from the request. - * - Logs a message acknowledging the subscription request. - * - Updates the internal tracking of subscribers for the given URI. + * Install the `resources/subscribe` and `resources/unsubscribe` handlers on a + * 2025-era server, recording each subscription in `subscribed`. * - * The `Unsubscribe` handler performs the following actions: - * - Extracts the URI and session ID from the request. - * - Logs a message acknowledging the unsubscription request. - * - Removes the subscriber for the specified URI. + * Each handler acknowledges the request with an info-level log message, then + * adds the URI to, or removes it from, the set. * * @param {McpServer} server - The server instance to which subscription handlers will be attached. + * @param {Set} subscribed - The URIs this server's client has subscribed to. */ -export const setSubscriptionHandlers = (server: McpServer) => { - // Set the subscription handler - server.server.setRequestHandler( - "resources/subscribe", - async (request, ctx) => { - // Get the URI to subscribe to - const { uri } = request.params; - - // Get the session id (can be undefined for stdio) - const sessionId = ctx.sessionId as string; - - // Acknowledge the subscribe request - await server.sendLoggingMessage( - { - level: "info", - data: `Received Subscribe Resource request for URI: ${uri} ${ - sessionId ? `from session ${sessionId}` : "" - }`, - }, - sessionId, - ); - - // Get the subscribers for this URI - const subscribers = subscriptions.has(uri) - ? (subscriptions.get(uri) as Set) - : new Set(); - subscribers.add(sessionId); - subscriptions.set(uri, subscribers); - return {}; - }, - ); - - // Set the unsubscription handler - server.server.setRequestHandler( - "resources/unsubscribe", - async (request, ctx) => { - // Get the URI to subscribe to - const { uri } = request.params; - - // Get the session id (can be undefined for stdio) - const sessionId = ctx.sessionId as string; - - // Acknowledge the subscribe request - await server.sendLoggingMessage( - { - level: "info", - data: `Received Unsubscribe Resource request: ${uri} ${ - sessionId ? `from session ${sessionId}` : "" - }`, - }, - sessionId, - ); - - // Remove the subscriber - if (subscriptions.has(uri)) { - const subscribers = subscriptions.get(uri) as Set; - if (subscribers.has(sessionId)) subscribers.delete(sessionId); - } - return {}; - }, - ); -}; - -/** - * Sends simulated resource update notifications to the subscribed client. - * - * Iterates the URIs in `subscriptions` and emits a - * `notifications/resources/updated` notification for each URI the given - * sessionId is subscribed to. - * - * @param {McpServer} server - The server instance used to send notifications. - * @param {string | undefined} sessionId - The session ID of the client to check for subscriptions. - * @returns {Promise} Resolves once all applicable notifications are sent. - */ -const sendSimulatedResourceUpdates = async ( +export const setSubscriptionHandlers = ( server: McpServer, - sessionId: string | undefined, -): Promise => { - // Search all URIs for ones this client is subscribed to - for (const uri of subscriptions.keys()) { - const subscribers = subscriptions.get(uri) as Set; - - // If this client is subscribed, send the notification - if (subscribers.has(sessionId)) { - await server.server.notification({ - method: "notifications/resources/updated", - params: { uri }, - }); - } - } + subscribed: Set, +) => { + server.server.setRequestHandler("resources/subscribe", async (request) => { + const { uri } = request.params; + await server.sendLoggingMessage({ + level: "info", + data: `Received Subscribe Resource request for URI: ${uri}`, + }); + subscribed.add(uri); + return {}; + }); + + server.server.setRequestHandler("resources/unsubscribe", async (request) => { + const { uri } = request.params; + await server.sendLoggingMessage({ + level: "info", + data: `Received Unsubscribe Resource request: ${uri}`, + }); + subscribed.delete(uri); + return {}; + }); }; /** - * Starts the process of simulating resource updates and sending server notifications - * to the client for the resources they are subscribed to. If the update interval is - * already active, invoking this function will not start another interval. + * Start sending `server`'s client a `notifications/resources/updated` for each + * resource it has subscribed to, now and then every five seconds. * - * @param server - * @param sessionId + * @param {McpServer} server - The server whose client receives the updates. + * @param {Set} subscribed - The URIs that client has subscribed to. + * @returns {() => void} Stops the updates. */ export const beginSimulatedResourceUpdates = ( server: McpServer, - sessionId: string | undefined, -) => { - if (!subsUpdateIntervals.has(sessionId)) { - // Send once immediately, then every 5 seconds. Fire-and-forget: this - // function is synchronous by contract (its callers start a background - // simulation), so neither send can be awaited. A send that fails (e.g. the - // transport closed before the updates were stopped) is logged rather than - // left to become an unhandled rejection. - const send = () => - sendSimulatedResourceUpdates(server, sessionId).catch((error: unknown) => - console.error("Simulated resource update failed:", error), - ); - void send(); - - // Set the interval to send later resource update notifications to this client - subsUpdateIntervals.set( - sessionId, - setInterval(() => void send(), 5000), - ); - } -}; - -/** - * Stops simulated resource updates for a given session. - * - * This function halts any active intervals associated with the provided session ID - * and removes the session's corresponding entries from resource management collections. - * Session ID can be undefined for stdio. - * - * @param {string} [sessionId] - */ -export const stopSimulatedResourceUpdates = (sessionId?: string) => { - // Remove active intervals - if (subsUpdateIntervals.has(sessionId)) { - const subsUpdateInterval = subsUpdateIntervals.get(sessionId); - clearInterval(subsUpdateInterval); - subsUpdateIntervals.delete(sessionId); - } -}; - -/** - * Removes a session from every URI's subscriber set, dropping any URI entry - * that ends up with no remaining subscribers. - * - * A session that disconnects without explicitly unsubscribing otherwise stays - * in `subscriptions` for the life of the process. Call this from the - * transport's `cleanup(sessionId)` when a session ends. - * - * @param {string} [sessionId] - */ -export const removeSubscriber = (sessionId?: string) => { - for (const [uri, subscribers] of subscriptions) { - subscribers.delete(sessionId); - if (subscribers.size === 0) { - subscriptions.delete(uri); + subscribed: Set, +): (() => void) => { + // Fire-and-forget: this runs on a timer with no caller to await it. A send + // that fails (e.g. the transport closed before the updates were stopped) is + // logged rather than left to become an unhandled rejection. + const send = async () => { + try { + for (const uri of [...subscribed]) { + await server.server.sendResourceUpdated({ uri }); + } + } catch (error: unknown) { + console.error("Simulated resource update failed:", error); } - } + }; + void send(); + const interval = setInterval(() => void send(), 5000); + return () => clearInterval(interval); }; diff --git a/src/everything/resources/templates.ts b/src/everything/resources/templates.ts index 7a97aa0a48..9eb101efad 100644 --- a/src/everything/resources/templates.ts +++ b/src/everything/resources/templates.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { CompleteResourceTemplateCallback, McpServer, + ResourceNotFoundError, ResourceTemplate, completable, } from "@modelcontextprotocol/server"; @@ -138,7 +139,9 @@ export const blobResourceUri = (resourceId: number) => * @param {URL} uri - The URI of the resource (used in the error message). * @param {Record} variables - Context variables including resourceId. * @returns {number} The parsed and validated resource identifier as a positive integer. - * @throws {Error} If the resourceId is not a finite positive integer. + * @throws {ResourceNotFoundError} If the resourceId is not a finite positive + * integer: no such resource exists, which the protocol reports as -32602 + * with the URI in the error's data (#4852). */ const parseResourceId = (uri: URL, variables: Record) => { const idxStr = String(variables.resourceId ?? ""); @@ -146,7 +149,7 @@ const parseResourceId = (uri: URL, variables: Record) => { if (Number.isFinite(idx) && Number.isInteger(idx) && idx > 0) { return idx; } - throw new Error(`Unknown resource: ${uri.toString()}`); + throw new ResourceNotFoundError(uri.toString()); }; /** diff --git a/src/everything/server/context.ts b/src/everything/server/context.ts new file mode 100644 index 0000000000..1827958aee --- /dev/null +++ b/src/everything/server/context.ts @@ -0,0 +1,75 @@ +// The per-instance context the server factory hands to the features that +// need more than the `McpServer` itself (#4852). +// +// Before the 2026-07-28 revision, features that kept state across calls +// (simulated logging, resource subscriptions, the roots cache, the URL +// elicitation error path) held it in module-level maps keyed by the +// transport's session id. The 2026-07-28 revision has no sessions, so that +// state now lives here, on the server instance the factory built: one per +// stdio connection, one per 2025-era Streamable HTTP session, and one per +// request on the 2026-07-28 HTTP path, where nothing survives between +// requests unless the server mints a handle for it (as the tasks store does). +// +// The context also carries the protocol era the instance serves, because a +// few features differ by era, and the publisher that announces list and +// resource changes, because where a change notification goes differs by +// transport: on stdio and on a 2025-era session the instance sends it +// itself, while on the 2026-07-28 HTTP path the instance lives for one +// request and the change goes out on the handler's subscription bus. + +import type { McpServer } from "@modelcontextprotocol/server"; + +/** The protocol era a server instance serves. */ +export type Era = "legacy" | "modern"; + +/** Announces list and resource changes to the clients that asked for them. */ +export type ChangePublisher = { + toolsChanged: () => void; + promptsChanged: () => void; + resourcesChanged: () => void; + resourceUpdated: (uri: string) => void; +}; + +/** What the factory hands to a feature that needs more than the server. */ +export type ServerContext = { + /** The protocol era this instance serves. */ + era: Era; + /** Where this instance's change notifications go. */ + publish: ChangePublisher; + /** + * The resources a 2025-era client has subscribed to with + * `resources/subscribe`. Always empty on 2026-07-28, which has no such + * method. + */ + subscribed: Set; + /** Run `fn` when the instance is cleaned up (a connection or session ends). */ + onCleanup: (fn: () => void) => void; +}; + +/** + * A publisher that sends each change from `server` itself. On a 2025-era + * connection the notification goes to the client unsolicited; on a + * 2026-07-28 stdio connection `serveStdio` routes it onto the open + * `subscriptions/listen` streams that asked for it. + * + * `resourceUpdated` is sent only for a URI `isSubscribed` accepts: a 2025-era + * client subscribes per resource, and on 2026-07-28 the listen router does + * the filtering, so the server passes `() => true` there. + */ +export function instancePublisher( + server: McpServer, + isSubscribed: (uri: string) => boolean, +): ChangePublisher { + const report = (error: unknown) => + console.error("Change notification failed:", error); + return { + toolsChanged: () => server.sendToolListChanged(), + promptsChanged: () => server.sendPromptListChanged(), + resourcesChanged: () => server.sendResourceListChanged(), + resourceUpdated: (uri) => { + if (isSubscribed(uri)) { + server.server.sendResourceUpdated({ uri }).catch(report); + } + }, + }; +} diff --git a/src/everything/server/index.ts b/src/everything/server/index.ts index fcc0526aea..c6d4968bb3 100644 --- a/src/everything/server/index.ts +++ b/src/everything/server/index.ts @@ -1,39 +1,121 @@ -import { McpServer } from "@modelcontextprotocol/server"; +// The everything server's factory: builds one fully registered `McpServer` +// for the protocol era it is asked to serve (#4852). +// +// One factory backs every transport and both eras. `serveStdio` calls it once +// per stdio connection; the Streamable HTTP transport calls it once per +// 2025-era session and once per 2026-07-28 request; the SSE transport once +// per (2025-era) connection. Everything that must outlive a single call lives +// on the instance it builds, in the context below, never in a map keyed by a +// session id: the 2026-07-28 revision has no sessions. +// +// What differs by era is wired here, in one place: +// - 2025-era: the client's capabilities arrive in `initialize`, so the tools +// that need a client capability are registered once it has initialized, and +// the roots list is synced then. The features the 2026-07-28 revision +// removes (`logging/setLevel`-paced simulated logging, `resources/subscribe` +// timed updates) are registered here only, labelled deprecated. +// - 2026-07-28: there is no `initialize`, and each request carries the +// client's capabilities in its `_meta`, so the capability-gated tools are +// registered up front and the SDK refuses an embedded request the client +// did not declare (-32021). The tasks extension is served here only. + +import { + createRequestStateCodec, + McpServer, + type CacheHint, +} from "@modelcontextprotocol/server"; +import { setSubscriptionHandlers } from "../resources/subscriptions.js"; import { - setSubscriptionHandlers, - stopSimulatedResourceUpdates, - removeSubscriber, -} from "../resources/subscriptions.js"; -import { registerConditionalTools, registerTools } from "../tools/index.js"; + registerConditionalTools, + registerLegacyTools, + registerModernTools, + registerTools, +} from "../tools/index.js"; import { registerResources, readInstructions } from "../resources/index.js"; import { registerPrompts } from "../prompts/index.js"; -import { stopSimulatedLogging } from "./logging.js"; +import { registerTasksExtension, TASKS_EXTENSION } from "./tasks.js"; import { syncRoots } from "./roots.js"; +import { + instancePublisher, + type ChangePublisher, + type Era, + type ServerContext, +} from "./context.js"; import { SERVER_VERSION } from "../version.js"; +/** What the factory is asked to build. */ +export type ServerOptions = { + /** The protocol era the instance serves (default `legacy`). */ + era?: Era; + /** + * Where change notifications go. Defaults to the instance itself, which is + * right for stdio and for 2025-era sessions; the 2026-07-28 HTTP path + * passes its handler's subscription bus. + */ + publish?: ChangePublisher; +}; + // Server Factory response export type ServerFactoryResponse = { server: McpServer; - cleanup: (sessionId?: string) => void; + /** End the instance's timers and other per-connection work. */ + cleanup: () => void; +}; + +/** + * The state a multi-round tool carries between its rounds, sealed by the + * codec below. `requestState` round-trips through the client, so it is signed + * (HMAC-SHA256) and expires; the SDK checks it before the handler runs. + */ +export type RoundState = Record & { step: string }; + +/** + * The process-wide `requestState` codec. The key is per process: on the + * 2026-07-28 HTTP path a later round may be served by a fresh instance, so + * the key cannot belong to one. A deployment of several processes would load + * a shared secret here instead. + */ +export const roundStateCodec = createRequestStateCodec({ + key: crypto.getRandomValues(new Uint8Array(32)), + ttlSeconds: 600, +}); + +/** + * Cache hints for the 2026-07-28 cacheable results (`ttlMs` / `cacheScope`). + * The lists are the same for every client and change only with a + * `list_changed` notification, so a shared cache may hold them for a minute; + * `server/discover` changes only when the server is upgraded. 2025-era + * responses never carry these fields. A static document resource sets a + * longer hint of its own (resources/files.ts). + */ +export const CACHE_HINTS: Record = { + "server/discover": { ttlMs: 5 * 60_000, cacheScope: "public" }, + "tools/list": { ttlMs: 60_000, cacheScope: "public" }, + "prompts/list": { ttlMs: 60_000, cacheScope: "public" }, + "resources/list": { ttlMs: 60_000, cacheScope: "public" }, + "resources/templates/list": { ttlMs: 60_000, cacheScope: "public" }, }; /** * Server Factory * - * This function initializes a `McpServer` with specific capabilities and instructions, - * registers tools, resources, and prompts, and configures resource subscription handlers. + * Initializes a `McpServer` with its capabilities and instructions, and + * registers the tools, resources and prompts for the era it serves. * - * @returns {ServerFactoryResponse} An object containing the server instance, and a `cleanup` - * function for handling server-side cleanup when a session ends. - * - * Properties of the returned object: - * - `server` {Object}: The initialized server instance. - * - `cleanup` {Function}: Function to perform cleanup operations for a closing session. + * @param {ServerOptions} options - The era to serve and where its change + * notifications go. + * @returns {ServerFactoryResponse} The server instance, and a `cleanup` + * function that ends its per-connection work when the connection closes. */ -export const createServer: () => ServerFactoryResponse = () => { +export const createServer = ( + options: ServerOptions = {}, +): ServerFactoryResponse => { + const era = options.era ?? "legacy"; + // Read the server instructions const instructions = readInstructions(); + const cleanups: Array<() => void> = []; let initializeTimeout: NodeJS.Timeout | null = null; // Create the server @@ -55,48 +137,70 @@ export const createServer: () => ServerFactoryResponse = () => { subscribe: true, listChanged: true, }, + // Deprecated as of 2026-07-28 (SEP-2577), and still served on both + // eras: 2025-era clients set the level with `logging/setLevel`, + // 2026-07-28 clients per request in `_meta`. logging: {}, - // No `tasks` capability: SDK v2 removed the experimental tasks layer - // (SEP-2663). Part 5 (#4852) brings tasks back on the - // `io.modelcontextprotocol/tasks` extension. + // The tasks extension (`io.modelcontextprotocol/tasks`) is + // advertised on 2026-07-28 only, where it is served. + ...(era === "modern" && { extensions: { [TASKS_EXTENSION]: {} } }), }, instructions, + cacheHints: CACHE_HINTS, + requestState: { verify: roundStateCodec.verify }, }, ); - // Register the tools - registerTools(server); + // Resources a 2025-era client has subscribed to on this instance. On + // 2026-07-28 the client names them in its subscriptions/listen filter and + // the serving entry delivers only what it asked for. + const subscribed = new Set(); - // Register the resources - registerResources(server); + const context: ServerContext = { + era, + subscribed, + publish: + options.publish ?? + instancePublisher(server, (uri) => + era === "modern" ? true : subscribed.has(uri), + ), + onCleanup: (fn) => cleanups.push(fn), + }; - // Register the prompts + // Register the tools, resources and prompts every era gets + registerTools(server, context); + registerResources(server, context); registerPrompts(server); - // Set resource subscription handlers - setSubscriptionHandlers(server); + if (era === "modern") { + // Each request carries the client's capabilities, so the tools that need + // one are registered up front, along with the tasks extension. + registerModernTools(server, context); + registerTasksExtension(server); + } else { + registerLegacyTools(server, context); + setSubscriptionHandlers(server, context.subscribed); - // Perform post-initialization operations - server.server.oninitialized = async () => { - // Register conditional tools now that client capabilities are known. - // This finishes before the `notifications/initialized` handler finishes. - registerConditionalTools(server); + // Perform post-initialization operations + server.server.oninitialized = async () => { + // Register conditional tools now that client capabilities are known. + // This finishes before the `notifications/initialized` handler finishes. + registerConditionalTools(server, context); - // Sync roots if the client supports them. - // This is delayed until after the `notifications/initialized` handler finishes, - // otherwise, the request gets lost. - const sessionId = server.server.transport?.sessionId; - initializeTimeout = setTimeout(() => syncRoots(server, sessionId), 350); - }; + // Sync roots if the client supports them. + // This is delayed until after the `notifications/initialized` handler finishes, + // otherwise, the request gets lost. + initializeTimeout = setTimeout(() => syncRoots(server), 350); + }; + } // Return the ServerFactoryResponse return { server, - cleanup: (sessionId?: string) => { + cleanup: () => { // Stop any simulated logging or resource updates that may have been initiated. - stopSimulatedLogging(sessionId); - stopSimulatedResourceUpdates(sessionId); - removeSubscriber(sessionId); + for (const fn of cleanups.splice(0)) fn(); + subscribed.clear(); if (initializeTimeout) clearTimeout(initializeTimeout); }, } satisfies ServerFactoryResponse; diff --git a/src/everything/server/logging.ts b/src/everything/server/logging.ts index 3415912982..5d6f26afe6 100644 --- a/src/everything/server/logging.ts +++ b/src/everything/server/logging.ts @@ -1,90 +1,47 @@ +// Simulated logging for `toggle-simulated-logging`: a timer that sends a +// random-leveled `notifications/message` every five seconds, filtered by the +// level the client set with `logging/setLevel`. +// +// This is 2025-era behavior. The 2026-07-28 revision removes +// `logging/setLevel` and unsolicited log messages: a log message is sent only +// during a request, at the level that request names in its `_meta` +// (`trigger-log-messages` shows that). Logging itself is deprecated as of +// 2026-07-28 (SEP-2577). The timer belongs to one server instance, the one +// serving the client's connection or session, so no session map is needed +// (#4852). + import { LoggingLevel, McpServer } from "@modelcontextprotocol/server"; -// Map session ID to the interval for sending logging messages to the client -const logsUpdateIntervals: Map = - new Map(); +/** One message per logging level, from least to most severe. */ +const MESSAGES: { level: LoggingLevel; data: string }[] = [ + { level: "debug", data: "Debug-level message" }, + { level: "info", data: "Info-level message" }, + { level: "notice", data: "Notice-level message" }, + { level: "warning", data: "Warning-level message" }, + { level: "error", data: "Error-level message" }, + { level: "critical", data: "Critical-level message" }, + { level: "alert", data: "Alert level-message" }, + { level: "emergency", data: "Emergency-level message" }, +]; /** - * Initiates a simulated logging process by sending random log messages to the client at a - * fixed interval. Each log message contains a random logging level and optional session ID. + * Start sending `server`'s client a random-leveled log message now and then + * every five seconds. The SDK drops a message below the level the client set. * - * @param {McpServer} server - The server instance responsible for handling the logging messages. - * @param {string | undefined} sessionId - An optional identifier for the session. If provided, - * the session ID will be appended to log messages. + * @param {McpServer} server - The server whose client receives the messages. + * @returns {() => void} Stops the messages. */ -export const beginSimulatedLogging = ( - server: McpServer, - sessionId: string | undefined, -) => { - const maybeAppendSessionId = sessionId ? ` - SessionId ${sessionId}` : ""; - const messages: { level: LoggingLevel; data: string }[] = [ - { level: "debug", data: `Debug-level message${maybeAppendSessionId}` }, - { level: "info", data: `Info-level message${maybeAppendSessionId}` }, - { level: "notice", data: `Notice-level message${maybeAppendSessionId}` }, - { - level: "warning", - data: `Warning-level message${maybeAppendSessionId}`, - }, - { level: "error", data: `Error-level message${maybeAppendSessionId}` }, - { - level: "critical", - data: `Critical-level message${maybeAppendSessionId}`, - }, - { level: "alert", data: `Alert level-message${maybeAppendSessionId}` }, - { - level: "emergency", - data: `Emergency-level message${maybeAppendSessionId}`, - }, - ]; - - /** - * Send a simulated logging message to the client - */ - const sendSimulatedLoggingMessage = async (sessionId: string | undefined) => { - // By using the `sendLoggingMessage` function to send the message, we - // ensure that the client's chosen logging level will be respected - await server.sendLoggingMessage( - messages[Math.floor(Math.random() * messages.length)], - sessionId, - ); - }; - - // Set the interval to send later logging messages to this client - /* v8 ignore else -- toggle-simulated-logging tracks the sessions it started and never begins one twice */ - if (!logsUpdateIntervals.has(sessionId)) { - // Send once immediately, then every 5 seconds. Fire-and-forget: this - // function is synchronous by contract (it starts a background simulation), - // so neither send can be awaited. A send that fails (e.g. the transport - // closed before `stopSimulatedLogging`) is logged rather than left to - // become an unhandled rejection. - const send = () => - sendSimulatedLoggingMessage(sessionId).catch((error: unknown) => +export const beginSimulatedLogging = (server: McpServer): (() => void) => { + // Fire-and-forget: this runs on a timer with no caller to await it. A send + // that fails (e.g. the transport closed before logging was stopped) is + // logged rather than left to become an unhandled rejection. + const send = () => + server + .sendLoggingMessage(MESSAGES[Math.floor(Math.random() * MESSAGES.length)]) + .catch((error: unknown) => console.error("Simulated logging message failed:", error), ); - void send(); - - // Send a randomly-leveled log message every 5 seconds - logsUpdateIntervals.set( - sessionId, - setInterval(() => void send(), 5000), - ); - } -}; - -/** - * Stops the simulated logging process for a given session. - * - * This function halts the periodic logging updates associated with the specified - * session ID by clearing the interval and removing the session's tracking - * reference. Session ID can be undefined for stdio. - * - * @param {string} [sessionId] - The optional unique identifier of the session. - */ -export const stopSimulatedLogging = (sessionId?: string) => { - // Remove active intervals - if (logsUpdateIntervals.has(sessionId)) { - const logsUpdateInterval = logsUpdateIntervals.get(sessionId); - clearInterval(logsUpdateInterval); - logsUpdateIntervals.delete(sessionId); - } + void send(); + const interval = setInterval(() => void send(), 5000); + return () => clearInterval(interval); }; diff --git a/src/everything/server/roots.ts b/src/everything/server/roots.ts index cd4824c030..c9d8c05bf1 100644 --- a/src/everything/server/roots.ts +++ b/src/everything/server/roots.ts @@ -1,89 +1,58 @@ -import { McpServer, Root } from "@modelcontextprotocol/server"; +// The 2025-era roots sync: after `initialize`, a server whose client supports +// Roots asks for the list with `roots/list`, and asks again each time the +// client sends `notifications/roots/list_changed`, reporting each answer to +// the client as a log message. +// +// 2025-era only, and deprecated: the 2026-07-28 revision removes +// server-to-client requests and `notifications/roots/list_changed`, and +// deprecates Roots. There a server asks for roots in-band, by returning an +// `input_required` result from a call, which `get-roots-list` does on both +// eras (#4852). Nothing is cached: the answer is only reported. -// Track roots by session id -export const roots: Map = new Map< - string | undefined, - Root[] ->(); +import { McpServer } from "@modelcontextprotocol/server"; + +/** The servers whose `roots/list_changed` handler is installed. */ +const watching = new WeakSet(); /** - * Get the latest the client roots list for the session. - * - * - Request and cache the roots list for the session if it has not been fetched before. - * - Return the cached roots list for the session if it exists. - * - * When requesting the roots list for a session, it also sets up a `roots/list_changed` - * notification handler. This ensures that updates are automatically fetched and handled - * in real-time. - * - * This function is idempotent. It should only request roots from the client once per session, - * returning the cached version thereafter. + * Ask the client for its roots and report the answer, and re-ask on each + * `notifications/roots/list_changed`. Does nothing for a client without Roots. + * Safe to call more than once: the notification handler is installed once. * - * @param {McpServer} server - An instance of the MCP server used to communicate with the client. - * @param {string} [sessionId] - An optional session id used to associate the roots list with a specific client session. + * A failed request is logged to stderr, not thrown, since the caller is a + * timer with nothing to report the failure to. * - * @throws {Error} In case of a failure to request the roots from the client, an error log message is sent. + * @param {McpServer} server - The 2025-era server whose client is asked. */ -export const syncRoots = async (server: McpServer, sessionId?: string) => { +export const syncRoots = async (server: McpServer): Promise => { const clientCapabilities = server.server.getClientCapabilities() || {}; - const clientSupportsRoots: boolean = clientCapabilities?.roots !== undefined; + if (clientCapabilities.roots === undefined) return; - // Fetch the roots list for this client - if (clientSupportsRoots) { - // Function to request the updated roots list from the client - const requestRoots = async () => { - try { - // Request the updated roots list from the client - const response = await server.server.listRoots(); - /* v8 ignore else -- unreachable: listRoots() validates the reply against ListRootsResultSchema, which requires `roots`, and rejects anything else */ - if (response && "roots" in response) { - // Store the roots list for this client - roots.set(sessionId, response.roots); - - // Notify the client of roots received - await server.sendLoggingMessage( - { - level: "info", - logger: "everything-server", - data: `Roots updated: ${response?.roots?.length} root(s) received from client`, - }, - sessionId, - ); - } else { - await server.sendLoggingMessage( - { - level: "info", - logger: "everything-server", - data: "Client returned no roots set", - }, - sessionId, - ); - } - } catch (error) { - console.error( - `Failed to request roots from client ${sessionId}: ${ - /* v8 ignore start -- the SDK always rejects listRoots() with an Error (ProtocolError or SdkError) */ - error instanceof Error ? error.message : String(error) - /* v8 ignore stop */ - }`, - ); - } - }; - - // If the roots have not been synced for this client, - // set notification handler and request initial roots - if (!roots.has(sessionId)) { - // Set the list changed notification handler - server.server.setNotificationHandler( - "notifications/roots/list_changed", - requestRoots, + const requestRoots = async () => { + try { + const response = await server.server.listRoots(); + await server.sendLoggingMessage({ + level: "info", + logger: "everything-server", + data: `Roots updated: ${response.roots.length} root(s) received from client`, + }); + } catch (error) { + console.error( + `Failed to request roots from client: ${ + /* v8 ignore start -- the SDK always rejects listRoots() with an Error (ProtocolError or SdkError) */ + error instanceof Error ? error.message : String(error) + /* v8 ignore stop */ + }`, ); - - // Request the initial roots list immediately - await requestRoots(); } + }; - // Return the roots list for this client - return roots.get(sessionId); + if (!watching.has(server)) { + watching.add(server); + server.server.setNotificationHandler( + "notifications/roots/list_changed", + requestRoots, + ); } + await requestRoots(); }; diff --git a/src/everything/server/tasks.ts b/src/everything/server/tasks.ts new file mode 100644 index 0000000000..277203bf70 --- /dev/null +++ b/src/everything/server/tasks.ts @@ -0,0 +1,288 @@ +// The server side of the Tasks extension (`io.modelcontextprotocol/tasks`, +// SEP-2663) on the 2026-07-28 revision (#4852). +// +// A tool that runs as a task answers `tools/call` with a task handle +// (`resultType: "task"`) instead of its result. The client then polls +// `tasks/get` for the task's status, answers an `input_required` status with +// `tasks/update`, and may stop the task with `tasks/cancel`; the final result +// arrives inside the `completed` status. The SDK has no server implementation +// of the extension (modelcontextprotocol/ext-tasks#30), so this module is one, +// for the everything server's three task tools. +// +// A task outlives the request that created it, and on the 2026-07-28 HTTP +// path every request is served by a fresh server instance, so tasks live in a +// process-wide store keyed by the task id. The id is a random UUID the server +// mints: it is the handle, and knowing it is what lets a client reach the +// task. Nothing runs in the background. A task's status is worked out from the +// clock when it is read, so there are no timers to stop; a task past its TTL +// is dropped the next time the store is touched. +// +// The extension is served on 2026-07-28 only. The 2025-11-25 revision had its +// own, different task methods in the core protocol; they are not served. + +import { randomUUID } from "node:crypto"; +import { + CLIENT_CAPABILITIES_META_KEY, + McpServer, + ProtocolError, + ProtocolErrorCode, + type CallToolResult, + type InputRequests, + type InputResponses, + type ServerContext, +} from "@modelcontextprotocol/server"; +import { z } from "zod"; + +/** The extension's identifier, as declared in `capabilities.extensions`. */ +export const TASKS_EXTENSION = "io.modelcontextprotocol/tasks"; + +/** The status of a task. */ +export type TaskStatus = + | "working" + | "input_required" + | "completed" + | "failed" + | "cancelled"; + +/** What a task kind decides about its task at one moment. */ +export type TaskState = + | { status: "working"; statusMessage: string } + | { + status: "input_required"; + statusMessage: string; + inputRequests: InputRequests; + } + | { status: "completed"; statusMessage?: string; result: CallToolResult }; + +/** + * One kind of task: how its status follows from the clock, and what it does + * with the client's answers to its input requests. + */ +export type TaskBehavior = { + /** The task's state at `now` (epoch milliseconds). */ + stateAt: (now: number) => TaskState; + /** Take the client's answers, sent with `tasks/update` at `now`. */ + receive: (responses: InputResponses, now: number) => void; +}; + +/** A task in the store. */ +type StoredTask = { + taskId: string; + createdAt: number; + lastUpdatedAt: number; + ttlMs: number; + pollIntervalMs: number; + behavior: TaskBehavior; + /** Set once the client cancels; a cancelled task stays cancelled. */ + cancelled: boolean; + /** The status last reported, so a change moves `lastUpdatedAt`. */ + lastStatus?: string; +}; + +/** Every live task, by id, across every server instance in the process. */ +const tasks = new Map(); + +/** Drop every task past its TTL. */ +function sweep(now: number): void { + for (const [taskId, task] of tasks) { + if (task.createdAt + task.ttlMs <= now) tasks.delete(taskId); + } +} + +/** The task with this id, or a -32602 error naming it as unknown. */ +function find(taskId: string, now: number): StoredTask { + sweep(now); + const task = tasks.get(taskId); + if (!task) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Unknown task: ${taskId}`, + ); + } + return task; +} + +/** The task's state now, honoring a cancellation. */ +function stateOf(task: StoredTask, now: number): TaskState | CancelledState { + return task.cancelled + ? { status: "cancelled", statusMessage: "Cancelled by the client" } + : task.behavior.stateAt(now); +} + +type CancelledState = { status: "cancelled"; statusMessage: string }; + +/** The wire form of a task (the `DetailedTask` of the extension's schema). */ +function describe(task: StoredTask, now: number): Record { + const state = stateOf(task, now); + if (state.status !== task.lastStatus) { + task.lastStatus = state.status; + task.lastUpdatedAt = now; + } + return { + taskId: task.taskId, + status: state.status, + ...(state.statusMessage !== undefined && { + statusMessage: state.statusMessage, + }), + createdAt: new Date(task.createdAt).toISOString(), + lastUpdatedAt: new Date(task.lastUpdatedAt).toISOString(), + ttlMs: task.ttlMs, + pollIntervalMs: task.pollIntervalMs, + ...(state.status === "input_required" && { + inputRequests: state.inputRequests, + }), + ...(state.status === "completed" && { result: state.result }), + }; +} + +/** + * Store a new task and return the `tools/call` result that hands its handle + * to the client: the task's fields, with `resultType: "task"`. + * + * @param behavior - How the task progresses. + * @param options - Its time-to-live and the polling interval to suggest. + */ +export function createTask( + behavior: TaskBehavior, + options: { ttlMs: number; pollIntervalMs: number }, +): CallToolResult { + const now = Date.now(); + sweep(now); + const task: StoredTask = { + taskId: randomUUID(), + createdAt: now, + lastUpdatedAt: now, + ...options, + behavior, + cancelled: false, + }; + tasks.set(task.taskId, task); + // The SDK passes a tool result's `resultType` through to the wire on + // 2026-07-28, which is how a task handle replaces the tool's result. + return { content: [], resultType: "task", ...describe(task, now) }; +} + +/** + * The capabilities the client declared in this request's `_meta` envelope, + * where a 2026-07-28 client sends them with every request. + */ +export function clientCapabilitiesOf( + ctx: ServerContext, +): Record | undefined { + // The SDK's published typing of the envelope has no members, so it is read + // as the plain JSON object it is. + const envelope: unknown = ctx.mcpReq.envelope; + const capabilities = isObject(envelope) + ? envelope[CLIENT_CAPABILITIES_META_KEY] + : undefined; + return isObject(capabilities) ? capabilities : undefined; +} + +/** + * Whether the client that sent this request declared the tasks extension, so + * it can follow a task handle. A tool may answer with a task only then. + */ +export function clientSupportsTasks(ctx: ServerContext): boolean { + const extensions = clientCapabilitiesOf(ctx)?.extensions; + return isObject(extensions) && extensions[TASKS_EXTENSION] !== undefined; +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** The result for a task tool called by a client without the extension. */ +export function tasksRequired(name: string): CallToolResult { + return { + content: [ + { + type: "text", + text: `${name} runs as a task, so it needs a client that declares the ${TASKS_EXTENSION} extension in its capabilities.`, + }, + ], + isError: true, + }; +} + +/** Forget every task. For tests. */ +export function clearTasks(): void { + tasks.clear(); +} + +const TaskIdParams = z.object({ + taskId: z.string().describe("The task identifier"), +}); + +const UpdateParams = TaskIdParams.extend({ + inputResponses: z + .record(z.string(), z.unknown()) + .describe("Answers to the task's outstanding input requests, by key"), +}); + +const TaskResult = z.looseObject({}); +const EmptyResult = z.object({}); + +/** + * Serve the extension's methods on a 2026-07-28 server: `tasks/get`, + * `tasks/update` and `tasks/cancel`. An unknown or expired task id, and an + * update or cancellation that does not fit the task's status, is refused + * with -32602 (invalid params). + * + * @param {McpServer} server - The 2026-07-28 server to serve them on. + */ +export function registerTasksExtension(server: McpServer): void { + server.server.setRequestHandler( + "tasks/get", + { params: TaskIdParams, result: TaskResult }, + async ({ taskId }) => { + const now = Date.now(); + return describe(find(taskId, now), now); + }, + ); + + server.server.setRequestHandler( + "tasks/update", + { params: UpdateParams, result: EmptyResult }, + async ({ taskId, inputResponses }) => { + const now = Date.now(); + const task = find(taskId, now); + const state = stateOf(task, now); + if (state.status !== "input_required") { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Task ${taskId} is ${state.status}, not waiting for input`, + ); + } + const outstanding = Object.keys(state.inputRequests); + const unknown = Object.keys(inputResponses).filter( + (key) => !outstanding.includes(key), + ); + if (unknown.length > 0) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Task ${taskId} has no outstanding input request ${unknown.join(", ")}; it is waiting for ${outstanding.join(", ")}`, + ); + } + task.behavior.receive(inputResponses as InputResponses, now); + return {}; + }, + ); + + server.server.setRequestHandler( + "tasks/cancel", + { params: TaskIdParams, result: EmptyResult }, + async ({ taskId }) => { + const now = Date.now(); + const task = find(taskId, now); + const { status } = stateOf(task, now); + if (status === "completed" || status === "cancelled") { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Task ${taskId} is already ${status}`, + ); + } + task.cancelled = true; + return {}; + }, + ); +} diff --git a/src/everything/tools/get-header-params.ts b/src/everything/tools/get-header-params.ts new file mode 100644 index 0000000000..ce82a10747 --- /dev/null +++ b/src/everything/tools/get-header-params.ts @@ -0,0 +1,111 @@ +// The `get-header-params` tool: demonstrates two 2026-07-28 tool-definition +// features (#4852). +// +// - Its `inputSchema` and `outputSchema` are plain JSON Schema 2020-12 +// documents (`$schema` named), passed through `fromJsonSchema` rather than +// built from Zod, and validated by the SDK's JSON Schema validator. +// - Its `region` argument carries `x-mcp-header: "Region"`. Over Streamable +// HTTP a 2026-07-28 client mirrors that argument into an +// `Mcp-Param-Region` request header, so infrastructure in front of the +// server (a load balancer, a gateway) can route on it without reading the +// body, and the server rejects a call whose header is missing or disagrees +// with the body (-32020). The tool reports the header it was sent, which is +// null on stdio and for a 2025-era client, which mirrors nothing. + +import { + McpServer, + CallToolResult, + fromJsonSchema, + type JsonSchemaType, +} from "@modelcontextprotocol/server"; + +/** + * The keyword that asks a 2026-07-28 client to mirror an argument into the + * `Mcp-Param-` header (SEP-2243). It is an extension keyword, so the + * JSON Schema type has no member for it; it is spread into the property. + */ +const mirroredInHeader = { "x-mcp-header": "Region" }; + +/** The input: a region, mirrored into a header, and an optional count. */ +const inputSchema: JsonSchemaType = { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + region: { + type: "string", + description: + "A region name, e.g. eu-west. Mirrored into the Mcp-Param-Region HTTP header.", + ...mirroredInHeader, + }, + repeat: { + type: "integer", + minimum: 1, + maximum: 3, + default: 1, + description: "How many times to repeat the region in the result (1-3)", + }, + }, + required: ["region"], + additionalProperties: false, +}; + +/** The output: the region from the body, and the header that carried it. */ +const outputSchema: JsonSchemaType = { + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + region: { type: "string" }, + regionHeader: { + type: ["string", "null"], + description: "The Mcp-Param-Region header the request carried, if any", + }, + repeated: { type: "array", items: { type: "string" } }, + }, + required: ["region", "regionHeader", "repeated"], + additionalProperties: false, +}; + +type HeaderParamsInput = { region: string; repeat?: number }; + +// Tool configuration +const name = "get-header-params"; +const config = { + title: "Get Header Params Tool", + description: + "Returns its region argument and the Mcp-Param-Region HTTP header it arrived in. Demonstrates " + + "x-mcp-header (a 2026-07-28 Streamable HTTP client mirrors the argument into the header) and " + + "JSON Schema 2020-12 input and output schemas.", + inputSchema: fromJsonSchema(inputSchema), + outputSchema: fromJsonSchema(outputSchema), + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, +}; + +/** + * Registers the 'get-header-params' tool. + * + * @param {McpServer} server - The McpServer instance where the tool will be registered. + */ +export const registerGetHeaderParamsTool = (server: McpServer) => { + server.registerTool( + name, + config, + async ({ region, repeat }, ctx): Promise => { + const regionHeader = + ctx.http?.req?.headers.get("mcp-param-region") ?? null; + const structuredContent = { + region, + regionHeader, + repeated: Array.from({ length: repeat ?? 1 }, () => region), + }; + return { + content: [{ type: "text", text: JSON.stringify(structuredContent) }], + structuredContent, + }; + }, + ); +}; diff --git a/src/everything/tools/get-roots-list.ts b/src/everything/tools/get-roots-list.ts index a18d208143..bdb7769981 100644 --- a/src/everything/tools/get-roots-list.ts +++ b/src/everything/tools/get-roots-list.ts @@ -1,12 +1,27 @@ -import { McpServer, CallToolResult } from "@modelcontextprotocol/server"; -import { syncRoots } from "../server/roots.js"; +// The `get-roots-list` tool: asks the client for its roots and lists them. +// +// Written once for both protocol eras with a multi-round-trip request +// (#4852): the first call returns `input_required` carrying a `roots/list` +// request, the client answers it and retries, and the retry lists the +// answer. A 2026-07-28 client does that in-band; for a 2025-era client the +// SDK's legacy shim sends a real `roots/list` request and re-enters the +// handler with the answer. Roots is deprecated as of 2026-07-28 (SEP-2577). + +import { + McpServer, + CallToolResult, + inputRequired, + inputResponse, + type InputRequiredResult, +} from "@modelcontextprotocol/server"; // Tool configuration const name = "get-roots-list"; const config = { title: "Get Roots List Tool", description: - "Lists the current MCP roots provided by the client. Demonstrates the roots protocol capability even though this server doesn't access files.", + "Lists the current MCP roots provided by the client. Demonstrates the roots protocol capability even though this server doesn't access files. " + + "Deprecated: Roots is deprecated as of protocol version 2026-07-28. The server asks for the roots with a multi-round-trip request on every protocol version.", inputSchema: {}, annotations: { readOnlyHint: true, @@ -19,79 +34,66 @@ const config = { /** * Registers the 'get-roots-list' tool. * - * If the client does not support the roots capability, the tool is not registered. - * - * The registered tool interacts with the MCP roots capability, which enables the server to access - * information about the client's workspace directories or file system roots. - * - * When supported, the server automatically retrieves and formats the current list of roots from the - * client upon connection and whenever the client sends a `roots/list_changed` notification. - * - * Therefore, this tool displays the roots that the server currently knows about for the connected - * client. If for some reason the server never got the initial roots list, the tool will request the - * list from the client again. + * The tool needs a client that supports Roots: on a 2025-era connection the + * server factory registers it only for one that declared the capability, and + * on 2026-07-28 the SDK refuses the embedded request (-32021) of a client + * whose request did not declare it. * * @param {McpServer} server - The McpServer instance where the tool will be registered. */ export const registerGetRootsListTool = (server: McpServer) => { - // Does client support roots? - const clientCapabilities = server.server.getClientCapabilities() || {}; - const clientSupportsRoots: boolean = clientCapabilities.roots !== undefined; + server.registerTool( + name, + config, + async (_args, ctx): Promise => { + const answer = inputResponse(ctx.mcpReq.inputResponses, "roots"); - // If so, register tool - if (clientSupportsRoots) { - server.registerTool( - name, - config, - async (args, ctx): Promise => { - // Get the current rootsFetch the current roots list from the client if need be - const currentRoots = await syncRoots(server, ctx.sessionId); + // First round: ask the client for its roots + if (answer.kind !== "roots") { + return inputRequired({ + inputRequests: { roots: inputRequired.listRoots() }, + }); + } - // Respond if client supports roots but doesn't have any configured - if ( - clientSupportsRoots && - (!currentRoots || currentRoots.length === 0) - ) { - return { - content: [ - { - type: "text", - text: - "The client supports roots but no roots are currently configured.\n\n" + - "This could mean:\n" + - "1. The client hasn't provided any roots yet\n" + - "2. The client provided an empty roots list\n" + - "3. The roots configuration is still being loaded", - }, - ], - }; - } - - // Create formatted response if there is a list of roots - const rootsList = currentRoots - ? currentRoots - .map((root, index) => { - return `${index + 1}. ${root.name || "Unnamed Root"}\n URI: ${ - root.uri - }`; - }) - .join("\n\n") - : "No roots found"; + const currentRoots = answer.roots; + // Respond if client supports roots but doesn't have any configured + if (currentRoots.length === 0) { return { content: [ { type: "text", text: - `Current MCP Roots (${ - currentRoots!.length - } total):\n\n${rootsList}\n\n` + - "Note: This server demonstrates the roots protocol capability but doesn't actually access files. " + - "The roots are provided by the MCP client and can be used by servers that need file system access.", + "The client supports roots but no roots are currently configured.\n\n" + + "This could mean:\n" + + "1. The client hasn't provided any roots yet\n" + + "2. The client provided an empty roots list\n" + + "3. The roots configuration is still being loaded", }, ], }; - }, - ); - } + } + + // Create formatted response if there is a list of roots + const rootsList = currentRoots + .map((root, index) => { + return `${index + 1}. ${root.name || "Unnamed Root"}\n URI: ${ + root.uri + }`; + }) + .join("\n\n"); + + return { + content: [ + { + type: "text", + text: + `Current MCP Roots (${currentRoots.length} total):\n\n${rootsList}\n\n` + + "Note: This server demonstrates the roots protocol capability but doesn't actually access files. " + + "The roots are provided by the MCP client and can be used by servers that need file system access.", + }, + ], + }; + }, + ); }; diff --git a/src/everything/tools/gzip-file-as-resource.ts b/src/everything/tools/gzip-file-as-resource.ts index 99c9da1bb9..8cda5b29d9 100644 --- a/src/everything/tools/gzip-file-as-resource.ts +++ b/src/everything/tools/gzip-file-as-resource.ts @@ -1,3 +1,15 @@ +// The `gzip-file-as-resource` tool: fetches a file, gzips it, and returns the +// result as an embedded resource or as a link to a resource the client can +// read later. +// +// Where that later resource lives depends on the protocol era (#4852). For a +// 2025-era client it is registered on the server instance serving the +// client's connection or session (resources/session.ts), and lasts as long as +// that does. The 2026-07-28 revision has no sessions, and on its HTTP path +// each request gets a fresh instance, so for a 2026-07-28 client the resource +// is stored under a URI with a server-minted id that every instance can serve +// (resources/minted.ts), and expires after ten minutes. + import { z } from "zod"; import { McpServer, @@ -5,10 +17,12 @@ import { Resource, } from "@modelcontextprotocol/server"; import { gzipSync } from "node:zlib"; +import { mintBlobResource } from "../resources/minted.js"; import { getSessionResourceURI, registerSessionResource, } from "../resources/session.js"; +import type { ServerContext } from "../server/context.js"; // Maximum input file size - 10 MB default const GZIP_MAX_FETCH_SIZE = Number( @@ -48,7 +62,7 @@ const name = "gzip-file-as-resource"; const config = { title: "GZip File as Resource Tool", description: - "Compresses a single file using gzip compression. Depending upon the selected output type, returns either the compressed data as a gzipped resource or a resource link, allowing it to be downloaded in a subsequent request during the current session.", + "Compresses a single file using gzip compression. Depending upon the selected output type, returns either the compressed data as a gzipped resource or a resource link, allowing it to be downloaded in a subsequent request: during the current session for a 2025-era client, or for ten minutes at a minted URI on protocol version 2026-07-28.", inputSchema: GZipFileAsResourceSchema, annotations: { readOnlyHint: false, @@ -71,9 +85,13 @@ const config = { * If an unrecognized `outputType` is provided, the tool throws an error. * * @param {McpServer} server - The McpServer instance where the tool will be registered. + * @param {ServerContext} context - The protocol era the instance serves. * @throws {Error} Throws an error if an unknown output type is specified. */ -export const registerGZipFileAsResourceTool = (server: McpServer) => { +export const registerGZipFileAsResourceTool = ( + server: McpServer, + context: ServerContext, +) => { server.registerTool(name, config, async (args): Promise => { const { name, @@ -100,14 +118,6 @@ export const registerGZipFileAsResourceTool = (server: McpServer) => { const mimeType = "application/gzip"; const resource = { uri, name, mimeType }; - // Register resource, get resource link in return - const resourceLink = registerSessionResource( - server, - resource, - "blob", - blob, - ); - // Return the resource or a resource link that can be used to access this resource later if (outputType === "resource") { return { @@ -119,6 +129,11 @@ export const registerGZipFileAsResourceTool = (server: McpServer) => { ], }; } else if (outputType === "resourceLink") { + // Make the resource readable later, where this era can reach it + const resourceLink = + context.era === "modern" + ? mintBlobResource(name, mimeType, blob) + : registerSessionResource(server, resource, "blob", blob); return { content: [resourceLink], }; diff --git a/src/everything/tools/index.ts b/src/everything/tools/index.ts index 3e71f17740..361cd67bcb 100644 --- a/src/everything/tools/index.ts +++ b/src/everything/tools/index.ts @@ -1,7 +1,22 @@ +// Registers the everything server's tools, grouped by the protocol era that +// gets them (#4852): +// - `registerTools`: every client, on either era. +// - `registerLegacyTools`: 2025-era clients only, for the deprecated features +// the 2026-07-28 revision removes (`logging/setLevel`, `resources/subscribe`). +// - `registerConditionalTools`: 2025-era clients that declared the capability +// a tool needs; called once `initialize` has told the server what the client +// supports. +// - `registerModernTools`: 2026-07-28 clients. Their capabilities arrive with +// each request, so the capability-gated tools are registered up front and the +// SDK refuses an embedded request the client did not declare (-32021); the +// task tools are served only here, on the tasks extension. + import { McpServer } from "@modelcontextprotocol/server"; +import type { ServerContext } from "../server/context.js"; import { registerGetAnnotatedMessageTool } from "./get-annotated-message.js"; import { registerEchoTool } from "./echo.js"; import { registerGetEnvTool } from "./get-env.js"; +import { registerGetHeaderParamsTool } from "./get-header-params.js"; import { registerGetResourceLinksTool } from "./get-resource-links.js"; import { registerGetResourceReferenceTool } from "./get-resource-reference.js"; import { registerGetRootsListTool } from "./get-roots-list.js"; @@ -9,43 +24,104 @@ import { registerGetStructuredContentTool } from "./get-structured-content.js"; import { registerGetSumTool } from "./get-sum.js"; import { registerGetTinyImageTool } from "./get-tiny-image.js"; import { registerGZipFileAsResourceTool } from "./gzip-file-as-resource.js"; +import { registerSimulateResearchQueryTool } from "./simulate-research-query.js"; import { registerToggleSimulatedLoggingTool } from "./toggle-simulated-logging.js"; import { registerToggleSubscriberUpdatesTool } from "./toggle-subscriber-updates.js"; +import { registerTriggerElicitationRequestAsyncTool } from "./trigger-elicitation-request-async.js"; import { registerTriggerElicitationRequestTool } from "./trigger-elicitation-request.js"; +import { registerTriggerListChangedTool } from "./trigger-list-changed.js"; +import { registerTriggerLogMessagesTool } from "./trigger-log-messages.js"; import { registerTriggerLongRunningOperationTool } from "./trigger-long-running-operation.js"; +import { registerTriggerMultiStepElicitationTool } from "./trigger-multi-step-elicitation.js"; +import { registerTriggerResourceUpdateTool } from "./trigger-resource-update.js"; +import { registerTriggerSamplingRequestAsyncTool } from "./trigger-sampling-request-async.js"; import { registerTriggerSamplingRequestTool } from "./trigger-sampling-request.js"; import { registerTriggerUrlElicitationTool } from "./trigger-url-elicitation.js"; /** - * Register the tools with the MCP server. + * Register the tools every client gets, on either protocol era. * @param server + * @param context */ -export const registerTools = (server: McpServer) => { +export const registerTools = (server: McpServer, context: ServerContext) => { registerEchoTool(server); registerGetAnnotatedMessageTool(server); registerGetEnvTool(server); + registerGetHeaderParamsTool(server); registerGetResourceLinksTool(server); registerGetResourceReferenceTool(server); registerGetStructuredContentTool(server); registerGetSumTool(server); registerGetTinyImageTool(server); - registerGZipFileAsResourceTool(server); - registerToggleSimulatedLoggingTool(server); - registerToggleSubscriberUpdatesTool(server); + registerGZipFileAsResourceTool(server, context); + registerTriggerListChangedTool(server, context); + registerTriggerLogMessagesTool(server); registerTriggerLongRunningOperationTool(server); + registerTriggerResourceUpdateTool(server, context); }; /** - * Register the tools that are conditional upon client capabilities. - * These must be registered conditionally, after initialization. + * Register the deprecated tools that only a 2025-era client gets, because + * they rely on methods the 2026-07-28 revision removes. + * @param server + * @param context + */ +export const registerLegacyTools = ( + server: McpServer, + context: ServerContext, +) => { + registerToggleSimulatedLoggingTool(server, context); + registerToggleSubscriberUpdatesTool(server, context); +}; + +/** + * Register the tools that need a client capability, for a 2025-era client + * that declared it. These must be registered after initialization, when the + * client's capabilities are known. + * @param server + * @param context + */ +export const registerConditionalTools = ( + server: McpServer, + context: ServerContext, +) => { + const capabilities = server.server.getClientCapabilities() || {}; + const elicitation = capabilities.elicitation; + // Form mode is `elicitation.form`, or `elicitation` with neither mode, + // which the spec treats as form mode for backwards compatibility (#4985). + const supportsFormElicitation = + elicitation !== undefined && + (elicitation.form !== undefined || elicitation.url === undefined); + + if (capabilities.roots !== undefined) registerGetRootsListTool(server); + if (supportsFormElicitation) { + registerTriggerElicitationRequestTool(server); + registerTriggerMultiStepElicitationTool(server); + } + if (elicitation?.url !== undefined) { + registerTriggerUrlElicitationTool(server, context); + } + if (capabilities.sampling !== undefined) { + registerTriggerSamplingRequestTool(server); + } +}; + +/** + * Register the tools a 2026-07-28 client gets beyond the common ones: the + * capability-gated tools, registered up front, and the task tools. + * @param server + * @param context */ -export const registerConditionalTools = (server: McpServer) => { +export const registerModernTools = ( + server: McpServer, + context: ServerContext, +) => { registerGetRootsListTool(server); registerTriggerElicitationRequestTool(server); - registerTriggerUrlElicitationTool(server); + registerTriggerMultiStepElicitationTool(server); + registerTriggerUrlElicitationTool(server, context); registerTriggerSamplingRequestTool(server); - // The task tools (simulate-research-query, trigger-sampling-request-async, - // trigger-elicitation-request-async) were removed with SDK v2, which drops - // the experimental tasks layer (SEP-2663). Part 5 (#4852) re-implements them - // on the `io.modelcontextprotocol/tasks` extension. + registerSimulateResearchQueryTool(server); + registerTriggerSamplingRequestAsyncTool(server); + registerTriggerElicitationRequestAsyncTool(server); }; diff --git a/src/everything/tools/simulate-research-query.ts b/src/everything/tools/simulate-research-query.ts new file mode 100644 index 0000000000..8b2864a28b --- /dev/null +++ b/src/everything/tools/simulate-research-query.ts @@ -0,0 +1,256 @@ +// The `simulate-research-query` tool: a simulated deep-research job that runs +// as a task on the Tasks extension (`io.modelcontextprotocol/tasks`), on +// protocol version 2026-07-28 (#4852). +// +// The call returns a task handle at once. The task works through four +// stages, one a second, and the client follows it with `tasks/get`. For an +// ambiguous topic it stops at the third stage with status `input_required`, +// carrying an elicitation that asks which interpretation the user means; the +// client answers it with `tasks/update` and the task carries on. The final +// report arrives inside the `completed` status. The task's progress is worked +// out from the clock when it is read (server/tasks.ts), so nothing runs in +// the background. +// +// SDK v2 removed the experimental 2025-11-25 tasks layer this tool was first +// built on (SEP-1686), so it is registered for 2026-07-28 clients only. + +import { + McpServer, + CallToolResult, + acceptedContent, + inputRequired, + inputResponse, + type InputResponses, +} from "@modelcontextprotocol/server"; +import { z } from "zod"; +import { + clientCapabilitiesOf, + clientSupportsTasks, + createTask, + tasksRequired, + type TaskState, +} from "../server/tasks.js"; + +// Tool input schema +const SimulateResearchQuerySchema = z.object({ + topic: z.string().describe("The research topic to investigate"), + ambiguous: z + .boolean() + .default(false) + .describe( + "Simulate an ambiguous query that requires clarification (triggers input_required status)", + ), +}); + +/** The stages a research task works through, in order. */ +export const STAGES = [ + "Gathering sources", + "Analyzing content", + "Synthesizing findings", + "Generating report", +]; + +/** How long each stage takes, in milliseconds. */ +export const STAGE_DURATION = 1000; + +/** The stage an ambiguous query stops before, to ask for a clarification. */ +const CLARIFY_AT = 2; + +// Tool configuration +const name = "simulate-research-query"; +const config = { + title: "Simulate Research Query", + description: + "Simulates a deep research operation that gathers, analyzes, and synthesizes information. " + + "Runs as a task on the io.modelcontextprotocol/tasks extension (protocol version 2026-07-28): " + + "the call returns a task handle, and the client polls tasks/get as the task moves through four stages. " + + "If 'ambiguous' is true and the client supports elicitation, the task pauses with status " + + "input_required until the client answers its clarification request with tasks/update.", + inputSchema: SimulateResearchQuerySchema, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, +}; + +/** The clarification question's answer. */ +const ClarificationSchema = z.object({ interpretation: z.string() }); + +/** + * Returns contextual interpretation options based on the topic. + */ +function getInterpretationsForTopic( + topic: string, +): Array<{ const: string; title: string }> { + const lowerTopic = topic.toLowerCase(); + + if (lowerTopic.includes("python")) { + return [ + { const: "programming", title: "Python programming language" }, + { const: "snake", title: "Python snake species" }, + { const: "comedy", title: "Monty Python comedy group" }, + ]; + } + + return [ + { const: "technical", title: "Technical/scientific perspective" }, + { const: "historical", title: "Historical perspective" }, + { const: "current", title: "Current events/news perspective" }, + ]; +} + +/** What the user's answer to the clarification request means. */ +function clarificationFrom(responses: InputResponses): string { + const view = inputResponse(responses, "clarification"); + const accepted = acceptedContent( + responses, + "clarification", + ClarificationSchema, + ); + if (accepted) return accepted.interpretation; + if (view.kind === "elicit" && view.action === "decline") { + return "User declined - using default interpretation"; + } + if (view.kind === "elicit" && view.action === "cancel") { + return "User cancelled - using default interpretation"; + } + return "No usable answer - using default interpretation"; +} + +/** + * Generates the final research report with educational content about tasks. + */ +function generateResearchReport( + topic: string, + clarification: string | undefined, +): CallToolResult { + const subject = clarification ? `${topic} (${clarification})` : topic; + const report = `# Research Report: ${subject} + +## Research Parameters +- **Topic**: ${topic} +${clarification ? `- **Clarification**: ${clarification}` : ""} + +## Synthesis +This research query was processed through ${STAGES.length} stages: +${STAGES.map((s, i) => `- Stage ${i + 1}: ${s} ✓`).join("\n")} + +--- + +## About This Demo (SEP-2663: the Tasks extension) + +This tool demonstrates MCP's task-based execution pattern for long-running operations: + +**Task Lifecycle Demonstrated:** +1. \`tools/call\` → the server returns a task handle (\`resultType: "task"\`), not the final result +2. Client polls \`tasks/get\` → the server returns the current status and \`statusMessage\` +3. Status progressed: \`working\` → ${ + clarification ? `\`input_required\` → \`working\` → ` : "" + }\`completed\` +4. The \`completed\` status carries this final result +${ + clarification + ? ` +**Input Flow:** +The query was ambiguous, so the task paused with status \`input_required\`, +carrying an elicitation request. The client answered it with \`tasks/update\` +("${clarification}"), and the task resumed and completed. +` + : "" +} +**Key Concepts:** +- Tasks enable "call now, fetch later" patterns +- \`statusMessage\` provides human-readable progress updates +- Tasks have a TTL (\`ttlMs\`) for automatic cleanup +- \`pollIntervalMs\` suggests how often to check status +- A task asks for input in-band, through its \`input_required\` status + +*This is a simulated research report from the Everything MCP Server.* +`; + + return { content: [{ type: "text", text: report }] }; +} + +/** + * Registers the 'simulate-research-query' tool, for 2026-07-28 clients. + * + * A client that does not declare the tasks extension gets an error result, + * since it could not follow the task handle. `ambiguous` is ignored for a + * client that does not support elicitation. + * + * @param {McpServer} server - The McpServer instance where the tool will be registered. + */ +export const registerSimulateResearchQueryTool = (server: McpServer) => { + server.registerTool( + name, + config, + async (args, ctx): Promise => { + if (!clientSupportsTasks(ctx)) return tasksRequired(name); + const { topic, ambiguous } = SimulateResearchQuerySchema.parse(args); + const canAsk = clientCapabilitiesOf(ctx)?.elicitation !== undefined; + + // The clock time the stages started counting from, and the stage they + // started at: an ambiguous query restarts the count once it is answered. + let startedAt = Date.now(); + let startStage = 0; + let clarification: string | undefined; + const needsClarification = () => + ambiguous && canAsk && clarification === undefined; + + return createTask( + { + stateAt: (now): TaskState => { + const stage = + startStage + Math.floor((now - startedAt) / STAGE_DURATION); + if (needsClarification() && stage >= CLARIFY_AT) { + return { + status: "input_required", + statusMessage: `Found multiple interpretations for "${topic}". Requesting clarification...`, + inputRequests: { + clarification: inputRequired.elicit({ + message: `The research query "${topic}" could have multiple interpretations. Please clarify what you're looking for:`, + requestedSchema: { + type: "object", + properties: { + interpretation: { + type: "string", + title: "Clarification", + description: + "Which interpretation of the topic do you mean?", + oneOf: getInterpretationsForTopic(topic), + }, + }, + required: ["interpretation"], + }, + }), + }, + }; + } + if (stage >= STAGES.length) { + return { + status: "completed", + statusMessage: "Research complete", + result: generateResearchReport(topic, clarification), + }; + } + return { + status: "working", + statusMessage: + clarification !== undefined && stage === CLARIFY_AT + ? `Continuing with interpretation: "${clarification}"...` + : `${STAGES[stage]}...`, + }; + }, + receive: (responses, now) => { + clarification = clarificationFrom(responses); + startedAt = now; + startStage = CLARIFY_AT; + }, + }, + { ttlMs: 5 * 60_000, pollIntervalMs: STAGE_DURATION }, + ); + }, + ); +}; diff --git a/src/everything/tools/toggle-simulated-logging.ts b/src/everything/tools/toggle-simulated-logging.ts index a847e6459a..486a13fd8e 100644 --- a/src/everything/tools/toggle-simulated-logging.ts +++ b/src/everything/tools/toggle-simulated-logging.ts @@ -1,14 +1,26 @@ +// The `toggle-simulated-logging` tool: starts or stops a timer that sends the +// client a random-leveled log message every five seconds, filtered by the +// level it set with `logging/setLevel`. +// +// 2025-era only, and deprecated: the 2026-07-28 revision removes +// `logging/setLevel` and unsolicited log messages (and deprecates logging), +// so the server factory registers this tool only for a 2025-era client. +// `trigger-log-messages` shows the 2026-07-28 replacement, a log level set +// per request. The timer belongs to the server instance serving this client, +// so no session map is needed (#4852). + import { McpServer, CallToolResult } from "@modelcontextprotocol/server"; -import { - beginSimulatedLogging, - stopSimulatedLogging, -} from "../server/logging.js"; +import { beginSimulatedLogging } from "../server/logging.js"; +import type { ServerContext } from "../server/context.js"; // Tool configuration const name = "toggle-simulated-logging"; const config = { title: "Toggle Simulated Logging", - description: "Toggles simulated, random-leveled logging on or off.", + description: + "Toggles simulated, random-leveled logging on or off. Deprecated: 2025-era clients only, since " + + "protocol version 2026-07-28 removes logging/setLevel; use trigger-log-messages with a per-request " + + "log level instead.", inputSchema: {}, annotations: { readOnlyHint: false, @@ -18,42 +30,41 @@ const config = { }, }; -// Track enabled clients by session id -const clients: Set = new Set(); - /** * Registers the `toggle-simulated-logging` tool. * * The registered tool enables or disables the sending of periodic, random-leveled - * logging messages the connected client. - * - * When invoked, it either starts or stops simulated logging based on the session's - * current state. If logging for the specified session is active, it will be stopped; - * if it is inactive, logging will be started. + * logging messages to the connected client. Each call flips the state: it + * starts the messages when they are off and stops them when they are on. The + * messages also stop when the connection ends. * * @param {McpServer} server - The McpServer instance where the tool will be registered. + * @param {ServerContext} context - The instance's cleanup registry. */ -export const registerToggleSimulatedLoggingTool = (server: McpServer) => { - server.registerTool( - name, - config, - async (_args, ctx): Promise => { - const sessionId = ctx?.sessionId; +export const registerToggleSimulatedLoggingTool = ( + server: McpServer, + context: ServerContext, +) => { + let stop: (() => void) | undefined; + context.onCleanup(() => { + stop?.(); + stop = undefined; + }); - let response: string; - if (clients.has(sessionId)) { - stopSimulatedLogging(sessionId); - clients.delete(sessionId); - response = `Stopped simulated logging for session ${sessionId}`; - } else { - beginSimulatedLogging(server, sessionId); - clients.add(sessionId); - response = `Started simulated, random-leveled logging for session ${sessionId} at a 5 second pace. Client's selected logging level will be respected. If an interval elapses and the message to be sent is below the selected level, it will not be sent. Thus at higher chosen logging levels, messages should arrive further apart. `; - } + server.registerTool(name, config, async (): Promise => { + let response: string; + if (stop) { + stop(); + stop = undefined; + response = "Stopped simulated logging"; + } else { + stop = beginSimulatedLogging(server); + response = + "Started simulated, random-leveled logging at a 5 second pace. Client's selected logging level will be respected. If an interval elapses and the message to be sent is below the selected level, it will not be sent. Thus at higher chosen logging levels, messages should arrive further apart."; + } - return { - content: [{ type: "text", text: `${response}` }], - }; - }, - ); + return { + content: [{ type: "text", text: response }], + }; + }); }; diff --git a/src/everything/tools/toggle-subscriber-updates.ts b/src/everything/tools/toggle-subscriber-updates.ts index 2b53fded57..596125baa2 100644 --- a/src/everything/tools/toggle-subscriber-updates.ts +++ b/src/everything/tools/toggle-subscriber-updates.ts @@ -1,14 +1,26 @@ +// The `toggle-subscriber-updates` tool: starts or stops a timer that sends +// `notifications/resources/updated` for each resource the client subscribed +// to with `resources/subscribe`. +// +// 2025-era only, and deprecated: the 2026-07-28 revision removes +// `resources/subscribe` and unsolicited notifications, so the server factory +// registers this tool only for a 2025-era client. A 2026-07-28 client asks +// for updates in its `subscriptions/listen` filter, and +// `trigger-resource-update` sends one on demand. The timer belongs to the +// server instance serving this client, so no session map is needed (#4852). + import { McpServer, CallToolResult } from "@modelcontextprotocol/server"; -import { - beginSimulatedResourceUpdates, - stopSimulatedResourceUpdates, -} from "../resources/subscriptions.js"; +import { beginSimulatedResourceUpdates } from "../resources/subscriptions.js"; +import type { ServerContext } from "../server/context.js"; // Tool configuration const name = "toggle-subscriber-updates"; const config = { title: "Toggle Subscriber Updates", - description: "Toggles simulated resource subscription updates on or off.", + description: + "Toggles simulated resource subscription updates on or off. Deprecated: 2025-era clients only, " + + "since protocol version 2026-07-28 removes resources/subscribe; use trigger-resource-update with a " + + "subscriptions/listen stream instead.", inputSchema: {}, annotations: { readOnlyHint: false, @@ -18,45 +30,41 @@ const config = { }, }; -// Track enabled clients by session id -const clients: Set = new Set(); - /** * Registers the `toggle-subscriber-updates` tool. * * The registered tool enables or disables the sending of periodic, simulated resource - * update messages the connected client for any subscriptions they have made. - * - * When invoked, it either starts or stops simulated resource updates based on the session's - * current state. If simulated updates for the specified session is active, it will be stopped; - * if it is inactive, simulated updates will be started. - * - * The response provides feedback indicating whether simulated updates were started or stopped, - * including the session ID. + * update messages to the connected client for any subscriptions it has made. + * Each call flips the state: it starts the updates when they are off and stops + * them when they are on. The updates also stop when the connection ends. * * @param {McpServer} server - The McpServer instance where the tool will be registered. + * @param {ServerContext} context - The instance's subscriptions and cleanup registry. */ -export const registerToggleSubscriberUpdatesTool = (server: McpServer) => { - server.registerTool( - name, - config, - async (_args, ctx): Promise => { - const sessionId = ctx?.sessionId; +export const registerToggleSubscriberUpdatesTool = ( + server: McpServer, + context: ServerContext, +) => { + let stop: (() => void) | undefined; + context.onCleanup(() => { + stop?.(); + stop = undefined; + }); - let response: string; - if (clients.has(sessionId)) { - stopSimulatedResourceUpdates(sessionId); - clients.delete(sessionId); - response = `Stopped simulated resource updates for session ${sessionId}`; - } else { - beginSimulatedResourceUpdates(server, sessionId); - clients.add(sessionId); - response = `Started simulated resource updated notifications for session ${sessionId} at a 5 second pace. Client will receive updates for any resources the it is subscribed to.`; - } + server.registerTool(name, config, async (): Promise => { + let response: string; + if (stop) { + stop(); + stop = undefined; + response = "Stopped simulated resource updates"; + } else { + stop = beginSimulatedResourceUpdates(server, context.subscribed); + response = + "Started simulated resource updated notifications at a 5 second pace. Client will receive updates for any resources it is subscribed to."; + } - return { - content: [{ type: "text", text: `${response}` }], - }; - }, - ); + return { + content: [{ type: "text", text: response }], + }; + }); }; diff --git a/src/everything/tools/trigger-elicitation-request-async.ts b/src/everything/tools/trigger-elicitation-request-async.ts new file mode 100644 index 0000000000..47d70fa003 --- /dev/null +++ b/src/everything/tools/trigger-elicitation-request-async.ts @@ -0,0 +1,133 @@ +// The `trigger-elicitation-request-async` tool: a task on the Tasks extension +// (`io.modelcontextprotocol/tasks`) that needs the user's input before it can +// finish, on protocol version 2026-07-28 (#4852). +// +// On 2025-11-25 this tool sent the client a task-augmented +// `elicitation/create` request and polled the client's own task. The +// 2026-07-28 revision has no server-to-client requests, so the roles turn +// around: the call returns a handle to a server task whose status is +// `input_required`, carrying the elicitation. The client shows it to the user +// and answers with `tasks/update`, and the task completes with a summary. + +import { + McpServer, + CallToolResult, + inputRequired, + inputResponse, +} from "@modelcontextprotocol/server"; +import { + clientSupportsTasks, + createTask, + tasksRequired, + type TaskState, +} from "../server/tasks.js"; + +// Tool configuration +const name = "trigger-elicitation-request-async"; +const config = { + title: "Trigger Async Elicitation Request Tool", + description: + "Runs as a task on the io.modelcontextprotocol/tasks extension (protocol version 2026-07-28) that needs " + + "the user's input: the task waits in status input_required, carrying an elicitation request, until the " + + "client answers it with tasks/update, then completes with a summary of the answer.", + inputSchema: {}, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, +}; + +/** The form the task asks the user to fill in. */ +const requestedSchema = { + type: "object" as const, + properties: { + name: { + type: "string" as const, + title: "Your Name", + description: "Enter your name", + }, + favoriteColor: { + type: "string" as const, + title: "Favorite Color", + description: "What is your favorite color?", + enum: ["red", "orange", "yellow", "green", "blue", "purple"], + }, + agreeToTerms: { + type: "boolean" as const, + title: "Agree to Terms", + description: "Do you agree to the terms and conditions?", + }, + }, + required: ["name"], +}; + +/** The completed task's summary of the user's answer. */ +function summarize( + action: string, + content: Record | undefined, +): CallToolResult { + const lines = [`Elicitation ${action}.`]; + if (action === "accept" && content) { + if (content.name) lines.push(`Name: ${content.name}`); + if (content.favoriteColor) { + lines.push(`Favorite color: ${content.favoriteColor}`); + } + if (content.agreeToTerms !== undefined) { + lines.push(`Agreed to terms: ${content.agreeToTerms}`); + } + } + return { content: [{ type: "text", text: lines.join("\n") }] }; +} + +/** + * Registers the 'trigger-elicitation-request-async' tool, for 2026-07-28 + * clients. + * + * A client that does not declare the tasks extension gets an error result. + * + * @param {McpServer} server - The McpServer instance where the tool will be registered. + */ +export const registerTriggerElicitationRequestAsyncTool = ( + server: McpServer, +) => { + server.registerTool( + name, + config, + async (_args, ctx): Promise => { + if (!clientSupportsTasks(ctx)) return tasksRequired(name); + let summary: CallToolResult | undefined; + + return createTask( + { + stateAt: (): TaskState => + summary === undefined + ? { + status: "input_required", + statusMessage: "Waiting for the user's input", + inputRequests: { + form: inputRequired.elicit({ + message: + "Please provide inputs for the following fields (async task demo):", + requestedSchema, + }), + }, + } + : { + status: "completed", + statusMessage: "Input received", + result: summary, + }, + receive: (responses) => { + const answer = inputResponse(responses, "form"); + if (answer.kind === "elicit") { + summary = summarize(answer.action, answer.content); + } + }, + }, + { ttlMs: 10 * 60_000, pollIntervalMs: 1000 }, + ); + }, + ); +}; diff --git a/src/everything/tools/trigger-elicitation-request.ts b/src/everything/tools/trigger-elicitation-request.ts index 2df4500d93..44bb1041a2 100644 --- a/src/everything/tools/trigger-elicitation-request.ts +++ b/src/everything/tools/trigger-elicitation-request.ts @@ -1,4 +1,22 @@ -import { McpServer, CallToolResult } from "@modelcontextprotocol/server"; +// The `trigger-elicitation-request` tool: asks the user to fill in a form +// that exercises every field type form-mode elicitation supports, and +// summarizes the answer. +// +// Written once for both protocol eras with a multi-round-trip request +// (#4852): the first call returns `input_required` carrying an +// `elicitation/create` request, the client shows the form and retries with +// the answer, and the retry summarizes it. A 2026-07-28 client does that +// in-band; for a 2025-era client the SDK's legacy shim sends a real +// `elicitation/create` request and re-enters the handler with the answer. + +import { + McpServer, + CallToolResult, + ElicitRequestFormParams, + inputRequired, + inputResponse, + type InputRequiredResult, +} from "@modelcontextprotocol/server"; // Tool configuration const name = "trigger-elicitation-request"; @@ -14,223 +32,210 @@ const config = { }, }; +/** The form: one field of each type form-mode elicitation supports. */ +const requestedSchema: ElicitRequestFormParams["requestedSchema"] = { + type: "object", + properties: { + name: { + title: "String", + type: "string", + description: "Your full, legal name", + }, + check: { + title: "Boolean", + type: "boolean", + description: "Agree to the terms and conditions", + }, + firstLine: { + title: "String with default", + type: "string", + description: "Favorite first line of a story", + default: "It was a dark and stormy night.", + }, + email: { + title: "String with email format", + type: "string", + format: "email", + description: + "Your email address (will be verified, and never shared with anyone else)", + }, + homepage: { + type: "string", + format: "uri", + title: "String with uri format", + description: "Portfolio / personal website", + }, + birthdate: { + title: "String with date format", + type: "string", + format: "date", + description: "Your date of birth", + }, + integer: { + title: "Integer", + type: "integer", + description: + "Your favorite integer (do not give us your phone number, pin, or other sensitive info)", + minimum: 1, + maximum: 100, + default: 42, + }, + number: { + title: "Number in range 1-1000", + type: "number", + description: "Favorite number (there are no wrong answers)", + minimum: 0, + maximum: 1000, + default: 3.14, + }, + untitledSingleSelectEnum: { + type: "string", + title: "Untitled Single Select Enum", + description: "Choose your favorite friend", + enum: ["Monica", "Rachel", "Joey", "Chandler", "Ross", "Phoebe"], + default: "Monica", + }, + untitledMultipleSelectEnum: { + type: "array", + title: "Untitled Multiple Select Enum", + description: "Choose your favorite instruments", + minItems: 1, + maxItems: 3, + items: { + type: "string", + enum: ["Guitar", "Piano", "Violin", "Drums", "Bass"], + }, + default: ["Guitar"], + }, + titledSingleSelectEnum: { + type: "string", + title: "Titled Single Select Enum", + description: "Choose your favorite hero", + oneOf: [ + { const: "hero-1", title: "Superman" }, + { const: "hero-2", title: "Green Lantern" }, + { const: "hero-3", title: "Wonder Woman" }, + ], + default: "hero-1", + }, + titledMultipleSelectEnum: { + type: "array", + title: "Titled Multiple Select Enum", + description: "Choose your favorite types of fish", + minItems: 1, + maxItems: 3, + items: { + anyOf: [ + { const: "fish-1", title: "Tuna" }, + { const: "fish-2", title: "Salmon" }, + { const: "fish-3", title: "Trout" }, + ], + }, + default: ["fish-1"], + }, + legacyTitledEnum: { + type: "string", + title: "Legacy Titled Single Select Enum", + description: "Choose your favorite type of pet", + enum: ["pet-1", "pet-2", "pet-3", "pet-4", "pet-5"], + enumNames: ["Cats", "Dogs", "Birds", "Fish", "Reptiles"], + default: "pet-1", + }, + }, + required: ["name"], +}; + /** * Registers the 'trigger-elicitation-request' tool. * - * The tool sends a form-mode request, so it is registered only when the client - * supports form-mode elicitation: it declared `elicitation.form`, or an - * `elicitation` object with neither `form` nor `url`, which the spec treats as - * form mode for backwards compatibility. A client that declared only - * `elicitation.url` does not get it (#4985). - * - * The registered tool sends an elicitation request for the user to provide information - * based on a pre-defined schema of fields including text inputs, booleans, numbers, - * email, dates, enums of various types, etc. It uses validation and handles multiple - * possible outcomes from the user's response, such as acceptance with content, decline, - * or cancellation of the dialog. The process also ensures parsing and validating - * the elicitation input arguments at runtime. + * The tool needs a client that supports form-mode elicitation. On a 2025-era + * connection the server factory registers it only for a client that declared + * `elicitation.form`, or an `elicitation` object with neither `form` nor + * `url`, which the spec treats as form mode for backwards compatibility; a + * client that declared only `elicitation.url` does not get it (#4985). On + * 2026-07-28 the SDK refuses the embedded request (-32021) of a client whose + * request did not declare form-mode elicitation. * - * The elicitation dialog response is returned, formatted into a structured result, - * which contains both user-submitted input data (if provided) and debugging information, - * including raw results. + * The first round sends the form. The retry reads the user's response, which + * may be acceptance with content, decline, or cancellation of the dialog, and + * returns a structured summary with the raw result for debugging. * - * @param {McpServer} server - TThe McpServer instance where the tool will be registered. + * @param {McpServer} server - The McpServer instance where the tool will be registered. */ export const registerTriggerElicitationRequestTool = (server: McpServer) => { - // Does the client support form-mode elicitation? - const clientCapabilities = server.server.getClientCapabilities() || {}; - const elicitation = clientCapabilities.elicitation; - const clientSupportsFormElicitation: boolean = - elicitation !== undefined && - (elicitation.form !== undefined || elicitation.url === undefined); + server.registerTool( + name, + config, + async (_args, ctx): Promise => { + const answer = inputResponse(ctx.mcpReq.inputResponses, "form"); - // If so, register tool - if (clientSupportsFormElicitation) { - server.registerTool( - name, - config, - async (args, ctx): Promise => { - const elicitationResult = await ctx.mcpReq.send( - { - method: "elicitation/create", - params: { + // First round: show the user the form + if (answer.kind !== "elicit") { + return inputRequired({ + inputRequests: { + form: inputRequired.elicit({ message: "Please provide inputs for the following fields:", - requestedSchema: { - type: "object", - properties: { - name: { - title: "String", - type: "string", - description: "Your full, legal name", - }, - check: { - title: "Boolean", - type: "boolean", - description: "Agree to the terms and conditions", - }, - firstLine: { - title: "String with default", - type: "string", - description: "Favorite first line of a story", - default: "It was a dark and stormy night.", - }, - email: { - title: "String with email format", - type: "string", - format: "email", - description: - "Your email address (will be verified, and never shared with anyone else)", - }, - homepage: { - type: "string", - format: "uri", - title: "String with uri format", - description: "Portfolio / personal website", - }, - birthdate: { - title: "String with date format", - type: "string", - format: "date", - description: "Your date of birth", - }, - integer: { - title: "Integer", - type: "integer", - description: - "Your favorite integer (do not give us your phone number, pin, or other sensitive info)", - minimum: 1, - maximum: 100, - default: 42, - }, - number: { - title: "Number in range 1-1000", - type: "number", - description: "Favorite number (there are no wrong answers)", - minimum: 0, - maximum: 1000, - default: 3.14, - }, - untitledSingleSelectEnum: { - type: "string", - title: "Untitled Single Select Enum", - description: "Choose your favorite friend", - enum: [ - "Monica", - "Rachel", - "Joey", - "Chandler", - "Ross", - "Phoebe", - ], - default: "Monica", - }, - untitledMultipleSelectEnum: { - type: "array", - title: "Untitled Multiple Select Enum", - description: "Choose your favorite instruments", - minItems: 1, - maxItems: 3, - items: { - type: "string", - enum: ["Guitar", "Piano", "Violin", "Drums", "Bass"], - }, - default: ["Guitar"], - }, - titledSingleSelectEnum: { - type: "string", - title: "Titled Single Select Enum", - description: "Choose your favorite hero", - oneOf: [ - { const: "hero-1", title: "Superman" }, - { const: "hero-2", title: "Green Lantern" }, - { const: "hero-3", title: "Wonder Woman" }, - ], - default: "hero-1", - }, - titledMultipleSelectEnum: { - type: "array", - title: "Titled Multiple Select Enum", - description: "Choose your favorite types of fish", - minItems: 1, - maxItems: 3, - items: { - anyOf: [ - { const: "fish-1", title: "Tuna" }, - { const: "fish-2", title: "Salmon" }, - { const: "fish-3", title: "Trout" }, - ], - }, - default: ["fish-1"], - }, - legacyTitledEnum: { - type: "string", - title: "Legacy Titled Single Select Enum", - description: "Choose your favorite type of pet", - enum: ["pet-1", "pet-2", "pet-3", "pet-4", "pet-5"], - enumNames: ["Cats", "Dogs", "Birds", "Fish", "Reptiles"], - default: "pet-1", - }, - }, - required: ["name"], - }, - }, + requestedSchema, + }), }, - { timeout: 10 * 60 * 1000 /* 10 minutes */ }, - ); + }); + } - // Handle different response actions - const content: CallToolResult["content"] = []; + // The answer as the client sent it, for the debugging output + const rawResult = { + action: answer.action, + ...(answer.content !== undefined && { content: answer.content }), + }; - if ( - elicitationResult.action === "accept" && - elicitationResult.content - ) { - content.push({ - type: "text", - text: `✅ User provided the requested information!`, - }); + // Handle different response actions + const content: CallToolResult["content"] = []; - // Only access elicitationResult.content when action is accept - const userData = elicitationResult.content; - const lines = []; - if (userData.name) lines.push(`- Name: ${userData.name}`); - if (userData.check !== undefined) - lines.push(`- Agreed to terms: ${userData.check}`); - if (userData.color) lines.push(`- Favorite Color: ${userData.color}`); - if (userData.email) lines.push(`- Email: ${userData.email}`); - if (userData.homepage) lines.push(`- Homepage: ${userData.homepage}`); - if (userData.birthdate) - lines.push(`- Birthdate: ${userData.birthdate}`); - if (userData.integer !== undefined) - lines.push(`- Favorite Integer: ${userData.integer}`); - if (userData.number !== undefined) - lines.push(`- Favorite Number: ${userData.number}`); - if (userData.petType) lines.push(`- Pet Type: ${userData.petType}`); + if (answer.action === "accept" && answer.content) { + content.push({ + type: "text", + text: `✅ User provided the requested information!`, + }); - content.push({ - type: "text", - text: `User inputs:\n${lines.join("\n")}`, - }); - } else if (elicitationResult.action === "decline") { - content.push({ - type: "text", - text: `❌ User declined to provide the requested information.`, - }); - } else if (elicitationResult.action === "cancel") { - content.push({ - type: "text", - text: `⚠️ User cancelled the elicitation dialog.`, - }); - } + // Only access answer.content when action is accept + const userData = answer.content; + const lines = []; + if (userData.name) lines.push(`- Name: ${userData.name}`); + if (userData.check !== undefined) + lines.push(`- Agreed to terms: ${userData.check}`); + if (userData.color) lines.push(`- Favorite Color: ${userData.color}`); + if (userData.email) lines.push(`- Email: ${userData.email}`); + if (userData.homepage) lines.push(`- Homepage: ${userData.homepage}`); + if (userData.birthdate) + lines.push(`- Birthdate: ${userData.birthdate}`); + if (userData.integer !== undefined) + lines.push(`- Favorite Integer: ${userData.integer}`); + if (userData.number !== undefined) + lines.push(`- Favorite Number: ${userData.number}`); + if (userData.petType) lines.push(`- Pet Type: ${userData.petType}`); - // Include raw result for debugging content.push({ type: "text", - text: `\nRaw result: ${JSON.stringify(elicitationResult, null, 2)}`, + text: `User inputs:\n${lines.join("\n")}`, }); + } else if (answer.action === "decline") { + content.push({ + type: "text", + text: `❌ User declined to provide the requested information.`, + }); + } else if (answer.action === "cancel") { + content.push({ + type: "text", + text: `⚠️ User cancelled the elicitation dialog.`, + }); + } - return { content }; - }, - ); - } + // Include raw result for debugging + content.push({ + type: "text", + text: `\nRaw result: ${JSON.stringify(rawResult, null, 2)}`, + }); + + return { content }; + }, + ); }; diff --git a/src/everything/tools/trigger-list-changed.ts b/src/everything/tools/trigger-list-changed.ts new file mode 100644 index 0000000000..69fbccad63 --- /dev/null +++ b/src/everything/tools/trigger-list-changed.ts @@ -0,0 +1,64 @@ +// The `trigger-list-changed` tool: announces that the server's tool, prompt +// or resource list changed, so a client can watch the notification arrive. +// +// Where the notification goes depends on the protocol era (#4852). A +// 2025-era client gets it unsolicited. A 2026-07-28 client gets it only on a +// `subscriptions/listen` stream whose filter asked for that list +// (`toolsListChanged`, `promptsListChanged` or `resourcesListChanged`). The +// server context's publisher picks the route: the instance itself on stdio +// and on a 2025-era session, the handler's subscription bus on the 2026-07-28 +// HTTP path. + +import { McpServer, CallToolResult } from "@modelcontextprotocol/server"; +import { z } from "zod"; +import type { ServerContext } from "../server/context.js"; + +// Tool input schema +const TriggerListChangedSchema = z.object({ + list: z + .enum(["tools", "prompts", "resources"]) + .describe("Which list to announce as changed"), +}); + +// Tool configuration +const name = "trigger-list-changed"; +const config = { + title: "Trigger List Changed Tool", + description: + "Sends a notifications//list_changed for the tool, prompt or resource list. On protocol version " + + "2026-07-28 it is delivered on each subscriptions/listen stream that asked for that list; a 2025-era " + + "client receives it unsolicited.", + inputSchema: TriggerListChangedSchema, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, +}; + +/** + * Registers the 'trigger-list-changed' tool. + * + * @param {McpServer} server - The McpServer instance where the tool will be registered. + * @param {ServerContext} context - Where the instance's change notifications go. + */ +export const registerTriggerListChangedTool = ( + server: McpServer, + context: ServerContext, +) => { + server.registerTool(name, config, async (args): Promise => { + const { list } = TriggerListChangedSchema.parse(args); + if (list === "tools") context.publish.toolsChanged(); + else if (list === "prompts") context.publish.promptsChanged(); + else context.publish.resourcesChanged(); + return { + content: [ + { + type: "text", + text: `Sent notifications/${list}/list_changed`, + }, + ], + }; + }); +}; diff --git a/src/everything/tools/trigger-log-messages.ts b/src/everything/tools/trigger-log-messages.ts new file mode 100644 index 0000000000..b626a22536 --- /dev/null +++ b/src/everything/tools/trigger-log-messages.ts @@ -0,0 +1,75 @@ +// The `trigger-log-messages` tool: sends one log message at each logging +// level during the call, so a client can see which ones its level lets +// through. +// +// The level is set differently on each protocol era (#4852). A 2026-07-28 +// client names it per request, in the request's `_meta` +// (`io.modelcontextprotocol/logLevel`), and a request that names none gets no +// log messages at all. A 2025-era client sets it for the whole session with +// `logging/setLevel`. The SDK applies whichever filter the request has, so +// the handler is the same for both. Logging is deprecated as of 2026-07-28 +// (SEP-2577); this replaces `toggle-simulated-logging`, whose timer relies on +// `logging/setLevel` and unsolicited messages, which 2026-07-28 removes. + +import { + McpServer, + CallToolResult, + LoggingLevel, +} from "@modelcontextprotocol/server"; + +/** Every logging level, from least to most severe. */ +export const LOGGING_LEVELS: LoggingLevel[] = [ + "debug", + "info", + "notice", + "warning", + "error", + "critical", + "alert", + "emergency", +]; + +// Tool configuration +const name = "trigger-log-messages"; +const config = { + title: "Trigger Log Messages Tool", + description: + "Sends one log message at each logging level during the call. On protocol version 2026-07-28 only " + + "the messages at or above the level the request names in _meta (io.modelcontextprotocol/logLevel) " + + "are sent, and none if it names no level; a 2025-era client gets those at or above the level it set " + + "with logging/setLevel. Deprecated: logging is deprecated as of protocol version 2026-07-28.", + inputSchema: {}, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, +}; + +/** + * Registers the 'trigger-log-messages' tool. + * + * @param {McpServer} server - The McpServer instance where the tool will be registered. + */ +export const registerTriggerLogMessagesTool = (server: McpServer) => { + server.registerTool( + name, + config, + async (_args, ctx): Promise => { + for (const level of LOGGING_LEVELS) { + await ctx.mcpReq.log(level, `${level}-level message`, "everything"); + } + return { + content: [ + { + type: "text", + text: + `Sent one log message at each of ${LOGGING_LEVELS.length} levels (${LOGGING_LEVELS.join(", ")}). ` + + "The ones below this request's log level were dropped.", + }, + ], + }; + }, + ); +}; diff --git a/src/everything/tools/trigger-multi-step-elicitation.ts b/src/everything/tools/trigger-multi-step-elicitation.ts new file mode 100644 index 0000000000..afb2b73b66 --- /dev/null +++ b/src/everything/tools/trigger-multi-step-elicitation.ts @@ -0,0 +1,147 @@ +// The `trigger-multi-step-elicitation` tool: asks the user two questions in +// sequence, each depending on the last, to show `requestState` carrying a +// multi-round-trip flow from one round to the next (#4852). +// +// Each retry of a multi-round-trip request carries only that round's answers, +// and on the 2026-07-28 HTTP path a later round may be served by a fresh +// server instance, so nothing on the server remembers earlier rounds. What +// the flow has learned travels in `requestState` instead: an opaque string +// the server returns with `input_required` and the client echoes back on the +// retry. It is attacker-controlled on the way back, so the server signs it +// and gives it an expiry (`roundStateCodec`); the SDK rejects a tampered or +// expired state with -32602 before the handler runs. The handler switches on +// the phase the state names rather than on which answers arrived. +// +// Written once for both protocol eras: for a 2025-era client the SDK's legacy +// shim sends each round as a real `elicitation/create` request and replays +// the state between rounds. + +import { + McpServer, + CallToolResult, + acceptedContent, + inputRequired, + inputResponse, + type InputRequiredResult, +} from "@modelcontextprotocol/server"; +import { z } from "zod"; +import { roundStateCodec, type RoundState } from "../server/index.js"; + +// Tool configuration +const name = "trigger-multi-step-elicitation"; +const config = { + title: "Trigger Multi-Step Elicitation Tool", + description: + "Asks the user for their name, then for a favorite color addressed to them by that name, carrying " + + "the first answer to the second round in requestState. Demonstrates multi-round-trip requests " + + "(input_required) and requestState on protocol version 2026-07-28; 2025-era clients get the same " + + "flow as elicitation requests.", + inputSchema: {}, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, +}; + +/** The first question's answer: a name that is not blank. */ +const NameSchema = z.object({ + name: z.string().trim().min(1).describe("Your name"), +}); + +/** The second question's answer. */ +const ColorSchema = z.object({ + color: z + .enum(["red", "green", "blue"]) + .describe("Your favorite of the three"), +}); + +/** Ask for the name: the first round, and a repeat of it after a bad answer. */ +const askForName = async (message: string): Promise => + inputRequired({ + inputRequests: { + name: inputRequired.elicit({ message, requestedSchema: NameSchema }), + }, + requestState: await roundStateCodec.mint({ step: "awaiting-name" }), + }); + +/** Ask for the color, carrying the name the first round proved. */ +const askForColor = async (name: string): Promise => + inputRequired({ + inputRequests: { + color: inputRequired.elicit({ + message: `Thanks, ${name}. Which color do you like best?`, + requestedSchema: ColorSchema, + }), + }, + requestState: await roundStateCodec.mint({ step: "awaiting-color", name }), + }); + +/** The result for a user who declined or cancelled a question. */ +const stopped = (action: string, step: string): CallToolResult => ({ + content: [ + { type: "text", text: `Stopped: the user chose ${action} at ${step}.` }, + ], + isError: true, +}); + +/** + * Registers the 'trigger-multi-step-elicitation' tool. + * + * The tool needs a client that supports form-mode elicitation, so it is gated + * like `trigger-elicitation-request`. Each entry reads the phase from the + * verified `requestState`: + * - none: the first call, which asks for the name; + * - `awaiting-name`: reads the name, asking again if it is missing or blank, + * then asks for the color with the name in the next state; + * - `awaiting-color`: reads the color, asking again if it is not one of the + * choices, then returns both answers. + * A declined or cancelled question ends the flow with an error result. + * + * @param {McpServer} server - The McpServer instance where the tool will be registered. + */ +export const registerTriggerMultiStepElicitationTool = (server: McpServer) => { + server.registerTool( + name, + config, + async (_args, ctx): Promise => { + const state = ctx.mcpReq.requestState(); + const responses = ctx.mcpReq.inputResponses; + + switch (state?.step) { + case "awaiting-name": { + const view = inputResponse(responses, "name"); + if (view.kind === "elicit" && view.action !== "accept") { + return stopped(view.action, "the name question"); + } + const answer = acceptedContent(responses, "name", NameSchema); + if (answer === undefined) { + return askForName("A name is required. What is your name?"); + } + return askForColor(answer.name); + } + case "awaiting-color": { + const view = inputResponse(responses, "color"); + if (view.kind === "elicit" && view.action !== "accept") { + return stopped(view.action, "the color question"); + } + const answer = acceptedContent(responses, "color", ColorSchema); + const name = String(state.name); + if (answer === undefined) return askForColor(name); + return { + content: [ + { + type: "text", + text: `${name}'s favorite color is ${answer.color}.`, + }, + ], + structuredContent: { name, color: answer.color }, + }; + } + default: + return askForName("What is your name?"); + } + }, + ); +}; diff --git a/src/everything/tools/trigger-resource-update.ts b/src/everything/tools/trigger-resource-update.ts new file mode 100644 index 0000000000..5f3ca68abb --- /dev/null +++ b/src/everything/tools/trigger-resource-update.ts @@ -0,0 +1,64 @@ +// The `trigger-resource-update` tool: announces that a resource changed, so a +// client can watch `notifications/resources/updated` arrive for a resource it +// asked to be told about. +// +// How the client asks depends on the protocol era (#4852). A 2026-07-28 +// client names the URI in the `resourceSubscriptions` of its +// `subscriptions/listen` filter, and the notification is delivered only on +// such a stream. A 2025-era client calls `resources/subscribe`, and the +// notification is sent only once it has. This replaces the timed updates of +// `toggle-subscriber-updates` (2025-era only) with one sent on demand. + +import { McpServer, CallToolResult } from "@modelcontextprotocol/server"; +import { z } from "zod"; +import type { ServerContext } from "../server/context.js"; + +// Tool input schema +const TriggerResourceUpdateSchema = z.object({ + uri: z + .string() + .describe( + "The URI of the resource to announce as updated, e.g. demo://resource/dynamic/text/1", + ), +}); + +// Tool configuration +const name = "trigger-resource-update"; +const config = { + title: "Trigger Resource Update Tool", + description: + "Sends a notifications/resources/updated for a resource URI. On protocol version 2026-07-28 it is " + + "delivered on each subscriptions/listen stream whose resourceSubscriptions name the URI; a 2025-era " + + "client receives it once it has called resources/subscribe for the URI.", + inputSchema: TriggerResourceUpdateSchema, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, +}; + +/** + * Registers the 'trigger-resource-update' tool. + * + * @param {McpServer} server - The McpServer instance where the tool will be registered. + * @param {ServerContext} context - Where the instance's change notifications go. + */ +export const registerTriggerResourceUpdateTool = ( + server: McpServer, + context: ServerContext, +) => { + server.registerTool(name, config, async (args): Promise => { + const { uri } = TriggerResourceUpdateSchema.parse(args); + context.publish.resourceUpdated(uri); + return { + content: [ + { + type: "text", + text: `Sent notifications/resources/updated for ${uri} to each subscriber`, + }, + ], + }; + }); +}; diff --git a/src/everything/tools/trigger-sampling-request-async.ts b/src/everything/tools/trigger-sampling-request-async.ts new file mode 100644 index 0000000000..349c0faba7 --- /dev/null +++ b/src/everything/tools/trigger-sampling-request-async.ts @@ -0,0 +1,125 @@ +// The `trigger-sampling-request-async` tool: a task on the Tasks extension +// (`io.modelcontextprotocol/tasks`) that needs an LLM completion from the +// client before it can finish, on protocol version 2026-07-28 (#4852). +// +// On 2025-11-25 this tool sent the client a task-augmented +// `sampling/createMessage` request and polled the client's own task. The +// 2026-07-28 revision has no server-to-client requests, so the roles turn +// around: the call returns a handle to a server task whose status is +// `input_required`, carrying the sampling request. The client runs it and +// answers with `tasks/update`, and the task completes with the result. +// Sampling is deprecated as of 2026-07-28 (SEP-2577). + +import { + McpServer, + CallToolResult, + inputRequired, + inputResponse, + type InputResponseView, +} from "@modelcontextprotocol/server"; +import { z } from "zod"; +import { + clientSupportsTasks, + createTask, + tasksRequired, + type TaskState, +} from "../server/tasks.js"; + +// Tool input schema +const TriggerSamplingRequestAsyncSchema = z.object({ + prompt: z.string().describe("The prompt to send to the LLM"), + maxTokens: z + .number() + .default(100) + .describe("Maximum number of tokens to generate"), +}); + +/** The client's answer to a sampling request, as `inputResponse` reads it. */ +type SamplingResult = Extract< + InputResponseView, + { kind: "sampling" } +>["result"]; + +// Tool configuration +const name = "trigger-sampling-request-async"; +const config = { + title: "Trigger Async Sampling Request Tool", + description: + "Runs as a task on the io.modelcontextprotocol/tasks extension (protocol version 2026-07-28) that needs an " + + "LLM completion: the task waits in status input_required, carrying a sampling request, until the client " + + "answers it with tasks/update, then completes with the result. Deprecated: Sampling is deprecated as of " + + "protocol version 2026-07-28.", + inputSchema: TriggerSamplingRequestAsyncSchema, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, +}; + +/** + * Registers the 'trigger-sampling-request-async' tool, for 2026-07-28 clients. + * + * A client that does not declare the tasks extension gets an error result. + * The SDK checks the sampling capability when the client fetches the + * request, not here: the request travels inside the task's status. + * + * @param {McpServer} server - The McpServer instance where the tool will be registered. + */ +export const registerTriggerSamplingRequestAsyncTool = (server: McpServer) => { + server.registerTool( + name, + config, + async (args, ctx): Promise => { + if (!clientSupportsTasks(ctx)) return tasksRequired(name); + const { prompt, maxTokens } = + TriggerSamplingRequestAsyncSchema.parse(args); + let result: SamplingResult | undefined; + + return createTask( + { + stateAt: (): TaskState => + result === undefined + ? { + status: "input_required", + statusMessage: "Waiting for the client's LLM to respond", + inputRequests: { + sampling: inputRequired.createMessage({ + messages: [ + { + role: "user", + content: { + type: "text", + text: `Resource ${name} context: ${prompt}`, + }, + }, + ], + systemPrompt: "You are a helpful test server.", + maxTokens, + temperature: 0.7, + }), + }, + } + : { + status: "completed", + statusMessage: "Sampling complete", + result: { + content: [ + { + type: "text", + text: `LLM sampling result: \n${JSON.stringify(result, null, 2)}`, + }, + ], + }, + }, + receive: (responses) => { + const answer = inputResponse(responses, "sampling"); + if (answer.kind === "sampling") result = answer.result; + }, + }, + { ttlMs: 10 * 60_000, pollIntervalMs: 1000 }, + ); + }, + ); +}; diff --git a/src/everything/tools/trigger-sampling-request.ts b/src/everything/tools/trigger-sampling-request.ts index 28ed5fb4bd..688a1509ef 100644 --- a/src/everything/tools/trigger-sampling-request.ts +++ b/src/everything/tools/trigger-sampling-request.ts @@ -1,7 +1,20 @@ +// The `trigger-sampling-request` tool: asks the client's LLM for a completion +// and returns what it sent back. +// +// Written once for both protocol eras with a multi-round-trip request +// (#4852): the first call returns `input_required` carrying a +// `sampling/createMessage` request, the client runs it and retries, and the +// retry returns the result. A 2026-07-28 client does that in-band; for a +// 2025-era client the SDK's legacy shim sends a real `sampling/createMessage` +// request and re-enters the handler with the answer. Sampling is deprecated +// as of 2026-07-28 (SEP-2577). + import { McpServer, CallToolResult, - CreateMessageRequest, + inputRequired, + inputResponse, + type InputRequiredResult, } from "@modelcontextprotocol/server"; import { z } from "zod"; @@ -18,7 +31,9 @@ const TriggerSamplingRequestSchema = z.object({ const name = "trigger-sampling-request"; const config = { title: "Trigger Sampling Request Tool", - description: "Trigger a Request from the Server for LLM Sampling", + description: + "Trigger a Request from the Server for LLM Sampling. Deprecated: Sampling is deprecated as of protocol " + + "version 2026-07-28. The server asks with a multi-round-trip request on every protocol version.", inputSchema: TriggerSamplingRequestSchema, annotations: { readOnlyHint: false, @@ -31,63 +46,55 @@ const config = { /** * Registers the 'trigger-sampling-request' tool. * - * If the client does not support the sampling capability, the tool is not registered. + * The tool needs a client that supports sampling: on a 2025-era connection + * the server factory registers it only for one that declared the capability, + * and on 2026-07-28 the SDK refuses the embedded request (-32021) of a client + * whose request did not declare it. * - * The registered tool performs the following operations: - * - Validates incoming arguments using `TriggerSamplingRequestSchema`. - * - Constructs a `sampling/createMessage` request object using provided prompt and maximum tokens. - * - Sends the request to the server for sampling. - * - Formats and returns the sampling result content to the client. + * The first round returns the `sampling/createMessage` request built from the + * prompt and token limit; the retry formats the client's result. * * @param {McpServer} server - The McpServer instance where the tool will be registered. */ export const registerTriggerSamplingRequestTool = (server: McpServer) => { - // Does the client support sampling? - const clientCapabilities = server.server.getClientCapabilities() || {}; - const clientSupportsSampling: boolean = - clientCapabilities.sampling !== undefined; - - // If so, register tool - if (clientSupportsSampling) { - server.registerTool( - name, - config, - async (args, ctx): Promise => { - const validatedArgs = TriggerSamplingRequestSchema.parse(args); - const { prompt, maxTokens } = validatedArgs; + server.registerTool( + name, + config, + async (args, ctx): Promise => { + const { prompt, maxTokens } = TriggerSamplingRequestSchema.parse(args); + const answer = inputResponse(ctx.mcpReq.inputResponses, "sampling"); - // Create the sampling request - const request: CreateMessageRequest = { - method: "sampling/createMessage", - params: { - messages: [ - { - role: "user", - content: { - type: "text", - text: `Resource ${name} context: ${prompt}`, + // First round: ask the client to sample + if (answer.kind !== "sampling") { + return inputRequired({ + inputRequests: { + sampling: inputRequired.createMessage({ + messages: [ + { + role: "user", + content: { + type: "text", + text: `Resource ${name} context: ${prompt}`, + }, }, - }, - ], - systemPrompt: "You are a helpful test server.", - maxTokens, - temperature: 0.7, + ], + systemPrompt: "You are a helpful test server.", + maxTokens, + temperature: 0.7, + }), }, - }; + }); + } - // Send the sampling request to the client - const result = await ctx.mcpReq.send(request); - - // Return the result to the client - return { - content: [ - { - type: "text", - text: `LLM sampling result: \n${JSON.stringify(result, null, 2)}`, - }, - ], - }; - }, - ); - } + // Return the result to the client + return { + content: [ + { + type: "text", + text: `LLM sampling result: \n${JSON.stringify(answer.result, null, 2)}`, + }, + ], + }; + }, + ); }; diff --git a/src/everything/tools/trigger-url-elicitation.ts b/src/everything/tools/trigger-url-elicitation.ts index 9faa1e557d..ffe08a05b6 100644 --- a/src/everything/tools/trigger-url-elicitation.ts +++ b/src/everything/tools/trigger-url-elicitation.ts @@ -1,11 +1,29 @@ +// The `trigger-url-elicitation` tool: directs the user to a URL in the +// browser (URL-mode elicitation) and reports how the flow ended. +// +// The two protocol eras do this differently, so the tool branches on the era +// its server instance serves (#4852): +// - 2025-era: the server sends an `elicitation/create` request carrying an +// `elicitationId` (the request path), or throws a +// `UrlElicitationRequiredError` (-32042) that the client handles before it +// retries (the error path). +// - 2026-07-28: there is no server-to-client request and no -32042 error. The +// tool returns `input_required` carrying the URL elicitation, and the client +// retries with the user's answer. The in-band request has no +// `elicitationId`, and `errorPath` changes nothing. + import { randomUUID } from "node:crypto"; import { McpServer, CallToolResult, ElicitRequestURLParams, + inputRequired, + inputResponse, UrlElicitationRequiredError, + type InputRequiredResult, } from "@modelcontextprotocol/server"; import { z } from "zod"; +import type { ServerContext } from "../server/context.js"; // Tool input schema const TriggerUrlElicitationSchema = z.object({ @@ -17,7 +35,10 @@ const TriggerUrlElicitationSchema = z.object({ elicitationId: z .string() .optional() - .describe("Optional explicit elicitation ID. Defaults to a random UUID."), + .describe( + "Optional explicit elicitation ID. Defaults to a random UUID. 2025-era clients only: the " + + "2026-07-28 in-band request carries no elicitation ID.", + ), errorPath: z .boolean() .default(false) @@ -27,7 +48,9 @@ const TriggerUrlElicitationSchema = z.object({ "When true, throws a UrlElicitationRequiredError (MCP error code -32042) so the client handles " + "the URL elicitation via the error path rather than waiting for a response. " + "To clear the error, satisfy the prerequisite and retry this call with the same arguments; the " + - "retry ignores errorPath and proceeds, so the client does not loop on the same error.", + "retry ignores errorPath and proceeds, so the client does not loop on the same error. " + + "2025-era clients only: protocol version 2026-07-28 has no -32042 error, so there both " + + "settings use the in-band input_required flow.", ), }); @@ -39,7 +62,8 @@ const config = { "Trigger a URL elicitation so the client can direct the user to a browser flow. " + "Supports two mechanisms: the request path (elicitation/create, default) which awaits the user's " + "response, and the error path (UrlElicitationRequiredError, -32042) which signals the client " + - "to handle URL elicitation via the error response. Set errorPath=true to use the error path.", + "to handle URL elicitation via the error response. Set errorPath=true to use the error path. " + + "On protocol version 2026-07-28 the server instead returns the URL elicitation in-band (input_required).", inputSchema: TriggerUrlElicitationSchema, annotations: { readOnlyHint: false, @@ -49,39 +73,17 @@ const config = { }, }; -/** - * Tracks requests for which an error-path prerequisite has already been issued, - * keyed by the stable inputs a client resends when it retries the original tool - * call (session + URL + caller-supplied elicitationId). - * - * When the client satisfies the prerequisite and retries the same call, the - * matching entry lets us recognize the retry, ignore `errorPath`, and proceed - * via the request path instead of re-throwing `UrlElicitationRequiredError` — - * which would otherwise loop forever (throw -> client satisfies prerequisite -> - * retry -> throw -> ...). - * - * Demo simplification: entries are only removed on a recognized retry, so a - * client that triggers the error path and never retries leaves its key behind. - * That is acceptable for this reference server; a production implementation - * serving many long-lived sessions should evict entries (e.g. a - * `Map` with TTL-based cleanup). - */ -const issuedErrorPathElicitations = new Set(); - -/** - * Test-only helper to reset the module-level error-path state between cases. - * Not part of the tool's public behavior. - */ -export const __resetIssuedErrorPathElicitations = () => - issuedErrorPathElicitations.clear(); - /** * Registers the 'trigger-url-elicitation' tool. * - * This tool only registers when the client advertises URL-mode elicitation - * capability (clientCapabilities.elicitation.url). + * The tool needs a client that supports URL-mode elicitation: on a 2025-era + * connection the server factory registers it only for a client that declared + * `elicitation.url`, and on 2026-07-28 the SDK refuses the embedded request + * (-32021) of a client whose request did not declare it. * - * Depending on the `errorPath` argument it either: + * On 2026-07-28 it returns the URL elicitation in-band and reports the answer + * on the retry. On a 2025-era connection, depending on the `errorPath` + * argument, it either: * - Sends an `elicitation/create` request and awaits the result (request path), or * - Throws a `UrlElicitationRequiredError` (MCP error -32042) carrying a * prerequisite elicitation for the client to handle (error path). When the @@ -90,124 +92,168 @@ export const __resetIssuedErrorPathElicitations = () => * not loop on the same error. * * @param {McpServer} server - The McpServer instance where the tool will be registered. + * @param {ServerContext} context - The protocol era the instance serves. */ -export const registerTriggerUrlElicitationTool = (server: McpServer) => { - const clientCapabilities = server.server.getClientCapabilities() || {}; - const clientElicitationCapabilities = clientCapabilities.elicitation as - | { - url?: object; - } - | undefined; - - const clientSupportsUrlElicitation = - clientElicitationCapabilities?.url !== undefined; - - if (clientSupportsUrlElicitation) { +export const registerTriggerUrlElicitationTool = ( + server: McpServer, + context: ServerContext, +) => { + if (context.era === "modern") { server.registerTool( name, config, - async (args, ctx): Promise => { - const { - url, - message, - elicitationId: requestedElicitationId, - errorPath, - } = args; - - const elicitationId = requestedElicitationId ?? randomUUID(); - const sessionId = ctx.sessionId ?? "default"; - - // Key the one-shot error-path marker on inputs the client resends - // verbatim when it retries the original tool call. A real client retries - // with the *same* arguments and does NOT echo the prerequisite's - // (server-generated) elicitationId, so we must key on stable inputs: - // the session, the requested URL, and the caller-supplied elicitationId - // (if any). Keying on the resolved/random elicitationId would change on - // every call and never match, re-throwing the prerequisite forever. - const errorPathKey = `${sessionId}\u0000${url}\u0000${requestedElicitationId ?? ""}`; - - const elicitationParams: ElicitRequestURLParams = { - mode: "url", - url, - message, - elicitationId, - }; + async (args, ctx): Promise => { + const { url, message } = args; + const answer = inputResponse(ctx.mcpReq.inputResponses, "url"); - // Error path: signal the client via UrlElicitationRequiredError (-32042) - // so it handles a prerequisite URL elicitation before this request can - // proceed. Two things keep the client from looping forever: - // - // 1. The prerequisite points at a *different* URL than the one that - // failed. Reusing the original `url` would make the client complete - // the prerequisite, retry, and hit the same -32042 error endlessly. - // 2. We remember that we issued a prerequisite for this request. When - // the client satisfies it and retries the same call, we recognize - // the retry, *ignore* errorPath, and fall through to the request - // path. Without this, the retry would re-enter the error path and - // re-request the prerequisite URL — another loop. - if (errorPath) { - if (issuedErrorPathElicitations.has(errorPathKey)) { - // Retry of a satisfied prerequisite: clear the one-shot marker and - // ignore errorPath, falling through to the request path below. - issuedErrorPathElicitations.delete(errorPathKey); - } else { - // Originating call: record that we issued a prerequisite for this - // request, then signal the client via -32042. - issuedErrorPathElicitations.add(errorPathKey); - const prerequisiteElicitation: ElicitRequestURLParams = { - mode: "url", - url: "https://modelcontextprotocol.io", - message: - "Open this link to satisfy the prerequisite, then retry the request.", - elicitationId: randomUUID(), - }; - throw new UrlElicitationRequiredError( - [prerequisiteElicitation], - "This request requires browser-based authorization.", - ); - } + // First round: direct the user to the URL + if (answer.kind !== "elicit") { + return inputRequired({ + inputRequests: { url: inputRequired.elicitUrl({ message, url }) }, + }); } - // Request path: send elicitation/create and await the user's response - const elicitationResult = await ctx.mcpReq.send( - { - method: "elicitation/create", - params: elicitationParams, - }, - { timeout: 10 * 60 * 1000 /* 10 minutes */ }, - ); - - // Handle different response actions - const content: CallToolResult["content"] = []; - - if (elicitationResult.action === "accept") { - content.push({ - type: "text", - text: - `✅ User completed the URL elicitation flow.\n` + - `Elicitation ID: ${elicitationId}\n` + - `URL: ${url}`, - }); - } else if (elicitationResult.action === "decline") { - content.push({ - type: "text", - text: `❌ User declined to open the URL (Elicitation ID: ${elicitationId}).`, - }); - } else if (elicitationResult.action === "cancel") { - content.push({ - type: "text", - text: `⚠️ User cancelled the URL elicitation (Elicitation ID: ${elicitationId}).`, - }); + const text = + answer.action === "accept" + ? `✅ User completed the URL elicitation flow.\nURL: ${url}` + : answer.action === "decline" + ? "❌ User declined to open the URL." + : "⚠️ User cancelled the URL elicitation."; + return { + content: [ + { type: "text", text }, + { + type: "text", + text: `\nRaw result: ${JSON.stringify({ action: answer.action }, null, 2)}`, + }, + ], + }; + }, + ); + return; + } + + /** + * Requests for which an error-path prerequisite has already been issued on + * this connection, keyed by the stable inputs a client resends when it + * retries the original tool call (URL + caller-supplied elicitationId). + * + * When the client satisfies the prerequisite and retries the same call, the + * matching entry lets us recognize the retry, ignore `errorPath`, and + * proceed via the request path instead of re-throwing + * `UrlElicitationRequiredError` — which would otherwise loop forever (throw + * -> client satisfies prerequisite -> retry -> throw -> ...). + * + * The set belongs to this server instance, which serves one connection or + * session, so it goes away with it (#4852). Entries are removed on a + * recognized retry; a client that never retries leaves its key until it + * disconnects. + */ + const issuedErrorPathElicitations = new Set(); + + server.registerTool( + name, + config, + async (args, ctx): Promise => { + const { + url, + message, + elicitationId: requestedElicitationId, + errorPath, + } = args; + + const elicitationId = requestedElicitationId ?? randomUUID(); + + // Key the one-shot error-path marker on inputs the client resends + // verbatim when it retries the original tool call. A real client retries + // with the *same* arguments and does NOT echo the prerequisite's + // (server-generated) elicitationId, so we must key on stable inputs: + // the requested URL and the caller-supplied elicitationId (if any). + // Keying on the resolved/random elicitationId would change on every + // call and never match, re-throwing the prerequisite forever. + const errorPathKey = `${url}\u0000${requestedElicitationId ?? ""}`; + + const elicitationParams: ElicitRequestURLParams = { + mode: "url", + url, + message, + elicitationId, + }; + + // Error path: signal the client via UrlElicitationRequiredError (-32042) + // so it handles a prerequisite URL elicitation before this request can + // proceed. Two things keep the client from looping forever: + // + // 1. The prerequisite points at a *different* URL than the one that + // failed. Reusing the original `url` would make the client complete + // the prerequisite, retry, and hit the same -32042 error endlessly. + // 2. We remember that we issued a prerequisite for this request. When + // the client satisfies it and retries the same call, we recognize + // the retry, *ignore* errorPath, and fall through to the request + // path. Without this, the retry would re-enter the error path and + // re-request the prerequisite URL — another loop. + if (errorPath) { + if (issuedErrorPathElicitations.has(errorPathKey)) { + // Retry of a satisfied prerequisite: clear the one-shot marker and + // ignore errorPath, falling through to the request path below. + issuedErrorPathElicitations.delete(errorPathKey); + } else { + // Originating call: record that we issued a prerequisite for this + // request, then signal the client via -32042. + issuedErrorPathElicitations.add(errorPathKey); + const prerequisiteElicitation: ElicitRequestURLParams = { + mode: "url", + url: "https://modelcontextprotocol.io", + message: + "Open this link to satisfy the prerequisite, then retry the request.", + elicitationId: randomUUID(), + }; + throw new UrlElicitationRequiredError( + [prerequisiteElicitation], + "This request requires browser-based authorization.", + ); } + } + + // Request path: send elicitation/create and await the user's response + const elicitationResult = await ctx.mcpReq.send( + { + method: "elicitation/create", + params: elicitationParams, + }, + { timeout: 10 * 60 * 1000 /* 10 minutes */ }, + ); - // Include raw result for debugging + // Handle different response actions + const content: CallToolResult["content"] = []; + + if (elicitationResult.action === "accept") { content.push({ type: "text", - text: `\nRaw result: ${JSON.stringify(elicitationResult, null, 2)}`, + text: + `✅ User completed the URL elicitation flow.\n` + + `Elicitation ID: ${elicitationId}\n` + + `URL: ${url}`, }); + } else if (elicitationResult.action === "decline") { + content.push({ + type: "text", + text: `❌ User declined to open the URL (Elicitation ID: ${elicitationId}).`, + }); + } else if (elicitationResult.action === "cancel") { + content.push({ + type: "text", + text: `⚠️ User cancelled the URL elicitation (Elicitation ID: ${elicitationId}).`, + }); + } - return { content }; - }, - ); - } + // Include raw result for debugging + content.push({ + type: "text", + text: `\nRaw result: ${JSON.stringify(elicitationResult, null, 2)}`, + }); + + return { content }; + }, + ); }; diff --git a/src/everything/transports/sse.ts b/src/everything/transports/sse.ts index a6a88761cb..323748d53c 100644 --- a/src/everything/transports/sse.ts +++ b/src/everything/transports/sse.ts @@ -1,6 +1,13 @@ // The HTTP+SSE transport manager (deprecated transport; kept working): one // server per `GET /sse` stream, with client messages posted to `/message`. // +// HTTP+SSE is a 2025-era transport, deprecated since 2025-03-26 and absent +// from 2026-07-28. The v2 SDK serves it only through the frozen copy in +// `@modelcontextprotocol/server-legacy/sse` (itself deprecated). It is kept so +// existing SSE clients keep working, serves the 2025 era only, and is never +// the default: Streamable HTTP serves both eras (#4852). The launcher warns +// on start. +// // `createApp()` builds the Express app without binding a port (#4854), so a // test can serve it on a free port and drive it with a real client, and each // app has its own session map. `startSseServer()` is what the launcher runs. @@ -81,7 +88,7 @@ export function createApp(): Express { const sessionId = transport.sessionId; console.error("Client Disconnected: ", sessionId); transports.delete(sessionId); - cleanup(sessionId); + cleanup(); }; }); @@ -114,6 +121,10 @@ export function createApp(): Express { */ export function startSseServer(): Server { console.error("Starting SSE server..."); + console.error( + "Warning: the HTTP+SSE transport is deprecated and serves 2025-era clients only. " + + "Use streamableHttp, which serves both 2025-era and 2026-07-28 clients.", + ); const app = createApp(); // Start the express server diff --git a/src/everything/transports/stdio.ts b/src/everything/transports/stdio.ts index 312a89c110..dbc320e3c7 100644 --- a/src/everything/transports/stdio.ts +++ b/src/everything/transports/stdio.ts @@ -1,7 +1,15 @@ #!/usr/bin/env node -// The stdio transport manager: one server, connected to the process's own -// stdin/stdout, closed on SIGINT. +// The stdio transport manager: the server factory, served on the process's +// own stdin/stdout through the SDK's `serveStdio` entry, closed on SIGINT. +// +// `serveStdio` lets the client's first message pick the connection's +// protocol era (#4852): a 2026-07-28 client opens with `server/discover` (or +// any request carrying the per-request `_meta` envelope) and a 2025-era +// client with `initialize`. It then builds one server for that era from the +// factory and pins it to the connection. It may build and discard one more +// first, to answer a discover probe, so each instance's cleanup runs when that +// instance closes. // // It exports its start-up instead of running it at import (#4854), so a test // can drive the same code in-process over a pair of streams and call the @@ -9,43 +17,61 @@ // launches the binary. import type { Readable, Writable } from "node:stream"; -import { StdioServerTransport } from "@modelcontextprotocol/server/stdio"; +import { + serveStdio, + StdioServerTransport, + type StdioServerHandle, +} from "@modelcontextprotocol/server/stdio"; import { createServer } from "../server/index.js"; -/** What a started stdio server hands back: the server and its SIGINT handler. */ +/** What a started stdio server hands back: the connection and its SIGINT handler. */ export type StdioSession = { - server: ReturnType["server"]; + handle: StdioServerHandle; shutdown: () => Promise; }; /** * The main method - * - Initializes the StdioServerTransport, sets up the server, + * - Serves the server factory over a StdioServerTransport, * - Handles cleanup on process exit. * * @param stdin - The stream the server reads messages from (the process's stdin by default). * @param stdout - The stream the server writes messages to (the process's stdout by default). - * @return {Promise} The connected server and the handler registered for SIGINT. + * @return {Promise} The served connection and the handler registered for SIGINT. */ export async function main( stdin: Readable = process.stdin, stdout: Writable = process.stdout, ): Promise { - const transport = new StdioServerTransport(stdin, stdout); - const { server, cleanup } = createServer(); + // The cleanups of the instances still open + const cleanups = new Set<() => void>(); - // Connect transport to server - await server.connect(transport); + const handle = serveStdio( + ({ era }) => { + const { server, cleanup } = createServer({ era }); + cleanups.add(cleanup); + server.server.onclose = () => { + cleanups.delete(cleanup); + cleanup(); + }; + return server; + }, + { + transport: new StdioServerTransport(stdin, stdout), + onerror: (error) => console.error("Transport error:", error), + }, + ); // Cleanup on exit const shutdown = async () => { - await server.close(); - cleanup(); + await handle.close(); + for (const cleanup of cleanups) cleanup(); + cleanups.clear(); process.exit(0); }; process.on("SIGINT", shutdown); - return { server, shutdown }; + return { handle, shutdown }; } /** diff --git a/src/everything/transports/streamableHttp.ts b/src/everything/transports/streamableHttp.ts index 9b475b3c20..6f060d8b14 100644 --- a/src/everything/transports/streamableHttp.ts +++ b/src/everything/transports/streamableHttp.ts @@ -1,15 +1,42 @@ -// The Streamable HTTP transport manager: one server per session, created on -// the initialize POST to `/mcp`, with GET for the standalone SSE stream and -// DELETE to end the session. +// The Streamable HTTP transport manager: one `/mcp` endpoint serving both +// protocol eras (#4852). +// +// - 2026-07-28 traffic (a request carrying the per-request `_meta` envelope) +// goes to the SDK's `createMcpHandler`, which builds a fresh server per +// request: no sessions, `server/discover` instead of `initialize`, change +// notifications on `subscriptions/listen` streams published through the +// handler's bus, and input requests in-band (`input_required`). +// - 2025-era traffic keeps the sessionful wiring it had: one server per +// session, created on the initialize POST, with GET for the standalone SSE +// stream, DELETE to end the session, and an event store for resumability. +// The SDK's default 2025-era serving is stateless, which would leave no +// channel for server-to-client requests, so elicitation, sampling and roots +// would stop working for 2025-era HTTP clients; routing them here keeps them +// working, the pattern the SDK documents for an existing sessionful +// deployment. +// +// A request with an `Mcp-Session-Id`, a GET and a DELETE are 2025-era by +// definition, since 2026-07-28 has none of them. A session-less POST is +// classified by the SDK's own `isLegacyRequest`, from a body parsed once and +// handed to whichever side serves it. // // `createApp()` builds the Express app without binding a port or installing a // signal handler (#4854), so a test can serve it on a free port, drive it with // a real client, and call its shutdown handler directly; each app has its own -// session map. `startStreamableHttpServer()` is what the launcher runs. +// session map and its own 2026-07-28 handler. `startStreamableHttpServer()` is +// what the launcher runs. -import { isJSONRPCRequest } from "@modelcontextprotocol/server"; -import { NodeStreamableHTTPServerTransport } from "@modelcontextprotocol/node"; -import express, { Express, Request, Response } from "express"; +import { + createMcpHandler, + isJSONRPCRequest, + isLegacyRequest, +} from "@modelcontextprotocol/server"; +import { + NodeStreamableHTTPServerTransport, + toNodeHandler, + toWebRequest, +} from "@modelcontextprotocol/node"; +import express, { Express, NextFunction, Request, Response } from "express"; import type { Server } from "node:http"; import { createServer } from "../server/index.js"; import { randomUUID } from "node:crypto"; @@ -55,6 +82,43 @@ function sessionNotFound(res: Response, id: string | number | null = null) { }); } +/** The most bytes of a session-less POST body parsed to classify it (4 MiB, the SDK's own bound). */ +const MAX_BODY_BYTES = 4 * 1024 * 1024; + +/** Parses a session-less POST's JSON body, to classify it and to serve it. */ +const parseJson = express.json({ limit: MAX_BODY_BYTES, type: () => true }); + +/** + * Answer a session-less POST whose body could not be parsed the way the SDK + * answers one: a JSON-RPC error with a null id, as 400 (not JSON) or 413 (too + * large). Anything else goes on to Express's error handling. + */ +function bodyError( + error: { type?: string }, + _req: Request, + res: Response, + next: NextFunction, +): void { + if (error.type === "entity.parse.failed") { + res.status(400).json({ + jsonrpc: "2.0", + error: { + code: -32700, + message: "Parse error: the request body is not valid JSON", + }, + id: null, + }); + } else if (error.type === "entity.too.large") { + res.status(413).json({ + jsonrpc: "2.0", + error: { code: -32600, message: "Request body too large" }, + id: null, + }); + } else { + next(error); + } +} + /** The Streamable HTTP app, and the handler the launcher installs for SIGINT. */ export type StreamableHttpApp = { app: Express; @@ -64,7 +128,7 @@ export type StreamableHttpApp = { /** * Build the Streamable HTTP transport's Express app: CORS, the `/mcp` POST, * GET and DELETE routes, the map from session id to transport that they - * share, and the shutdown handler that walks that map. + * share, the 2026-07-28 handler, and the shutdown handler that closes them. */ export function createApp(): StreamableHttpApp { // Express app with permissive CORS for testing with Inspector direct connect mode @@ -83,12 +147,44 @@ export function createApp(): StreamableHttpApp { }), ); + // The 2026-07-28 side: a fresh server per request, built for the modern + // era. Its change notifications go out on the handler's subscription bus, + // which reaches every open subscriptions/listen stream that asked for them. + // It refuses 2025-era traffic, which the routes below never send it. + const modern = createMcpHandler( + () => + createServer({ + era: "modern", + publish: { + toolsChanged: () => modern.notify.toolsChanged(), + promptsChanged: () => modern.notify.promptsChanged(), + resourcesChanged: () => modern.notify.resourcesChanged(), + resourceUpdated: (uri) => modern.notify.resourceUpdated(uri), + }, + }).server, + { + legacy: "reject", + onerror: (error) => console.log("Error serving MCP request:", error), + }, + ); + const serveModern = toNodeHandler(modern); + // Map sessionId to server transport for each client const transports: Map = new Map< string, NodeStreamableHTTPServerTransport >(); + // A session-less POST's body is parsed once, to classify it and serve it. + // A POST with a session id is a 2025-era session's, and its transport reads + // the body itself. + app.post( + "/mcp", + (req: Request, res: Response, next: NextFunction) => + req.headers["mcp-session-id"] ? next() : parseJson(req, res, next), + bodyError, + ); + // Handle POST requests for client messages app.post("/mcp", async (req: Request, res: Response) => { console.log("Received MCP POST request"); @@ -96,6 +192,16 @@ export function createApp(): StreamableHttpApp { // Check for existing session ID const sessionId = req.headers["mcp-session-id"] as string | undefined; + // A session-less POST that carries the 2026-07-28 envelope is served + // by the modern handler; everything else stays on the 2025-era path. + if ( + !sessionId && + !(await isLegacyRequest(await toWebRequest(req, req.body))) + ) { + await serveModern(req, res, req.body); + return; + } + let transport: NodeStreamableHTTPServerTransport; if (sessionId && transports.has(sessionId)) { @@ -125,14 +231,14 @@ export function createApp(): StreamableHttpApp { `Transport closed for session ${sid}, removing from transports map`, ); transports.delete(sid); - cleanup(sid); + cleanup(); } }; // Connect the transport to the MCP server BEFORE handling the request // so responses can flow back through the same transport await server.connect(transport); - await transport.handleRequest(req, res); + await transport.handleRequest(req, res, req.body); return; } else { // A session ID this app does not know, or has already ended @@ -237,6 +343,9 @@ export function createApp(): StreamableHttpApp { const shutdown = async () => { console.log("Shutting down server..."); + // End the 2026-07-28 requests and subscription streams in flight + await modern.close(); + // Close all active transports to properly clean up resources // (a snapshot, since closing a transport removes it from the map) for (const [sessionId, transport] of [...transports]) { From 9ef661c8695cc052823ada960b8d7366a452bdaa Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 11 Oct 2026 22:46:19 -0400 Subject: [PATCH 073/117] test(everything): cover both protocol eras and the 2026-07-28 features - modern.test.ts: server/discover, the extensions capability, cache hints, resultType, MRTR tools in-band, requestState across rounds (with retries after bad input, -32021 and a tampered state's -32602), per-request log levels, subscriptions/listen notifications, minted resources. - tasks.test.ts: rewritten for the tasks extension (tasks/get, tasks/update, tasks/cancel) in place of the skipped 2025-11-25 tests. - streamable-http.test.ts: 2026-07-28 clients on the same endpoint, Mcp-Param-* header mirroring and -32020, the handler bus, body errors. - Existing suites updated for per-instance state (no session ids in output), the legacy shim's wire shape, and -32602 resource-not-found. Part of #4852 Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/everything/__tests__/harness.ts | 82 ++ src/everything/__tests__/logging.test.ts | 54 +- src/everything/__tests__/modern.test.ts | 617 +++++++++++ src/everything/__tests__/resources.test.ts | 29 +- src/everything/__tests__/roots.test.ts | 24 +- .../__tests__/sampling-elicitation.test.ts | 12 +- src/everything/__tests__/server.test.ts | 32 +- src/everything/__tests__/sse.test.ts | 24 +- src/everything/__tests__/stdio.test.ts | 45 +- .../__tests__/streamable-http.test.ts | 174 +++ src/everything/__tests__/surface.test.ts | 191 +++- src/everything/__tests__/tasks.test.ts | 989 +++++++----------- src/everything/resources/subscriptions.ts | 22 +- src/everything/server/logging.ts | 7 +- src/everything/server/roots.ts | 13 +- src/everything/server/tasks.ts | 27 +- .../tools/simulate-research-query.ts | 4 +- src/everything/transports/stdio.ts | 13 +- 18 files changed, 1542 insertions(+), 817 deletions(-) create mode 100644 src/everything/__tests__/modern.test.ts diff --git a/src/everything/__tests__/harness.ts b/src/everything/__tests__/harness.ts index d2d542568c..839ebf0ba2 100644 --- a/src/everything/__tests__/harness.ts +++ b/src/everything/__tests__/harness.ts @@ -16,16 +16,23 @@ * (pass `sessionId: null` to leave it undefined, as stdio does), for the tests * that look at one. * + * `connectRaw()` serves the same way to a raw JSON-RPC peer that puts the + * 2026-07-28 envelope on each request, for what the SDK client hides or + * refuses: the wire fields it consumes (`resultType`, the cache fields), a + * tampered `requestState`, and task handles. + * * `connectModern()` serves a 2026-07-28 client: it hands the factory to the * SDK's `serveStdio` entry over the in-memory pair, as the stdio transport * does, and the client negotiates the era with `server/discover`, so every * request carries its capabilities in `_meta`. */ import { randomUUID } from "node:crypto"; +import { expect } from "vitest"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import type { ClientCapabilities, ContentBlock, + JSONRPCMessage, ServerNotification, } from "@modelcontextprotocol/client"; import { serveStdio } from "@modelcontextprotocol/server/stdio"; @@ -187,3 +194,78 @@ export const MODERN_CAPABILITIES: ClientCapabilities = { ...ALL_CAPABILITIES, extensions: { "io.modelcontextprotocol/tasks": {} }, }; + +type Json = Record; + +/** A raw 2026-07-28 JSON-RPC peer of a server served by `serveStdio`. */ +export type RawSession = { + /** Send a request; resolve with its whole response (result or error). */ + request: (method: string, params?: Json) => Promise; + /** The result of a request that must succeed. */ + result: (method: string, params?: Json) => Promise; + /** The result of a `tools/call`. */ + call: (name: string, args?: Json) => Promise; + /** The result of a `tasks/get`. */ + get: (taskId: string) => Promise; + close: () => Promise; +}; + +/** + * Connect a raw JSON-RPC peer to a server served by `serveStdio`, declaring + * `capabilities` in the envelope of every request it sends. + */ +export async function connectRaw(capabilities: Json): Promise { + const [peer, serverTransport] = InMemoryTransport.createLinkedPair(); + const cleanups: Array<() => void> = []; + const handle = serveStdio( + ({ era }) => { + const { server, cleanup } = createServer({ era }); + cleanups.push(cleanup); + return server; + }, + { transport: serverTransport }, + ); + const waiting = new Map void>(); + peer.onmessage = (message) => { + if ("id" in message && typeof message.id === "number") { + waiting.get(message.id)?.(message); + } + }; + await peer.start(); + let nextId = 1; + + const request = (method: string, params: Json = {}) => { + const id = nextId++; + return new Promise((resolve) => { + waiting.set(id, (m) => resolve({ ...m })); + void peer.send({ + jsonrpc: "2.0", + id, + method, + params: { + ...params, + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": capabilities, + ...(params._meta as Json | undefined), + }, + }, + }); + }); + }; + const result = async (method: string, params: Json = {}) => { + const response = await request(method, params); + expect(response.error).toBeUndefined(); + return response.result as Json; + }; + return { + request, + result, + call: (name, args = {}) => result("tools/call", { name, arguments: args }), + get: (taskId) => result("tasks/get", { taskId }), + close: async () => { + await handle.close(); + for (const cleanup of cleanups) cleanup(); + }, + }; +} diff --git a/src/everything/__tests__/logging.test.ts b/src/everything/__tests__/logging.test.ts index 30e2bacdc1..ac1fb49f01 100644 --- a/src/everything/__tests__/logging.test.ts +++ b/src/everything/__tests__/logging.test.ts @@ -2,7 +2,10 @@ * Characterizes the simulated logging that `toggle-simulated-logging` drives * (#4854): a random-leveled `notifications/message` at once and then every * five seconds, filtered by the level the client set with `logging/setLevel`, - * per session, until toggled off or the session is cleaned up. + * per session, until toggled off or the session is cleaned up. The timer + * belongs to the server instance serving the session, so the messages no + * longer name a session id (#4852). The tool is 2025-era only; its 2026-07-28 + * replacement, `trigger-log-messages`, is covered by modern.test.ts. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { @@ -44,12 +47,12 @@ describe("toggle-simulated-logging", () => { it("starts with one message at once, then one every five seconds", async () => { vi.spyOn(Math, "random").mockReturnValue(0.99); expect(await toggle()).toBe( - `Started simulated, random-leveled logging for session ${session.sessionId} at a 5 second pace. Client's selected logging level will be respected. If an interval elapses and the message to be sent is below the selected level, it will not be sent. Thus at higher chosen logging levels, messages should arrive further apart. `, + "Started simulated, random-leveled logging at a 5 second pace. Client's selected logging level will be respected. If an interval elapses and the message to be sent is below the selected level, it will not be sent. Thus at higher chosen logging levels, messages should arrive further apart.", ); await vi.waitFor(() => expect(messages()).toHaveLength(1)); expect(messages()[0]).toEqual({ level: "emergency", - data: `Emergency-level message - SessionId ${session.sessionId}`, + data: "Emergency-level message", }); await vi.advanceTimersByTimeAsync(10000); @@ -65,43 +68,39 @@ describe("toggle-simulated-logging", () => { await vi.waitFor(() => expect(messages()).toHaveLength(i + 1)); levels.push(messages()[i]); } - expect( - levels.map((m) => ({ - level: m.level, - data: String(m.data).replace(session.sessionId ?? "", ""), - })), - ).toMatchInlineSnapshot(` + expect(levels.map((m) => ({ level: m.level, data: m.data }))) + .toMatchInlineSnapshot(` [ { - "data": "Debug-level message - SessionId ", + "data": "Debug-level message", "level": "debug", }, { - "data": "Info-level message - SessionId ", + "data": "Info-level message", "level": "info", }, { - "data": "Notice-level message - SessionId ", + "data": "Notice-level message", "level": "notice", }, { - "data": "Warning-level message - SessionId ", + "data": "Warning-level message", "level": "warning", }, { - "data": "Error-level message - SessionId ", + "data": "Error-level message", "level": "error", }, { - "data": "Critical-level message - SessionId ", + "data": "Critical-level message", "level": "critical", }, { - "data": "Alert level-message - SessionId ", + "data": "Alert level-message", "level": "alert", }, { - "data": "Emergency-level message - SessionId ", + "data": "Emergency-level message", "level": "emergency", }, ] @@ -111,9 +110,7 @@ describe("toggle-simulated-logging", () => { it("stops when toggled again", async () => { await toggle(); await vi.waitFor(() => expect(messages()).toHaveLength(1)); - expect(await toggle()).toBe( - `Stopped simulated logging for session ${session.sessionId}`, - ); + expect(await toggle()).toBe("Stopped simulated logging"); await vi.advanceTimersByTimeAsync(20000); expect(messages()).toHaveLength(1); }); @@ -130,23 +127,6 @@ describe("toggle-simulated-logging", () => { expect(messages().map((m) => m.level)).toEqual(["critical"]); }); - it("names no session when there is none (stdio)", async () => { - await session.close(); - session = await connect({ sessionId: null }); - vi.spyOn(Math, "random").mockReturnValue(0.2); // info - expect(await toggle()).toMatch( - /^Started simulated, random-leveled logging for session undefined /, - ); - await vi.waitFor(() => expect(messages()).toHaveLength(1)); - expect(messages()[0]).toEqual({ - level: "info", - data: "Info-level message", - }); - expect(await toggle()).toBe( - "Stopped simulated logging for session undefined", - ); - }); - it("is per session", async () => { const other = await connect(); try { diff --git a/src/everything/__tests__/modern.test.ts b/src/everything/__tests__/modern.test.ts new file mode 100644 index 0000000000..be3c9862df --- /dev/null +++ b/src/everything/__tests__/modern.test.ts @@ -0,0 +1,617 @@ +/** + * The everything server as a 2026-07-28 client sees it (#4852), and the tools + * added with it as both eras see them. + * + * - The 2026-07-28 surface: `server/discover` (no `initialize`), the + * `extensions` capability, the tool list (capability-gated tools listed up + * front, task tools added, the 2025-only toggles absent), and the wire + * fields the SDK client consumes: `resultType`, and the `ttlMs` / + * `cacheScope` cache hints. + * - Multi-round-trip requests written once with `inputRequired(...)`: the + * form, sampling, roots and URL tools on 2026-07-28 (2025-era coverage, + * through the SDK's legacy shim, is in sampling-elicitation.test.ts and + * roots.test.ts), `requestState` carried across the multi-step tool's + * rounds on both eras, with retries after missing or bad input, and the + * SDK refusing an embedded request whose capability the request did not + * declare (-32021) or a tampered state (-32602). + * - Per-request log levels (`trigger-log-messages`) against the 2025-era + * session level. + * - Change notifications (`trigger-list-changed`, `trigger-resource-update`) + * on a `subscriptions/listen` stream, and unsolicited or after + * `resources/subscribe` on 2025-era sessions. + * - Minted resources: `gzip-file-as-resource` links on 2026-07-28. + * + * Over stdio, `get-header-params` sees no header; streamable-http.test.ts + * covers its `Mcp-Param-Region` header. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { gunzipSync } from "node:zlib"; +import type { + Client, + ElicitRequest, + ElicitResult, +} from "@modelcontextprotocol/client"; +import { McpServer } from "@modelcontextprotocol/server"; +import { CACHE_HINTS } from "../server/index.js"; +import { instancePublisher } from "../server/context.js"; +import { STATIC_DOCUMENT_CACHE_HINT } from "../resources/files.js"; +import { + clearMintedResources, + MINTED_RESOURCE_TTL_MS, +} from "../resources/minted.js"; +import { LOGGING_LEVELS } from "../tools/trigger-log-messages.js"; +import { + ALL_CAPABILITIES, + connect, + connectModern, + connectRaw, + contentOf, + MODERN_CAPABILITIES, + ofMethod, + textOf, + type RawSession, + type Session, +} from "./harness.js"; + +let session: Session | undefined; +let raw: RawSession | undefined; + +afterEach(async () => { + await session?.close(); + await raw?.close(); + session = undefined; + raw = undefined; + clearMintedResources(); + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +/** The text blocks of a `tools/call` result. */ +function texts(result: object): string[] { + return contentOf(result).map(textOf); +} + +describe("the 2026-07-28 surface", () => { + it("answers server/discover with the capabilities, the tasks extension and a cache hint", async () => { + raw = await connectRaw({}); + const discover = await raw.result("server/discover"); + expect(discover).toMatchObject({ + supportedVersions: expect.arrayContaining(["2026-07-28"]), + capabilities: { + tools: { listChanged: true }, + prompts: { listChanged: true }, + resources: { subscribe: true, listChanged: true }, + logging: {}, + extensions: { "io.modelcontextprotocol/tasks": {} }, + }, + resultType: "complete", + ...CACHE_HINTS["server/discover"], + _meta: { + "io.modelcontextprotocol/serverInfo": { + name: "mcp-servers/everything", + }, + }, + }); + }); + + it("advertises no extensions to a 2025-era client", async () => { + session = await connect(); + expect(session.client.getServerCapabilities()?.extensions).toBeUndefined(); + }); + + it("lists the capability-gated and task tools up front, and not the 2025-only toggles", async () => { + session = await connectModern(); + expect(session.client.getProtocolEra()).toBe("modern"); + const names = (await session.client.listTools()).tools.map((t) => t.name); + expect(names).toEqual( + expect.arrayContaining([ + "get-roots-list", + "trigger-elicitation-request", + "trigger-multi-step-elicitation", + "trigger-url-elicitation", + "trigger-sampling-request", + "simulate-research-query", + "trigger-sampling-request-async", + "trigger-elicitation-request-async", + ]), + ); + expect(names).not.toContain("toggle-simulated-logging"); + expect(names).not.toContain("toggle-subscriber-updates"); + }); + + it.each([ + ["tools/list", "tools/list"], + ["prompts/list", "prompts/list"], + ["resources/list", "resources/list"], + ["resources/templates/list", "resources/templates/list"], + ] as const)("stamps %s with its cache hint", async (method, hint) => { + raw = await connectRaw({}); + expect(await raw.result(method)).toMatchObject({ + resultType: "complete", + ...CACHE_HINTS[hint], + }); + }); + + it("stamps a static document read with its own, longer cache hint", async () => { + raw = await connectRaw({}); + const read = await raw.result("resources/read", { + uri: "demo://resource/static/document/instructions.md", + }); + expect(read).toMatchObject(STATIC_DOCUMENT_CACHE_HINT); + }); + + it("stamps a dynamic resource read as uncacheable", async () => { + raw = await connectRaw({}); + const read = await raw.result("resources/read", { + uri: "demo://resource/dynamic/text/1", + }); + expect(read).toMatchObject({ ttlMs: 0, cacheScope: "private" }); + }); + + it("answers a read of a resource that does not exist with -32602", async () => { + raw = await connectRaw({}); + const uri = "demo://resource/dynamic/text/0"; + const response = await raw.request("resources/read", { uri }); + expect(response.error).toMatchObject({ code: -32602, data: { uri } }); + }); + + it("stamps a tool result with resultType", async () => { + raw = await connectRaw({}); + expect(await raw.call("echo", { message: "hi" })).toEqual( + expect.objectContaining({ + resultType: "complete", + content: [{ type: "text", text: "Echo: hi" }], + }), + ); + }); + + it("reports no header for get-header-params over stdio", async () => { + session = await connectModern(); + const result = await session.client.callTool({ + name: "get-header-params", + arguments: { region: "eu-west", repeat: 2 }, + }); + expect(result.structuredContent).toEqual({ + region: "eu-west", + regionHeader: null, + repeated: ["eu-west", "eu-west"], + }); + }); + + it("repeats the region once by default", async () => { + session = await connectModern(); + const result = await session.client.callTool({ + name: "get-header-params", + arguments: { region: "ap-south" }, + }); + expect(result.structuredContent).toMatchObject({ repeated: ["ap-south"] }); + }); + + it("validates get-header-params arguments against its JSON Schema", async () => { + session = await connectModern(); + const result = await session.client.callTool({ + name: "get-header-params", + arguments: { region: "eu-west", repeat: 9 }, + }); + expect(result.isError).toBe(true); + expect(texts(result)[0]).toContain("must be <= 3"); + }); +}); + +describe("multi-round-trip requests on 2026-07-28", () => { + /** A 2026-07-28 client answering form elicitations with `answer`. */ + async function withElicitation( + answer: (request: ElicitRequest) => ElicitResult, + ) { + const asked: ElicitRequest["params"][] = []; + session = await connectModern({ + capabilities: ALL_CAPABILITIES, + setup: (client) => + client.setRequestHandler("elicitation/create", async (request) => { + asked.push(request.params); + return answer(request); + }), + }); + return { client: session.client, asked }; + } + + it("fills in the form in-band", async () => { + const { client, asked } = await withElicitation(() => ({ + action: "accept", + content: { name: "Ada" }, + })); + const result = await client.callTool({ + name: "trigger-elicitation-request", + arguments: {}, + }); + expect(asked).toHaveLength(1); + expect(texts(result).slice(0, 2)).toEqual([ + "✅ User provided the requested information!", + "User inputs:\n- Name: Ada", + ]); + }); + + it("samples in-band", async () => { + session = await connectModern({ + capabilities: ALL_CAPABILITIES, + setup: (client) => + client.setRequestHandler("sampling/createMessage", async () => ({ + role: "assistant", + model: "m", + content: { type: "text", text: "sampled" }, + })), + }); + const result = await session.client.callTool({ + name: "trigger-sampling-request", + arguments: { prompt: "p" }, + }); + expect(texts(result)[0]).toContain('"text": "sampled"'); + }); + + it("lists the roots in-band", async () => { + session = await connectModern({ + capabilities: ALL_CAPABILITIES, + setup: (client) => + client.setRequestHandler("roots/list", async () => ({ + roots: [{ uri: "file:///w", name: "W" }], + })), + }); + const result = await session.client.callTool({ + name: "get-roots-list", + arguments: {}, + }); + expect(texts(result)[0]).toMatch(/^Current MCP Roots \(1 total\)/); + }); + + it.each([ + [ + "accept", + "✅ User completed the URL elicitation flow.\nURL: https://example.com/auth", + ], + ["decline", "❌ User declined to open the URL."], + ["cancel", "⚠️ User cancelled the URL elicitation."], + ] as const)( + "reports a URL elicitation the user chose to %s", + async (action, text) => { + const { client, asked } = await withElicitation(() => ({ action })); + const result = await client.callTool({ + name: "trigger-url-elicitation", + arguments: { url: "https://example.com/auth", errorPath: true }, + }); + // No -32042 error path on 2026-07-28: the URL comes in-band, with no id. + expect(asked).toEqual([ + expect.objectContaining({ + mode: "url", + url: "https://example.com/auth", + }), + ]); + expect(asked[0]).not.toHaveProperty("elicitationId"); + expect(texts(result)[0]).toBe(text); + }, + ); + + it("refuses an embedded request the client's request did not declare (-32021)", async () => { + raw = await connectRaw({}); + const response = await raw.request("tools/call", { + name: "trigger-sampling-request", + arguments: { prompt: "p" }, + }); + expect(response.error).toMatchObject({ code: -32021 }); + }); + + it("refuses a tampered requestState with -32602 before the tool runs", async () => { + raw = await connectRaw({ elicitation: {} }); + const response = await raw.request("tools/call", { + name: "trigger-multi-step-elicitation", + arguments: {}, + requestState: "tampered", + inputResponses: {}, + }); + expect(response.error).toMatchObject({ code: -32602 }); + }); + + it("returns input_required with a signed requestState on the wire", async () => { + raw = await connectRaw({ elicitation: {} }); + const first = await raw.call("trigger-multi-step-elicitation"); + expect(first).toMatchObject({ + resultType: "input_required", + inputRequests: { name: { method: "elicitation/create" } }, + requestState: expect.any(String), + }); + }); +}); + +describe("trigger-multi-step-elicitation", () => { + /** Run the tool for a client that answers each question from `answers`. */ + async function run( + connectTo: typeof connect | typeof connectModern, + answers: ElicitResult[], + ) { + const asked: string[] = []; + session = await connectTo({ + capabilities: { elicitation: { form: {} } }, + setup: (client: Client) => + client.setRequestHandler("elicitation/create", async (request) => { + asked.push(request.params.message); + return answers.shift() ?? { action: "cancel" }; + }), + }); + const result = await session.client.callTool({ + name: "trigger-multi-step-elicitation", + arguments: {}, + }); + return { result, asked }; + } + + it.each([ + ["2026-07-28", connectModern], + ["2025-11-25", connect], + ] as const)( + "carries the name to the color question on %s", + async (_era, connectTo) => { + const { result, asked } = await run(connectTo, [ + { action: "accept", content: { name: "Ada" } }, + { action: "accept", content: { color: "green" } }, + ]); + expect(asked).toEqual([ + "What is your name?", + "Thanks, Ada. Which color do you like best?", + ]); + expect(texts(result)).toEqual(["Ada's favorite color is green."]); + expect(result.structuredContent).toEqual({ + name: "Ada", + color: "green", + }); + }, + ); + + it("asks again after a blank name or a color that is not a choice", async () => { + const { result, asked } = await run(connectModern, [ + { action: "accept", content: { name: " " } }, + { action: "accept", content: { name: "Ada" } }, + { action: "accept", content: { color: "mauve" } }, + { action: "accept", content: { color: "red" } }, + ]); + expect(asked).toEqual([ + "What is your name?", + "A name is required. What is your name?", + "Thanks, Ada. Which color do you like best?", + "Thanks, Ada. Which color do you like best?", + ]); + expect(texts(result)).toEqual(["Ada's favorite color is red."]); + }); + + it.each([ + [ + [{ action: "decline" as const }], + "Stopped: the user chose decline at the name question.", + ], + [ + [ + { action: "accept" as const, content: { name: "Ada" } }, + { action: "cancel" as const }, + ], + "Stopped: the user chose cancel at the color question.", + ], + ])("stops when the user declines or cancels: %j", async (answers, text) => { + const { result } = await run(connectModern, [...answers]); + expect(result.isError).toBe(true); + expect(texts(result)).toEqual([text]); + }); +}); + +describe("trigger-log-messages", () => { + /** The levels of the log messages a `trigger-log-messages` call delivered. */ + async function levelsFrom(s: Session, logLevel?: string): Promise { + const levels: string[] = []; + s.client.setNotificationHandler("notifications/message", (n) => { + levels.push(n.params.level); + }); + const result = await s.client.callTool({ + name: "trigger-log-messages", + arguments: {}, + ...(logLevel && { + _meta: { "io.modelcontextprotocol/logLevel": logLevel }, + }), + }); + expect(texts(result)[0]).toMatch( + /^Sent one log message at each of 8 levels/, + ); + return levels; + } + + it("sends the levels at or above the one the 2026-07-28 request names", async () => { + session = await connectModern(); + expect(await levelsFrom(session, "error")).toEqual( + LOGGING_LEVELS.slice(LOGGING_LEVELS.indexOf("error")), + ); + }); + + it("sends nothing to a 2026-07-28 request that names no level", async () => { + session = await connectModern(); + expect(await levelsFrom(session)).toEqual([]); + }); + + it("follows the level a 2025-era client set for the session", async () => { + session = await connect(); + await session.client.setLoggingLevel("critical"); + expect(await levelsFrom(session)).toEqual([ + "critical", + "alert", + "emergency", + ]); + }); +}); + +describe("change notifications", () => { + const URI = "demo://resource/dynamic/text/1"; + + /** Resolves with the next notification of `method` the client receives. */ + function next( + client: Client, + method: + | "notifications/resources/updated" + | "notifications/tools/list_changed" + | "notifications/prompts/list_changed" + | "notifications/resources/list_changed", + ) { + return new Promise((resolve) => { + client.setNotificationHandler(method, (n) => resolve(n.params ?? {})); + }); + } + + it.each([ + ["tools", "toolsListChanged", "notifications/tools/list_changed"], + ["prompts", "promptsListChanged", "notifications/prompts/list_changed"], + [ + "resources", + "resourcesListChanged", + "notifications/resources/list_changed", + ], + ] as const)( + "delivers a %s list change on a 2026-07-28 listen stream that asked for it", + async (list, filter, method) => { + session = await connectModern(); + const subscription = await session.client.listen({ [filter]: true }); + const arrived = next(session.client, method); + const result = await session.client.callTool({ + name: "trigger-list-changed", + arguments: { list }, + }); + expect(texts(result)).toEqual([ + `Sent notifications/${list}/list_changed`, + ]); + await arrived; + await subscription.close(); + }, + ); + + it("delivers a resource update on a 2026-07-28 listen stream that named the URI", async () => { + session = await connectModern(); + const subscription = await session.client.listen({ + resourceSubscriptions: [URI], + }); + const arrived = next(session.client, "notifications/resources/updated"); + const result = await session.client.callTool({ + name: "trigger-resource-update", + arguments: { uri: URI }, + }); + expect(texts(result)).toEqual([ + `Sent notifications/resources/updated for ${URI} to each subscriber`, + ]); + // The listen stream stamps the notification with its subscription id. + expect(await arrived).toMatchObject({ uri: URI }); + await subscription.close(); + }); + + it("sends a 2025-era client a list change unsolicited", async () => { + session = await connect(); + const arrived = next(session.client, "notifications/prompts/list_changed"); + await session.client.callTool({ + name: "trigger-list-changed", + arguments: { list: "prompts" }, + }); + await arrived; + }); + + it("sends a 2025-era client a resource update only once it has subscribed", async () => { + session = await connect(); + const s = session; + const updates = () => + ofMethod(s.notifications, "notifications/resources/updated"); + await s.client.callTool({ + name: "trigger-resource-update", + arguments: { uri: URI }, + }); + await s.client.ping(); + expect(updates()).toEqual([]); + + const arrived = next(s.client, "notifications/resources/updated"); + await s.client.subscribeResource({ uri: URI }); + await s.client.callTool({ + name: "trigger-resource-update", + arguments: { uri: URI }, + }); + expect(await arrived).toEqual({ uri: URI }); + }); + + it("logs, rather than throws, a resource update that cannot be sent", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const server = new McpServer({ name: "s", version: "0" }); + const failure = new Error("closed"); + vi.spyOn(server.server, "sendResourceUpdated").mockRejectedValue(failure); + instancePublisher(server, () => true).resourceUpdated(URI); + await vi.waitFor(() => + expect(error).toHaveBeenCalledWith( + "Change notification failed:", + failure, + ), + ); + }); +}); + +describe("minted resources", () => { + const DATA_URI = `data:text/plain;base64,${Buffer.from("hello, minted").toString("base64")}`; + + async function mint(): Promise { + session = await connectModern(); + const result = await session.client.callTool({ + name: "gzip-file-as-resource", + arguments: { name: "note.txt.gz", data: DATA_URI }, + }); + const [link] = contentOf(result); + expect(link).toMatchObject({ + type: "resource_link", + name: "note.txt.gz", + mimeType: "application/gzip", + uri: expect.stringMatching( + /^demo:\/\/resource\/minted\/[0-9a-f-]{36}\/note\.txt\.gz$/, + ), + }); + return (link as { uri: string }).uri; + } + + it("links a 2026-07-28 client to a resource it can read later", async () => { + const uri = await mint(); + const { contents } = await session!.client.readResource({ uri }); + const blob = (contents[0] as { blob: string }).blob; + expect(gunzipSync(Buffer.from(blob, "base64")).toString()).toBe( + "hello, minted", + ); + }); + + it("can be read by another 2026-07-28 connection that has the URI", async () => { + const uri = await mint(); + const other = await connectModern(); + try { + const { contents } = await other.client.readResource({ uri }); + expect(contents).toHaveLength(1); + } finally { + await other.close(); + } + }); + + it("expires after its TTL, and then reads as not found (-32602)", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + const uri = await mint(); + vi.setSystemTime(Date.now() + MINTED_RESOURCE_TTL_MS); + await expect(session!.client.readResource({ uri })).rejects.toMatchObject({ + code: -32602, + data: { uri }, + }); + }); + + it("is not served to a 2025-era client", async () => { + session = await connect(); + const { resourceTemplates } = await session.client.listResourceTemplates(); + expect(resourceTemplates.map((t) => t.name)).not.toContain( + "Minted Resource", + ); + }); + + it("lists nothing, so a URI is reached only through the tool's link", async () => { + session = await connectModern({ capabilities: MODERN_CAPABILITIES }); + const { resources } = await session.client.listResources(); + expect(resources.some((r) => r.uri.includes("/minted/"))).toBe(false); + }); +}); diff --git a/src/everything/__tests__/resources.test.ts b/src/everything/__tests__/resources.test.ts index 4e01f2e0bb..d26b33b05d 100644 --- a/src/everything/__tests__/resources.test.ts +++ b/src/everything/__tests__/resources.test.ts @@ -75,10 +75,12 @@ describe("dynamic resource templates", () => { }); it.each(["0", "-2", "1.5", "abc"])("rejects resource id %s", async (id) => { + // No such resource: -32602 with the URI in the error's data (#4852). const uri = `demo://resource/dynamic/text/${id}`; - await expect(session.client.readResource({ uri })).rejects.toThrow( - `Unknown resource: ${uri}`, - ); + await expect(session.client.readResource({ uri })).rejects.toMatchObject({ + code: -32602, + data: { uri }, + }); }); it("rejects a URI that matches no resource or template", async () => { @@ -110,9 +112,9 @@ describe("resource subscriptions", () => { // Unsubscribing from a URI with no subscriber set is accepted too. await session.client.unsubscribeResource({ uri: "demo://never" }); expect(logData()).toEqual([ - `Received Subscribe Resource request for URI: ${URI} from session ${session.sessionId}`, - `Received Unsubscribe Resource request: ${URI} from session ${session.sessionId}`, - `Received Unsubscribe Resource request: demo://never from session ${session.sessionId}`, + `Received Subscribe Resource request for URI: ${URI}`, + `Received Unsubscribe Resource request: ${URI}`, + "Received Unsubscribe Resource request: demo://never", ]); }); @@ -137,17 +139,6 @@ describe("resource subscriptions", () => { } }); - it("omits the session from the acknowledgement when there is none (stdio)", async () => { - await session.close(); - session = await connect({ sessionId: null }); - await session.client.subscribeResource({ uri: URI }); - await session.client.unsubscribeResource({ uri: URI }); - expect(logData()).toEqual([ - `Received Subscribe Resource request for URI: ${URI} `, - `Received Unsubscribe Resource request: ${URI} `, - ]); - }); - it("sends updates for subscribed URIs every 5 seconds while toggled on", async () => { vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] }); await session.client.subscribeResource({ uri: URI }); @@ -158,7 +149,7 @@ describe("resource subscriptions", () => { arguments: {}, }); expect(textOf(contentOf(on)[0])).toBe( - `Started simulated resource updated notifications for session ${session.sessionId} at a 5 second pace. Client will receive updates for any resources the it is subscribed to.`, + "Started simulated resource updated notifications at a 5 second pace. Client will receive updates for any resources it is subscribed to.", ); await vi.waitFor(() => expect(updates()).toEqual([URI])); @@ -175,7 +166,7 @@ describe("resource subscriptions", () => { arguments: {}, }); expect(textOf(contentOf(off)[0])).toBe( - `Stopped simulated resource updates for session ${session.sessionId}`, + "Stopped simulated resource updates", ); }); diff --git a/src/everything/__tests__/roots.test.ts b/src/everything/__tests__/roots.test.ts index 88cada7c13..8cfd251ae9 100644 --- a/src/everything/__tests__/roots.test.ts +++ b/src/everything/__tests__/roots.test.ts @@ -107,7 +107,7 @@ describe("roots sync after initialize", () => { session = s; await vi.waitFor(() => expect(errorSpy).toHaveBeenCalledWith( - `Failed to request roots from client ${s.sessionId}: no roots for you`, + "Failed to request roots from client: no roots for you", ), ); expect(logData(s)).toEqual([]); @@ -127,13 +127,16 @@ describe("get-roots-list", () => { ); }); - it("fetches the roots itself when called before the initial sync, and the sync then reuses them", async () => { + it("asks the client on every call, with a multi-round-trip request (#4852)", async () => { + // The tool no longer reads a per-session cache: each call returns + // input_required carrying roots/list, which the legacy shim sends as a + // real request. The sync after initialize asks on its own. const { s, requests } = await connectWithRoots(() => ROOTS); session = s; + await vi.waitFor(() => expect(requests()).toBe(1)); await getRootsList(s); - expect(requests()).toBe(1); - await new Promise((resolve) => setTimeout(resolve, 450)); - expect(requests()).toBe(1); + await getRootsList(s); + expect(requests()).toBe(3); }); it("explains an empty roots list", async () => { @@ -148,14 +151,17 @@ describe("get-roots-list", () => { ); }); - it("explains a client that failed to list its roots the same way", async () => { + it("reports a client that failed to list its roots as a tool error", async () => { vi.spyOn(console, "error").mockImplementation(() => {}); const { s } = await connectWithRoots(() => { throw new Error("unavailable"); }); session = s; - expect(await getRootsList(s)).toMatch( - /^The client supports roots but no roots are currently configured\./, - ); + const result = await s.client.callTool({ + name: "get-roots-list", + arguments: {}, + }); + expect(result.isError).toBe(true); + expect(textOf(contentOf(result)[0])).toContain("unavailable"); }); }); diff --git a/src/everything/__tests__/sampling-elicitation.test.ts b/src/everything/__tests__/sampling-elicitation.test.ts index 31b648913e..3e59f950df 100644 --- a/src/everything/__tests__/sampling-elicitation.test.ts +++ b/src/everything/__tests__/sampling-elicitation.test.ts @@ -51,8 +51,11 @@ describe("trigger-sampling-request", () => { arguments: { prompt: "Say hi" }, }); + // The tool asks in-band (input_required); the SDK's legacy shim sends the + // request, adding a progress token of its own (#4852). expect(asked).toEqual([ { + _meta: { progressToken: expect.anything() }, messages: [ { role: "user", @@ -119,11 +122,12 @@ describe("trigger-sampling-request", () => { }); expect(result).toEqual({ isError: true, - // The SDK prefixes the client's error message twice on its way back. + // The SDK's legacy shim reports a failed input request as a tool error + // naming the call it was fulfilling (#4852). content: [ { type: "text", - text: "user rejected", + text: "Fulfilling input required by 'tools/call' failed: user rejected", }, ], }); @@ -165,7 +169,11 @@ describe("trigger-elicitation-request", () => { expect(asked).toHaveLength(1); expect(asked[0]).toMatchInlineSnapshot(` { + "_meta": { + "progressToken": 0, + }, "message": "Please provide inputs for the following fields:", + "mode": "form", "requestedSchema": { "properties": { "birthdate": { diff --git a/src/everything/__tests__/server.test.ts b/src/everything/__tests__/server.test.ts index 768a5fcd41..8fd6eef0f2 100644 --- a/src/everything/__tests__/server.test.ts +++ b/src/everything/__tests__/server.test.ts @@ -5,7 +5,7 @@ * session resources, gzip-file-as-resource, makes blobs), and `syncRoots()` * for a server with no initialized client. */ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { InMemoryTransport, McpServer } from "@modelcontextprotocol/server"; import { Client } from "@modelcontextprotocol/client"; import { createServer } from "../server/index.js"; @@ -64,3 +64,33 @@ describe("syncRoots", () => { expect(await syncRoots(server)).toBeUndefined(); }); }); + +describe("syncRoots, called again", () => { + it("asks again but installs its roots/list_changed handler once", async () => { + const server = new McpServer({ name: "s", version: "0" }); + let requests = 0; + const client = new Client( + { name: "c", version: "0" }, + { capabilities: { roots: { listChanged: true } } }, + ); + client.setRequestHandler("roots/list", async () => { + requests++; + return { roots: [] }; + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await Promise.all([ + server.connect(serverTransport), + client.connect(clientTransport), + ]); + await syncRoots(server); + await syncRoots(server); + expect(requests).toBe(2); + // One handler, so one more request per change, not two. + await client.sendRootsListChanged(); + await vi.waitFor(() => expect(requests).toBe(3)); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(requests).toBe(3); + await client.close(); + }); +}); diff --git a/src/everything/__tests__/sse.test.ts b/src/everything/__tests__/sse.test.ts index 886cd30c66..58394d20f9 100644 --- a/src/everything/__tests__/sse.test.ts +++ b/src/everything/__tests__/sse.test.ts @@ -59,15 +59,21 @@ describe("SSE transport", () => { const a = await connectSse(); const b = await connectSse(); expect(a.sessionId).not.toBe(b.sessionId); - const toggled = await a.client.callTool({ - name: "toggle-simulated-logging", - arguments: {}, - }); - expect(JSON.stringify(toggled.content)).toContain(a.sessionId); - await a.client.callTool({ - name: "toggle-simulated-logging", - arguments: {}, - }); + // Each stream's server keeps its own toggle state: turning logging on + // for one leaves the other's off. + const toggle = async (client: typeof a.client) => + JSON.stringify( + ( + await client.callTool({ + name: "toggle-simulated-logging", + arguments: {}, + }) + ).content, + ); + expect(await toggle(a.client)).toContain("Started"); + expect(await toggle(b.client)).toContain("Started"); + expect(await toggle(a.client)).toContain("Stopped"); + expect(await toggle(b.client)).toContain("Stopped"); await Promise.all([a.client.close(), b.client.close()]); }); diff --git a/src/everything/__tests__/stdio.test.ts b/src/everything/__tests__/stdio.test.ts index b1b9847d2c..86ca23e153 100644 --- a/src/everything/__tests__/stdio.test.ts +++ b/src/everything/__tests__/stdio.test.ts @@ -53,19 +53,25 @@ describe("stdio transport", () => { await client.close(); }); - it("gives the session no id, so per-session output names none", async () => { - const { client } = await startOnPipes(); + it("serves a 2026-07-28 client on the same pipes (#4852)", async () => { + const toServer = new PassThrough(); + const fromServer = new PassThrough(); + const stdio = await main(toServer, fromServer); + started.push(stdio); + const client = new Client( + { name: "stdio-test", version: "0.0.0" }, + { versionNegotiation: { mode: { pin: "2026-07-28" } } }, + ); + await client.connect(new StdioServerTransport(fromServer, toServer)); + expect(client.getProtocolEra()).toBe("modern"); + expect(client.getServerCapabilities()?.extensions).toEqual({ + "io.modelcontextprotocol/tasks": {}, + }); const result = await client.callTool({ - name: "toggle-subscriber-updates", - arguments: {}, + name: "echo", + arguments: { message: "modern" }, }); - expect(result.content).toEqual([ - { - type: "text", - text: "Started simulated resource updated notifications for session undefined at a 5 second pace. Client will receive updates for any resources the it is subscribed to.", - }, - ]); - await client.callTool({ name: "toggle-subscriber-updates", arguments: {} }); + expect(result.content).toEqual([{ type: "text", text: "Echo: modern" }]); await client.close(); }); @@ -97,17 +103,22 @@ describe("stdio transport", () => { for (const listener of added) process.off("SIGINT", listener); }); - it("reports a failure to start and exits 1", async () => { + it("reports a transport that cannot start on stderr", async () => { const error = vi.spyOn(console, "error").mockImplementation(() => {}); - const exit = vi - .spyOn(process, "exit") - .mockImplementation(() => undefined as never); const broken = new PassThrough(); broken.on = () => { throw new Error("stdin unavailable"); }; + const before = process.listeners("SIGINT").length; await startStdioServer(broken, new PassThrough()); - expect(error).toHaveBeenCalledWith("Server error:", expect.any(Error)); - expect(exit).toHaveBeenCalledWith(1); + await vi.waitFor(() => + expect(error).toHaveBeenCalledWith( + "Transport error:", + expect.objectContaining({ message: "stdin unavailable" }), + ), + ); + for (const listener of process.listeners("SIGINT").slice(before)) { + process.off("SIGINT", listener); + } }); }); diff --git a/src/everything/__tests__/streamable-http.test.ts b/src/everything/__tests__/streamable-http.test.ts index fe735c97b9..6e8d70a00a 100644 --- a/src/everything/__tests__/streamable-http.test.ts +++ b/src/everything/__tests__/streamable-http.test.ts @@ -7,6 +7,13 @@ * only (#4087). The shutdown handler is called directly (#4983), and * `startStreamableHttpServer()` is * checked with `listenOrExit` stubbed, so no fixed port is ever bound. + * + * The same endpoint serves 2026-07-28 clients (#4852): a session-less POST + * carrying the per-request envelope goes to the SDK's `createMcpHandler`, and + * the "2026-07-28 clients" block below drives it with a client that + * negotiates that era, including the `Mcp-Param-Region` header the client + * mirrors from `get-header-params`'s `x-mcp-header` argument and the change + * notifications published on the handler's subscription bus. */ import type { Server } from "node:http"; import { once } from "node:events"; @@ -335,9 +342,12 @@ describe("Streamable HTTP transport", () => { ).mockRejectedValueOnce(new Error("boom")); const response = await post({ jsonrpc: "2.0", id: 1, method: "ping" }); expect(response.status).toBe(500); + // A session-less POST's body is parsed before it is routed (#4852), so + // the error carries the request's id. expect(await response.json()).toEqual({ jsonrpc: "2.0", error: { code: -32603, message: "Internal server error" }, + id: 1, }); expect(log).toHaveBeenCalledWith( "Error handling MCP request:", @@ -503,3 +513,167 @@ describe("startStreamableHttpServer", () => { ); }); }); + +describe("2026-07-28 clients", () => { + async function connectModernHttp() { + const client = new Client( + { name: "http-modern-test", version: "0.0.0" }, + { versionNegotiation: { mode: { pin: "2026-07-28" } } }, + ); + await client.connect(new StreamableHTTPClientTransport(new URL(mcp))); + return client; + } + + it("serves a 2026-07-28 client with no session", async () => { + const client = await connectModernHttp(); + expect(client.getProtocolEra()).toBe("modern"); + const result = await client.callTool({ + name: "echo", + arguments: { message: "modern http" }, + }); + expect(result.content).toEqual([ + { type: "text", text: "Echo: modern http" }, + ]); + await client.close(); + }); + + it("serves 2025-era and 2026-07-28 clients from the one endpoint", async () => { + const legacy = await connectHttp(); + const modern = await connectModernHttp(); + expect(legacy.client.getProtocolEra()).toBe("legacy"); + expect(legacy.sessionId).toMatch(/^[0-9a-f-]{36}$/); + expect(modern.getProtocolEra()).toBe("modern"); + await Promise.all([legacy.client.close(), modern.close()]); + }); + + it("receives the argument get-header-params marks with x-mcp-header as an Mcp-Param-Region header", async () => { + const client = await connectModernHttp(); + const result = await client.callTool({ + name: "get-header-params", + arguments: { region: "eu-west" }, + }); + expect(result.structuredContent).toEqual({ + region: "eu-west", + regionHeader: "eu-west", + repeated: ["eu-west"], + }); + await client.close(); + }); + + it("refuses a call whose Mcp-Param-Region header disagrees with the body (-32020)", async () => { + const response = await fetch(mcp, { + method: "POST", + headers: { + ...JSON_HEADERS, + "mcp-protocol-version": "2026-07-28", + "mcp-method": "tools/call", + "mcp-name": "get-header-params", + "mcp-param-region": "us-east", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { + name: "get-header-params", + arguments: { region: "eu-west" }, + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + }, + }, + }), + }); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ + error: { code: -32020 }, + }); + }); + + it.each([ + ["tools", "toolsListChanged", "notifications/tools/list_changed"], + ["prompts", "promptsListChanged", "notifications/prompts/list_changed"], + [ + "resources", + "resourcesListChanged", + "notifications/resources/list_changed", + ], + ] as const)( + "publishes a %s list change to listen streams, across requests", + async (list, filter, method) => { + const listener = await connectModernHttp(); + const caller = await connectModernHttp(); + const arrived = new Promise((resolve) => + listener.setNotificationHandler(method, () => resolve()), + ); + const subscription = await listener.listen({ [filter]: true }); + await caller.callTool({ + name: "trigger-list-changed", + arguments: { list }, + }); + await arrived; + await subscription.close(); + await Promise.all([listener.close(), caller.close()]); + }, + ); + + it("publishes a resource update to the listen streams that named the URI", async () => { + const uri = "demo://resource/dynamic/text/3"; + const listener = await connectModernHttp(); + const arrived = new Promise((resolve) => + listener.setNotificationHandler("notifications/resources/updated", (n) => + resolve(n.params.uri), + ), + ); + const subscription = await listener.listen({ + resourceSubscriptions: [uri], + }); + await listener.callTool({ + name: "trigger-resource-update", + arguments: { uri }, + }); + expect(await arrived).toBe(uri); + await subscription.close(); + await listener.close(); + }); + + it("answers a session-less POST that is not JSON with a parse error", async () => { + const response = await fetch(mcp, { + method: "POST", + headers: JSON_HEADERS, + body: "{not json", + }); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + jsonrpc: "2.0", + error: { + code: -32700, + message: "Parse error: the request body is not valid JSON", + }, + id: null, + }); + }); + + it("answers a session-less POST over 4 MiB with 413", async () => { + const response = await fetch(mcp, { + method: "POST", + headers: JSON_HEADERS, + body: JSON.stringify({ pad: "x".repeat(4 * 1024 * 1024) }), + }); + expect(response.status).toBe(413); + expect(await response.json()).toEqual({ + jsonrpc: "2.0", + error: { code: -32600, message: "Request body too large" }, + id: null, + }); + }); + + it("leaves any other body error to Express", async () => { + const response = await fetch(mcp, { + method: "POST", + headers: { ...JSON_HEADERS, "content-encoding": "bogus" }, + body: "{}", + }); + expect(response.status).toBe(415); + }); +}); diff --git a/src/everything/__tests__/surface.test.ts b/src/everything/__tests__/surface.test.ts index 4086b323f2..fca3047d78 100644 --- a/src/everything/__tests__/surface.test.ts +++ b/src/everything/__tests__/surface.test.ts @@ -4,7 +4,8 @@ * prompt, resource and resource-template lists, including which tools appear * only for clients that declare a capability. These lists are the regression * net for the SDK v2 migration: an assertion here that has to change is a - * behavior change. + * behavior change. Every session here is 2025-era (`initialize`); what a + * 2026-07-28 client is offered is in modern.test.ts. */ import { afterEach, describe, expect, it } from "vitest"; import { @@ -25,6 +26,7 @@ const BASE_TOOLS = [ "echo", "get-annotated-message", "get-env", + "get-header-params", "get-resource-links", "get-resource-reference", "get-structured-content", @@ -33,7 +35,10 @@ const BASE_TOOLS = [ "gzip-file-as-resource", "toggle-simulated-logging", "toggle-subscriber-updates", + "trigger-list-changed", + "trigger-log-messages", "trigger-long-running-operation", + "trigger-resource-update", ]; describe("initialize", () => { @@ -69,6 +74,7 @@ describe("initialize", () => { const gated = [ "get-roots-list", "trigger-elicitation-request", + "trigger-multi-step-elicitation", "trigger-sampling-request", "trigger-url-elicitation", ]; @@ -108,6 +114,7 @@ describe("tools/list", () => { ...BASE_TOOLS, "get-roots-list", "trigger-elicitation-request", + "trigger-multi-step-elicitation", "trigger-sampling-request", "trigger-url-elicitation", ].sort(), @@ -117,60 +124,26 @@ describe("tools/list", () => { it.each([ [{ roots: {} }, ["get-roots-list"]], [{ sampling: {} }, ["trigger-sampling-request"]], - [{ elicitation: {} }, ["trigger-elicitation-request"]], - [{ elicitation: { form: {} } }, ["trigger-elicitation-request"]], - // A URL-only client cannot answer the form request (#4985). - [{ elicitation: { url: {} } }, ["trigger-url-elicitation"]], [ - { elicitation: { form: {}, url: {} } }, - ["trigger-elicitation-request", "trigger-url-elicitation"], + { elicitation: {} }, + ["trigger-elicitation-request", "trigger-multi-step-elicitation"], ], - ])( - "gates tools on the client's declared capabilities: %j", - async (capabilities, extra) => { - session = await connect({ capabilities }); - const { tools } = await session.client.listTools(); - expect(tools.map((t) => t.name).sort()).toEqual( - [...BASE_TOOLS, ...extra].sort(), - ); - }, - ); - - // The async task tools went with SDK v2's experimental tasks layer - // (SEP-2663). Part 5 (#4852) brings them back on the tasks extension. - it.skip.each([ [ - { - sampling: {}, - tasks: { requests: { sampling: { createMessage: {} } } }, - }, - ["trigger-sampling-request", "trigger-sampling-request-async"], + { elicitation: { form: {} } }, + ["trigger-elicitation-request", "trigger-multi-step-elicitation"], ], + // A URL-only client cannot answer the form request (#4985). + [{ elicitation: { url: {} } }, ["trigger-url-elicitation"]], [ - { elicitation: {}, tasks: { requests: { elicitation: { create: {} } } } }, - ["trigger-elicitation-request", "trigger-elicitation-request-async"], - ], - [ - { - elicitation: { form: {} }, - tasks: { requests: { elicitation: { create: {} } } }, - }, - ["trigger-elicitation-request", "trigger-elicitation-request-async"], - ], - // The async tool's request is form mode too, so a URL-only client does - // not get it even with task support (#4985). - [ - { - elicitation: { url: {} }, - tasks: { requests: { elicitation: { create: {} } } }, - }, - ["trigger-url-elicitation"], + { elicitation: { form: {}, url: {} } }, + [ + "trigger-elicitation-request", + "trigger-multi-step-elicitation", + "trigger-url-elicitation", + ], ], - // The async tools need the base capability as well as the task one. - [{ tasks: { requests: { sampling: { createMessage: {} } } } }, []], - [{ tasks: { requests: { elicitation: { create: {} } } } }, []], ])( - "gates the async task tools on the client's declared capabilities: %j", + "gates tools on the client's declared capabilities: %j", async (capabilities, extra) => { session = await connect({ capabilities }); const { tools } = await session.client.listTools(); @@ -242,6 +215,17 @@ describe("tools/list", () => { "hasOutputSchema": false, "title": "Print Environment Tool", }, + "get-header-params": { + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + }, + "execution": undefined, + "hasOutputSchema": true, + "title": "Get Header Params Tool", + }, "get-resource-links": { "annotations": { "destructiveHint": false, @@ -352,6 +336,28 @@ describe("tools/list", () => { "hasOutputSchema": false, "title": "Trigger Elicitation Request Tool", }, + "trigger-list-changed": { + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": true, + }, + "execution": undefined, + "hasOutputSchema": false, + "title": "Trigger List Changed Tool", + }, + "trigger-log-messages": { + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true, + }, + "execution": undefined, + "hasOutputSchema": false, + "title": "Trigger Log Messages Tool", + }, "trigger-long-running-operation": { "annotations": { "destructiveHint": false, @@ -363,6 +369,28 @@ describe("tools/list", () => { "hasOutputSchema": false, "title": "Trigger Long Running Operation Tool", }, + "trigger-multi-step-elicitation": { + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": false, + }, + "execution": undefined, + "hasOutputSchema": false, + "title": "Trigger Multi-Step Elicitation Tool", + }, + "trigger-resource-update": { + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": false, + "readOnlyHint": true, + }, + "execution": undefined, + "hasOutputSchema": false, + "title": "Trigger Resource Update Tool", + }, "trigger-sampling-request": { "annotations": { "destructiveHint": false, @@ -440,6 +468,28 @@ describe("tools/list", () => { "properties": {}, "type": "object", }, + "get-header-params": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "region": { + "description": "A region name, e.g. eu-west. Mirrored into the Mcp-Param-Region HTTP header.", + "type": "string", + "x-mcp-header": "Region", + }, + "repeat": { + "default": 1, + "description": "How many times to repeat the region in the result (1-3)", + "maximum": 3, + "minimum": 1, + "type": "integer", + }, + }, + "required": [ + "region", + ], + "type": "object", + }, "get-resource-links": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { @@ -559,6 +609,29 @@ describe("tools/list", () => { "properties": {}, "type": "object", }, + "trigger-list-changed": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "list": { + "description": "Which list to announce as changed", + "enum": [ + "tools", + "prompts", + "resources", + ], + "type": "string", + }, + }, + "required": [ + "list", + ], + "type": "object", + }, + "trigger-log-messages": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": {}, + "type": "object", + }, "trigger-long-running-operation": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { @@ -575,6 +648,24 @@ describe("tools/list", () => { }, "type": "object", }, + "trigger-multi-step-elicitation": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": {}, + "type": "object", + }, + "trigger-resource-update": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "properties": { + "uri": { + "description": "The URI of the resource to announce as updated, e.g. demo://resource/dynamic/text/1", + "type": "string", + }, + }, + "required": [ + "uri", + ], + "type": "object", + }, "trigger-sampling-request": { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { @@ -597,12 +688,12 @@ describe("tools/list", () => { "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "elicitationId": { - "description": "Optional explicit elicitation ID. Defaults to a random UUID.", + "description": "Optional explicit elicitation ID. Defaults to a random UUID. 2025-era clients only: the 2026-07-28 in-band request carries no elicitation ID.", "type": "string", }, "errorPath": { "default": false, - "description": "Controls which elicitation mechanism is used. When false (default), sends an elicitation/create request (request path). When true, throws a UrlElicitationRequiredError (MCP error code -32042) so the client handles the URL elicitation via the error path rather than waiting for a response. To clear the error, satisfy the prerequisite and retry this call with the same arguments; the retry ignores errorPath and proceeds, so the client does not loop on the same error.", + "description": "Controls which elicitation mechanism is used. When false (default), sends an elicitation/create request (request path). When true, throws a UrlElicitationRequiredError (MCP error code -32042) so the client handles the URL elicitation via the error path rather than waiting for a response. To clear the error, satisfy the prerequisite and retry this call with the same arguments; the retry ignores errorPath and proceeds, so the client does not loop on the same error. 2025-era clients only: protocol version 2026-07-28 has no -32042 error, so there both settings use the in-band input_required flow.", "type": "boolean", }, "message": { diff --git a/src/everything/__tests__/tasks.test.ts b/src/everything/__tests__/tasks.test.ts index d90c266f3f..03581f9be6 100644 --- a/src/everything/__tests__/tasks.test.ts +++ b/src/everything/__tests__/tasks.test.ts @@ -1,747 +1,462 @@ /** - * Characterizes the everything server's experimental task tools (#4854): + * The everything server's task tools on the Tasks extension + * (`io.modelcontextprotocol/tasks`, SEP-2663), on protocol version 2026-07-28 + * (#4852): `simulate-research-query`, `trigger-sampling-request-async` and + * `trigger-elicitation-request-async`, and the extension's `tasks/get`, + * `tasks/update` and `tasks/cancel`. * - * - `simulate-research-query`, a server-side task (`taskSupport: required`) - * that a client drives with `tools/call` + `task`, `tasks/get` and - * `tasks/result`, including the `input_required` elicitation it sends for an - * ambiguous topic and what happens when the client cancels it. - * - `trigger-sampling-request-async` and `trigger-elicitation-request-async`, - * which send the client a task-augmented request and then poll the client's - * `tasks/get` until it is done. The test client declares the task request - * capabilities and keeps its tasks in an `InMemoryTaskStore`. + * A task tool answers `tools/call` with a task handle (`resultType: "task"`), + * which the SDK `Client` refuses, since it has no tasks support of its own + * (the extension's client lives in `@modelcontextprotocol/ext-tasks`). So + * these tests speak raw JSON-RPC to the server, served by `serveStdio` over an + * in-memory pair the way the stdio transport serves it, with the 2026-07-28 + * envelope on each request. A task's progress follows the clock, so the tests + * fake `Date` and move it. * - * Every wait in these tools is a `setTimeout` (one-second stages and polls), - * so the tests fake `setTimeout` and advance the clock. - * - * SDK v2 removed the experimental tasks layer these tools and tests were - * built on (SEP-2663), so the three tools are gone and every test here is - * skipped until Part 5 (#4852) re-implements them on the - * `io.modelcontextprotocol/tasks` extension. The tests are kept, not deleted, - * as the record of the behavior Part 5 restores. The v1 client surface they - * drive (`client.experimental.tasks`, `InMemoryTaskStore`, task-returning - * request handlers) is typed by the stand-ins below so the bodies still - * compile; calling any of them throws. + * These replace the Part 1 (#4854) tests of the same tools on the 2025-11-25 + * experimental tasks layer, which SDK v2 removed. Two behaviors those tests + * pinned are gone with the 2025 era rather than ported: the async tools + * sending the *client* a task-augmented request and polling the client's + * tasks (2026-07-28 has no server-to-client requests, so the server's task + * carries the request in its `input_required` status instead), and + * `tasks/result` and `tasks/list`, which the extension does not define. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { - CallToolResult, - Client, - ElicitRequest, - ElicitResult, - GetTaskResult, -} from "@modelcontextprotocol/client"; -import { CallToolResultSchema } from "@modelcontextprotocol/core"; -import { connect, contentOf, textOf, type Session } from "./harness.js"; - -/** A task, as the v1 tasks API reported it. */ -type Task = GetTaskResult; - -/** One message of v1's `callToolStream`. */ -type TaskStreamMessage = - | { type: "taskCreated"; task: Task } - | { type: "taskStatus"; task: Task } - | { type: "result"; result: CallToolResult } - | { type: "error"; error: Error }; - -const REMOVED = - "SDK v2 removed the experimental tasks API (SEP-2663); see #4852"; - -/** Stand-in for v1's `client.experimental.tasks`. */ -function experimentalTasks(_client: Client): { - callToolStream: ( - params: { name: string; arguments: Record }, - resultSchema: unknown, - ) => AsyncGenerator; - listTasks: () => Promise<{ tasks: Task[] }>; - cancelTask: (taskId: string) => Promise; - getTask: (taskId: string) => Promise; -} { - throw new Error(REMOVED); -} +import { clearTasks, TASKS_EXTENSION } from "../server/tasks.js"; +import { STAGE_DURATION, STAGES } from "../tools/simulate-research-query.js"; +import { connectRaw as rawClient, type RawSession } from "./harness.js"; -/** Stand-in for v1's client-side `InMemoryTaskStore`. */ -class InMemoryTaskStore { - constructor() { - throw new Error(REMOVED); - } - createTask( - _params: { ttl?: number }, - _requestId: number, - _request: unknown, - ): Promise { - throw new Error(REMOVED); - } - getTask(_taskId: string): Promise { - throw new Error(REMOVED); - } - storeTaskResult( - _taskId: string, - _status: "completed" | "failed", - _result: Record, - ): Promise { - throw new Error(REMOVED); - } - updateTaskStatus( - _taskId: string, - _status: Task["status"], - _statusMessage?: string, - ): Promise { - throw new Error(REMOVED); - } -} +type Json = Record; +type Peer = RawSession; -/** - * Stand-in for a v1 client request handler that answers a task-augmented - * request with a `CreateTaskResult`, which v2's typed handlers do not allow. - */ -function setTaskRequestHandler< - M extends "sampling/createMessage" | "elicitation/create", ->( - _client: Client, - _method: M, - _handler: (request: { - params: { task?: { ttl?: number } }; - }) => Promise<{ task: Task }>, -): void { - throw new Error(REMOVED); -} +const TASKS = { extensions: { [TASKS_EXTENSION]: {} } }; +const WITH_ELICITATION = { ...TASKS, elicitation: { form: {} } }; -let session: Session | undefined; +let peer: Peer; beforeEach(() => { - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date("2026-10-11T12:00:00Z")); }); afterEach(async () => { + await peer?.close(); + clearTasks(); vi.useRealTimers(); - vi.restoreAllMocks(); - await session?.close(); - session = undefined; }); -/** Advance the fake clock until `promise` settles (or `limitMs` passes). */ -async function settle(promise: Promise, limitMs = 30_000): Promise { - let settled = false; - const tracked = promise.finally(() => { - settled = true; - }); - tracked.catch(() => {}); // observed below - for (let t = 0; t < limitMs && !settled; t += 100) { - await vi.advanceTimersByTimeAsync(100); - } - if (!settled) throw new Error(`did not settle within ${limitMs} ms`); - return tracked; +/** Move the clock on by `ms`. */ +function advance(ms: number) { + vi.setSystemTime(Date.now() + ms); } -/** Collect every message of an async iterable. */ -async function collect(stream: AsyncIterable): Promise { - const out: T[] = []; - for await (const message of stream) out.push(message); - return out; +/** The text of a completed task's result. */ +function resultText(task: Json): string { + const result = task.result as { content: { text: string }[] }; + return result.content.map((block) => block.text).join("\n"); } -describe("simulate-research-query", () => { - /** Run the tool as a task to completion and return the stream's messages. */ - async function research(args: Record) { - const s = session!; - await s.client.listTools(); // lets callToolStream see taskSupport - return settle( - collect( - experimentalTasks(s.client).callToolStream( - { name: "simulate-research-query", arguments: args }, - CallToolResultSchema, - ), - ), - ); - } - - function statusMessages(messages: Awaited>) { - return messages - .filter((m) => m.type === "taskStatus") - .map((m) => (m.type === "taskStatus" ? m.task.statusMessage : "")); - } +describe("the extension", () => { + it("is advertised in server/discover", async () => { + peer = await rawClient(TASKS); + const discover = await peer.result("server/discover"); + expect(discover.capabilities).toMatchObject({ + extensions: { [TASKS_EXTENSION]: {} }, + }); + }); - function report(messages: Awaited>): string { - const last = messages.at(-1); - if (last?.type !== "result") throw new Error(`no result: ${last?.type}`); - return textOf(contentOf(last.result)[0]); - } + it.each([ + ["tasks/get", {}], + ["tasks/update", { inputResponses: {} }], + ["tasks/cancel", {}], + ])("refuses %s for an unknown task with -32602", async (method, extra) => { + peer = await rawClient(TASKS); + const response = await peer.request(method, { taskId: "nope", ...extra }); + expect(response.error).toMatchObject({ + code: -32602, + message: expect.stringContaining("Unknown task: nope"), + }); + }); - it.skip("creates a task that works through four stages and completes with a report", async () => { - session = await connect(); - const messages = await research({ topic: "tides" }); + it("drops a task once its TTL has passed", async () => { + peer = await rawClient(TASKS); + const created = await peer.call("simulate-research-query", { topic: "t" }); + advance(created.ttlMs as number); + const response = await peer.request("tasks/get", { + taskId: created.taskId, + }); + expect(response.error).toMatchObject({ code: -32602 }); + }); +}); - expect(messages[0]).toMatchObject({ - type: "taskCreated", - task: { status: "working", ttl: 300000, pollInterval: 1000 }, +describe("simulate-research-query", () => { + it("returns a task handle, then works through four stages and completes with a report", async () => { + peer = await rawClient(TASKS); + const created = await peer.call("simulate-research-query", { + topic: "MCP", }); - expect(statusMessages(messages)).toEqual([ - "Gathering sources...", + expect(created).toMatchObject({ + resultType: "task", + status: "working", + statusMessage: "Gathering sources...", + ttlMs: 300000, + pollIntervalMs: STAGE_DURATION, + }); + expect(created.taskId).toMatch(/^[0-9a-f-]{36}$/); + expect(created.createdAt).toBe("2026-10-11T12:00:00.000Z"); + + const messages = []; + for (let stage = 1; stage < STAGES.length; stage++) { + advance(STAGE_DURATION); + messages.push((await peer.get(created.taskId as string)).statusMessage); + } + expect(messages).toEqual([ "Analyzing content...", "Synthesizing findings...", "Generating report...", - // The completed task keeps the last stage's status message. - "Generating report...", ]); - expect(report(messages)).toMatchInlineSnapshot(` - "# Research Report: tides - - ## Research Parameters - - **Topic**: tides - - - ## Synthesis - This research query was processed through 4 stages: - - Stage 1: Gathering sources ✓ - - Stage 2: Analyzing content ✓ - - Stage 3: Synthesizing findings ✓ - - Stage 4: Generating report ✓ - - --- - - ## About This Demo (SEP-1686: Tasks) - This tool demonstrates MCP's task-based execution pattern for long-running operations: - - **Task Lifecycle Demonstrated:** - 1. \`tools/call\` with \`task\` parameter → Server returns \`CreateTaskResult\` (not the final result) - 2. Client polls \`tasks/get\` → Server returns current status and \`statusMessage\` - 3. Status progressed: \`working\` → \`completed\` - 4. Client calls \`tasks/result\` → Server returns this final result - - - **Key Concepts:** - - Tasks enable "call now, fetch later" patterns - - \`statusMessage\` provides human-readable progress updates - - Tasks have TTL (time-to-live) for automatic cleanup - - \`pollInterval\` suggests how often to check status - - Elicitation requests use \`relatedTask\` to queue via tasks/result (works on all transports) - - *This is a simulated research report from the Everything MCP Server.* - " - `); + advance(STAGE_DURATION); + const done = await peer.get(created.taskId as string); + expect(done).toMatchObject({ + status: "completed", + statusMessage: "Research complete", + resultType: "complete", + lastUpdatedAt: new Date(Date.now()).toISOString(), + }); + expect(resultText(done)).toMatch(/^# Research Report: MCP\n/); + expect(resultText(done)).toContain("Stage 4: Generating report ✓"); }); - it.skip("is rejected as a tool error when called without a task", async () => { - session = await connect(); - const result = await session.client.callTool({ - name: "simulate-research-query", - arguments: { topic: "x" }, - }); - expect(result).toEqual({ + it("is a tool error for a client that does not declare the extension", async () => { + peer = await rawClient({}); + const result = await peer.call("simulate-research-query", { topic: "t" }); + expect(result).toMatchObject({ isError: true, content: [ { type: "text", - text: "MCP error -32601: Tool simulate-research-query requires task augmentation (taskSupport: 'required')", + text: expect.stringContaining( + `needs a client that declares the ${TASKS_EXTENSION} extension`, + ), }, ], }); }); - it.skip("ignores 'ambiguous' for a client without elicitation", async () => { - session = await connect(); - const messages = await research({ topic: "python", ambiguous: true }); - expect(statusMessages(messages)).not.toContain( - expect.stringContaining("Requesting clarification"), - ); - expect(report(messages)).toMatch(/^# Research Report: python\n/); + it("ignores 'ambiguous' for a client without elicitation", async () => { + peer = await rawClient(TASKS); + const created = await peer.call("simulate-research-query", { + topic: "t", + ambiguous: true, + }); + advance(STAGE_DURATION * STAGES.length); + expect((await peer.get(created.taskId as string)).status).toBe("completed"); }); describe("with an ambiguous topic and an elicitation-capable client", () => { - async function connectAnswering( - answer: (req: ElicitRequest) => ElicitResult, - ) { - const asked: ElicitRequest["params"][] = []; - session = await connect({ - capabilities: { elicitation: { form: {} } }, - setup: (client) => - client.setRequestHandler("elicitation/create", async (req) => { - asked.push(req.params); - return answer(req); - }), + /** Start an ambiguous query and run it to its clarification request. */ + async function toClarification(topic: string) { + peer = await rawClient(WITH_ELICITATION); + const created = await peer.call("simulate-research-query", { + topic, + ambiguous: true, }); - return asked; + const taskId = created.taskId as string; + advance(STAGE_DURATION * 2); + return { taskId, task: await peer.get(taskId) }; } - it.skip("asks for a clarification through tasks/result, then completes with it", async () => { - const asked = await connectAnswering(() => ({ - action: "accept", - content: { interpretation: "programming" }, - })); - const messages = await research({ topic: "Python", ambiguous: true }); - - expect(asked).toHaveLength(1); - expect(asked[0]).toMatchObject({ - message: - 'The research query "Python" could have multiple interpretations. Please clarify what you\'re looking for:', - requestedSchema: { - type: "object", - properties: { - interpretation: { - type: "string", - title: "Clarification", - description: "Which interpretation of the topic do you mean?", - oneOf: [ - { const: "programming", title: "Python programming language" }, - { const: "snake", title: "Python snake species" }, - { const: "comedy", title: "Monty Python comedy group" }, - ], + it("waits for a clarification with tasks/update, then completes with it", async () => { + const { taskId, task } = await toClarification("python"); + expect(task).toMatchObject({ + status: "input_required", + statusMessage: + 'Found multiple interpretations for "python". Requesting clarification...', + }); + const request = (task.inputRequests as Json).clarification as Json; + expect(request).toMatchObject({ + method: "elicitation/create", + params: { mode: "form" }, + }); + const oneOf = ( + (request.params as Json).requestedSchema as { + properties: { interpretation: { oneOf: unknown[] } }; + } + ).properties.interpretation.oneOf; + expect(oneOf).toEqual([ + { const: "programming", title: "Python programming language" }, + { const: "snake", title: "Python snake species" }, + { const: "comedy", title: "Monty Python comedy group" }, + ]); + + // Still waiting, however long it takes. + advance(STAGE_DURATION * 10); + expect((await peer.get(taskId)).status).toBe("input_required"); + + expect( + await peer.result("tasks/update", { + taskId, + inputResponses: { + clarification: { + action: "accept", + content: { interpretation: "programming" }, }, }, - required: ["interpretation"], - }, + }), + ).toMatchObject({ resultType: "complete" }); + expect(await peer.get(taskId)).toMatchObject({ + status: "working", + statusMessage: 'Continuing with interpretation: "programming"...', }); - expect(statusMessages(messages)).toContain( - 'Found multiple interpretations for "Python". Requesting clarification...', - ); - const text = report(messages); - expect(text).toMatch(/^# Research Report: Python \(programming\)\n/); - expect(text).toContain("- **Clarification**: programming"); - expect(text).toContain( - 'After receiving clarification ("programming"), the task resumed processing and completed.', + + advance(STAGE_DURATION * 2); + const done = await peer.get(taskId); + expect(done.status).toBe("completed"); + expect(resultText(done)).toMatch( + /^# Research Report: python \(programming\)\n/, ); + expect(resultText(done)).toContain("answered it with `tasks/update`"); }); - it.skip("offers generic interpretations for other topics", async () => { - const asked = await connectAnswering(() => ({ action: "decline" })); - await research({ topic: "Mercury", ambiguous: true }); - const params = asked[0]; - if (!("requestedSchema" in params)) throw new Error("expected form mode"); - expect(params.requestedSchema.properties.interpretation).toHaveProperty( - "oneOf", - [ - { const: "technical", title: "Technical/scientific perspective" }, - { const: "historical", title: "Historical perspective" }, - { const: "current", title: "Current events/news perspective" }, - ], - ); + it("offers generic interpretations for other topics", async () => { + const { task } = await toClarification("climate"); + const request = (task.inputRequests as Json).clarification as { + params: { + requestedSchema: { + properties: { interpretation: { oneOf: { const: string }[] } }; + }; + }; + }; + expect( + request.params.requestedSchema.properties.interpretation.oneOf.map( + (option) => option.const, + ), + ).toEqual(["technical", "historical", "current"]); }); - it.skip.each([ - [{ action: "decline" }, "User declined - using default interpretation"], - [{ action: "cancel" }, "User cancelled - using default interpretation"], - [{ action: "accept", content: {} }, "User accepted without selection"], - // An accept with no content is treated as a cancel. - [{ action: "accept" }, "User cancelled - using default interpretation"], - ] as [ElicitResult, string][])( - "records %j as %j", - async (answer, clarification) => { - await connectAnswering(() => answer); - const messages = await research({ topic: "tides", ambiguous: true }); - expect(report(messages)).toContain( + it.each([ + ["decline", "User declined - using default interpretation"], + ["cancel", "User cancelled - using default interpretation"], + ])( + "continues with a default when the user chooses %s", + async (action, clarification) => { + const { taskId } = await toClarification("t"); + await peer.result("tasks/update", { + taskId, + inputResponses: { clarification: { action } }, + }); + advance(STAGE_DURATION * 2); + expect(resultText(await peer.get(taskId))).toContain( `- **Clarification**: ${clarification}`, ); }, ); - it.skip("falls back to a default interpretation when the elicitation fails", async () => { - const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); - await connectAnswering(() => { - throw new Error("no UI"); + it("continues with a default when the answer is not usable", async () => { + const { taskId } = await toClarification("t"); + await peer.result("tasks/update", { + taskId, + inputResponses: { clarification: { action: "accept", content: {} } }, }); - const messages = await research({ topic: "tides", ambiguous: true }); - const text = report(messages); - expect(text).toContain( - "- **Clarification**: technical (default - elicitation unavailable)", - ); - expect(text).toContain( - "**Note:** Elicitation failed and a default interpretation was used.", - ); - expect(warn).toHaveBeenCalledWith( - expect.stringMatching(/^Elicitation failed for task /), - expect.stringContaining("no UI"), + advance(STAGE_DURATION * 2); + expect(resultText(await peer.get(taskId))).toContain( + "No usable answer - using default interpretation", ); }); - }); - it.skip("lists its task, and stops working on it once the client cancels it", async () => { - const errors = vi.spyOn(console, "error").mockImplementation(() => {}); - session = await connect(); - const { client } = session; - await client.listTools(); - const stream = experimentalTasks(client).callToolStream( - { name: "simulate-research-query", arguments: { topic: "tides" } }, - CallToolResultSchema, - ); - const first = await stream.next(); - if (first.done || first.value.type !== "taskCreated") { - throw new Error("expected taskCreated"); - } - const { taskId } = first.value.task; - await stream.return(undefined); - - const { tasks } = await experimentalTasks(client).listTasks(); - expect(tasks.map((t) => t.taskId)).toContain(taskId); - - await experimentalTasks(client).cancelTask(taskId); - // The next stage's status update fails on the cancelled task; the - // failure is logged, marking it failed fails too, and it stays cancelled. - await vi.advanceTimersByTimeAsync(5000); - expect(errors).toHaveBeenCalledWith( - `Research task ${taskId} failed:`, - expect.any(Error), - ); - expect(await experimentalTasks(client).getTask(taskId)).toMatchObject({ - status: "cancelled", - statusMessage: "Client cancelled task execution.", + it("refuses an answer to a request it did not make", async () => { + const { taskId } = await toClarification("t"); + const response = await peer.request("tasks/update", { + taskId, + inputResponses: { other: { action: "accept" } }, + }); + expect(response.error).toMatchObject({ + code: -32602, + message: expect.stringContaining( + "has no outstanding input request other; it is waiting for clarification", + ), + }); }); }); -}); - -/** - * A client that declares sampling and elicitation, with task support for - * both, and keeps the tasks it creates in its own store. - */ -async function connectTaskClient( - onRequest: ( - method: "sampling" | "elicitation", - taskId: string | undefined, - ) => void, - extraTtl = 0, -) { - const taskStore = new InMemoryTaskStore(); - let requestNumber = 0; - const createTask = async ( - method: "sampling" | "elicitation", - request: Parameters[2], - ttl: number | undefined, - ) => { - const task = await taskStore.createTask( - { ttl: ttl === undefined ? undefined : ttl + extraTtl }, - ++requestNumber, - request, - ); - onRequest(method, task.taskId); - return { task }; - }; - session = await connect({ - capabilities: { - sampling: {}, - elicitation: { form: {} }, - tasks: { - list: {}, - cancel: {}, - requests: { - sampling: { createMessage: {} }, - elicitation: { create: {} }, - }, - }, - }, - setup: (client) => { - setTaskRequestHandler(client, "sampling/createMessage", async (req) => - createTask("sampling", req, req.params.task?.ttl), - ); - setTaskRequestHandler(client, "elicitation/create", async (req) => - createTask("elicitation", req, req.params.task?.ttl), - ); - }, - }); - return { client: session.client, taskStore }; -} - -async function callAsync(name: string, args: Record = {}) { - const result = await settle( - session!.client.callTool( - { name, arguments: args }, - { - timeout: 3_600_000, - }, - ), - 700_000, - ); - return contentOf(result).map(textOf); -} - -/** Run `fn` half a second from now, before the server's first one-second poll. */ -function later(fn: () => Promise): void { - setTimeout(() => void fn(), 500); -} - -const SAMPLE = { - role: "assistant" as const, - content: { type: "text" as const, text: "sampled" }, - model: "test-model", -}; -describe("trigger-sampling-request-async", () => { - it.skip("sends a task-augmented sampling request, polls the client's task, and returns its result", async () => { - let created = ""; - const { taskStore } = await connectTaskClient((_method, taskId) => { - created = taskId!; - // Finish the client-side task before the server's first poll. - later(() => taskStore.storeTaskResult(taskId!, "completed", SAMPLE)); + it("refuses tasks/update while the task is working", async () => { + peer = await rawClient(TASKS); + const created = await peer.call("simulate-research-query", { topic: "t" }); + const response = await peer.request("tasks/update", { + taskId: created.taskId, + inputResponses: {}, }); - const [text] = await callAsync("trigger-sampling-request-async", { - prompt: "hello", - maxTokens: 5, + expect(response.error).toMatchObject({ + code: -32602, + message: expect.stringContaining("is working, not waiting for input"), }); - const task = await taskStore.getTask(created); - expect(task?.ttl).toBe(300000); - expect(text).toBe( - `[COMPLETED] Async sampling completed!\n\n**Progress:**\nTask created: ${created}\nPoll 1: completed\n\n**Result:**\n${JSON.stringify( - { - ...SAMPLE, - _meta: { - "io.modelcontextprotocol/related-task": { taskId: created }, - }, - }, - null, - 2, - )}`, - ); }); - it.skip("reports a failed client task with its status message", async () => { - const { taskStore } = await connectTaskClient((_m, taskId) => { - later(() => - taskStore.updateTaskStatus(taskId!, "failed", "model overloaded"), - ); + it("stays cancelled once the client cancels it, and refuses a second cancel", async () => { + peer = await rawClient(TASKS); + const created = await peer.call("simulate-research-query", { topic: "t" }); + const taskId = created.taskId as string; + expect(await peer.result("tasks/cancel", { taskId })).toMatchObject({ + resultType: "complete", }); - const [text] = await callAsync("trigger-sampling-request-async", { - prompt: "p", + advance(STAGE_DURATION * STAGES.length); + expect(await peer.get(taskId)).toMatchObject({ + status: "cancelled", + statusMessage: "Cancelled by the client", + }); + const again = await peer.request("tasks/cancel", { taskId }); + expect(again.error).toMatchObject({ + code: -32602, + message: expect.stringContaining("is already cancelled"), }); - expect(text).toMatch( - /^\[FAILED\] model overloaded\n\nProgress:\nTask created: \S+\nPoll 1: failed - model overloaded$/, - ); }); - it.skip("reports the status message of a client task that is already finished when it is created", async () => { - // A task that fails before it is returned is never polled, so its status - // message comes from the CreateTaskResult. - const { taskStore } = await connectTaskClient((_m, taskId) => { - void taskStore.updateTaskStatus(taskId!, "failed", "instant failure"); + it("refuses to cancel a completed task", async () => { + peer = await rawClient(TASKS); + const created = await peer.call("simulate-research-query", { topic: "t" }); + advance(STAGE_DURATION * STAGES.length); + const response = await peer.request("tasks/cancel", { + taskId: created.taskId, }); - const [text] = await callAsync("trigger-sampling-request-async", { - prompt: "p", + expect(response.error).toMatchObject({ + code: -32602, + message: expect.stringContaining("is already completed"), }); - expect(text).toMatch( - /^\[FAILED\] instant failure\n\nProgress:\nTask created: \S+$/, - ); }); +}); - it.skip("reports a cancelled client task with no message", async () => { - const { taskStore } = await connectTaskClient((_m, taskId) => { - later(() => taskStore.updateTaskStatus(taskId!, "cancelled")); +describe("trigger-sampling-request-async", () => { + it("waits with a sampling request, then completes with the client's answer", async () => { + peer = await rawClient({ ...TASKS, sampling: {} }); + const created = await peer.call("trigger-sampling-request-async", { + prompt: "Say hi", }); - const [text] = await callAsync("trigger-sampling-request-async", { - prompt: "p", + expect(created).toMatchObject({ + resultType: "task", + status: "input_required", + statusMessage: "Waiting for the client's LLM to respond", + ttlMs: 600000, + inputRequests: { + sampling: { + method: "sampling/createMessage", + params: { + maxTokens: 100, + systemPrompt: "You are a helpful test server.", + messages: [ + { + role: "user", + content: { + type: "text", + text: "Resource trigger-sampling-request-async context: Say hi", + }, + }, + ], + }, + }, + }, + }); + const taskId = created.taskId as string; + await peer.result("tasks/update", { + taskId, + inputResponses: { + sampling: { + role: "assistant", + model: "test-model", + content: { type: "text", text: "hi" }, + }, + }, }); - expect(text).toMatch(/^\[CANCELLED\] No message\n\nProgress:\n/); + const done = await peer.get(taskId); + expect(done.status).toBe("completed"); + expect(resultText(done)).toMatch(/^LLM sampling result: \n/); + expect(resultText(done)).toContain('"text": "hi"'); }); - it.skip("gives up after 60 polls of a task that never finishes", async () => { - await connectTaskClient(() => {}); - const [text] = await callAsync("trigger-sampling-request-async", { + it("keeps waiting after an answer of the wrong kind", async () => { + peer = await rawClient({ ...TASKS, sampling: {} }); + const created = await peer.call("trigger-sampling-request-async", { prompt: "p", }); - expect(text).toMatch( - /^\[TIMEOUT\] Task timed out after 60 poll attempts\n\nProgress:\nTask created: \S+\nPoll 1: working\n/, - ); - expect(text.split("\n").filter((l) => l.startsWith("Poll "))).toHaveLength( - 60, + await peer.result("tasks/update", { + taskId: created.taskId, + inputResponses: { sampling: { action: "accept" } }, + }); + expect((await peer.get(created.taskId as string)).status).toBe( + "input_required", ); }); - it.skip("returns a synchronous answer from a client that ignores the task request", async () => { - session = await connect({ - capabilities: { - sampling: {}, - tasks: { requests: { sampling: { createMessage: {} } } }, - }, - setup: (client) => { - // A client that answers outside the SDK's sampling handler, which - // would insist on a CreateTaskResult for a task-augmented request. - client.fallbackRequestHandler = async () => SAMPLE; - }, - }); - const [text] = await callAsync("trigger-sampling-request-async", { + it("is a tool error for a client that does not declare the extension", async () => { + peer = await rawClient({ sampling: {} }); + const result = await peer.call("trigger-sampling-request-async", { prompt: "p", }); - expect(text).toBe( - `[SYNC] Client executed synchronously:\n${JSON.stringify(SAMPLE, null, 2)}`, - ); + expect(result.isError).toBe(true); }); }); describe("trigger-elicitation-request-async", () => { - it.skip("sends a task-augmented elicitation and summarizes the accepted answer", async () => { - const asked: string[] = []; - const { taskStore } = await connectTaskClient((method, taskId) => { - asked.push(method); - later(() => - taskStore.storeTaskResult(taskId!, "completed", { - action: "accept", - content: { name: "Ada", favoriteColor: "Blue", agreeToTerms: true }, - }), - ); - }); - const texts = await callAsync("trigger-elicitation-request-async"); - expect(asked).toEqual(["elicitation"]); - expect(texts[0]).toBe( - "[COMPLETED] User provided the requested information!", - ); - expect(texts[1]).toBe( - "User inputs:\n- Name: Ada\n- Favorite Color: Blue\n- Agreed to terms: true", - ); - expect(texts[2]).toMatch( - /^\nProgress:\nTask created: \S+\nPoll 1: completed\n\nRaw result: \{/, - ); - }); - - it.skip("summarizes an accepted answer with no fields", async () => { - const { taskStore } = await connectTaskClient((_m, taskId) => { - later(() => - taskStore.storeTaskResult(taskId!, "completed", { - action: "accept", - content: {}, - }), - ); - }); - const texts = await callAsync("trigger-elicitation-request-async"); - expect(texts.slice(0, 2)).toEqual([ - "[COMPLETED] User provided the requested information!", - "User inputs:\n", - ]); - }); - - it.skip.each([ - [ - "decline", - "[DECLINED] User declined to provide the requested information.", - ], - ["cancel", "[CANCELLED] User cancelled the elicitation dialog."], - ])("reports a %s", async (action, expected) => { - const { taskStore } = await connectTaskClient((_m, taskId) => { - later(() => taskStore.storeTaskResult(taskId!, "completed", { action })); + async function answer(response: Json) { + peer = await rawClient(WITH_ELICITATION); + const created = await peer.call("trigger-elicitation-request-async"); + expect(created).toMatchObject({ + status: "input_required", + statusMessage: "Waiting for the user's input", + inputRequests: { + form: { + method: "elicitation/create", + params: { mode: "form", requestedSchema: { required: ["name"] } }, + }, + }, }); - const texts = await callAsync("trigger-elicitation-request-async"); - expect(texts[0]).toBe(expected); - }); - - it.skip("reports only progress and the raw result for an answer of another shape", async () => { - const { taskStore } = await connectTaskClient((_m, taskId) => { - later(() => - taskStore.storeTaskResult(taskId!, "completed", { action: "accept" }), - ); + const taskId = created.taskId as string; + await peer.result("tasks/update", { + taskId, + inputResponses: { form: response }, }); - const texts = await callAsync("trigger-elicitation-request-async"); - expect(texts).toHaveLength(1); - expect(texts[0]).toMatch(/^\nProgress:\n/); - }); + return peer.get(taskId); + } - it.skip("logs the first poll, every tenth, and every status change while input is required", async () => { - let taskId = ""; - const { taskStore } = await connectTaskClient((_m, id) => { - taskId = id!; - later(() => taskStore.updateTaskStatus(id!, "input_required", "waiting")); + it("summarizes an accepted answer", async () => { + const done = await answer({ + action: "accept", + content: { name: "Ada", favoriteColor: "blue", agreeToTerms: true }, }); - const call = callAsync("trigger-elicitation-request-async"); - // Let 21 polls happen while input is required, then finish. - await vi.advanceTimersByTimeAsync(21_500); - await taskStore.storeTaskResult(taskId, "completed", { action: "decline" }); - const texts = await call; - const polls = texts[1].split("\n").filter((l) => l.startsWith("Poll ")); - expect(polls.slice(0, 3)).toEqual([ - "Poll 1: input_required - waiting", - "Poll 10: input_required - waiting", - "Poll 20: input_required - waiting", - ]); - // The completed status keeps the earlier message. - expect(polls.at(-1)).toMatch(/^Poll 2\d: completed - waiting$/); - expect(polls).toHaveLength(4); - }); - - it.skip("reports the status message of a client task that is already finished when it is created", async () => { - // A task that fails before it is returned is never polled, so its status - // message comes from the CreateTaskResult. - const { taskStore } = await connectTaskClient((_m, taskId) => { - void taskStore.updateTaskStatus(taskId!, "failed", "instant failure"); + expect(done).toMatchObject({ + status: "completed", + statusMessage: "Input received", }); - const texts = await callAsync("trigger-elicitation-request-async"); - expect(texts).toEqual([ - expect.stringMatching( - /^\[FAILED\] instant failure\n\nProgress:\nTask created: \S+$/, - ), - ]); + expect(resultText(done)).toBe( + "Elicitation accept.\nName: Ada\nFavorite color: blue\nAgreed to terms: true", + ); }); - it.skip("reports a failed client task", async () => { - const { taskStore } = await connectTaskClient((_m, taskId) => { - later(() => taskStore.updateTaskStatus(taskId!, "failed")); - }); - const texts = await callAsync("trigger-elicitation-request-async"); - expect(texts[0]).toMatch(/^\[FAILED\] No message\n\nProgress:\n/); + it("summarizes an accepted answer with no fields", async () => { + const done = await answer({ action: "accept", content: {} }); + expect(resultText(done)).toBe("Elicitation accept."); }); - /** Also fake `performance`, which the tool's polling deadline is measured with. */ - function fakeClock() { - vi.useFakeTimers({ - toFake: ["setTimeout", "clearTimeout", "performance"], - }); - } - - it.skip("gives up 5 seconds before the 10-minute TTL of a task that never finishes", async () => { - fakeClock(); - // A client that keeps its task longer than the 10-minute TTL asked for. - await connectTaskClient(() => {}, 60_000); - const texts = await callAsync("trigger-elicitation-request-async"); - expect(texts[0]).toMatch( - /^\[TIMEOUT\] Task timed out after 595 poll attempts, before its 10-minute TTL expired\n\nProgress:\n/, - ); + it.each(["decline", "cancel"])("reports a %s", async (action) => { + const done = await answer({ action }); + expect(resultText(done)).toBe(`Elicitation ${action}.`); }); - it.skip("times out before a client that honors the 10-minute TTL expires the task", async () => { - fakeClock(); - let created = ""; - const { taskStore } = await connectTaskClient((_m, taskId) => { - created = taskId!; + it("keeps waiting after an answer of the wrong kind", async () => { + peer = await rawClient(WITH_ELICITATION); + const created = await peer.call("trigger-elicitation-request-async"); + await peer.result("tasks/update", { + taskId: created.taskId, + inputResponses: { form: { roots: [] } }, }); - const texts = await callAsync("trigger-elicitation-request-async"); - // The last poll reached the task while the client still held it. - expect(texts).toHaveLength(1); - expect(texts[0]).toMatch( - /^\[TIMEOUT\] Task timed out after 595 poll attempts, before its 10-minute TTL expired\n\nProgress:\nTask created: \S+\nPoll 1: working\n/, + expect((await peer.get(created.taskId as string)).status).toBe( + "input_required", ); - expect(texts[0]).toMatch(/\nPoll 595: working$/); - // The client still holds the task when the tool gives up, 5 s before the - // 600000 ms TTL it asked for runs out. - expect((await taskStore.getTask(created))?.ttl).toBe(600000); - }); - - it.skip("times out without polling when its wait ends past the deadline", async () => { - // A timer that fires late: the first reading sets the deadline, and every - // later one is already past it. - let readings = 0; - vi.spyOn(performance, "now").mockImplementation(() => - readings++ === 0 ? 0 : 600_000, - ); - await connectTaskClient(() => {}); - const texts = await callAsync("trigger-elicitation-request-async"); - expect(texts).toEqual([ - expect.stringMatching( - /^\[TIMEOUT\] Task timed out after 0 poll attempts, before its 10-minute TTL expired\n\nProgress:\nTask created: \S+$/, - ), - ]); }); - it.skip("returns a synchronous answer from a client that ignores the task request", async () => { - session = await connect({ - capabilities: { - elicitation: {}, - tasks: { requests: { elicitation: { create: {} } } }, - }, - setup: (client) => { - client.fallbackRequestHandler = async () => ({ action: "cancel" }); - }, - }); - const texts = await callAsync("trigger-elicitation-request-async"); - expect(texts).toEqual([ - `[SYNC] Client executed synchronously:\n${JSON.stringify( - { action: "cancel" }, - null, - 2, - )}`, - ]); + it("is a tool error for a client that does not declare the extension", async () => { + peer = await rawClient({ elicitation: {} }); + const result = await peer.call("trigger-elicitation-request-async"); + expect(result.isError).toBe(true); }); }); diff --git a/src/everything/resources/subscriptions.ts b/src/everything/resources/subscriptions.ts index ef29d60cc4..f69f5308b2 100644 --- a/src/everything/resources/subscriptions.ts +++ b/src/everything/resources/subscriptions.ts @@ -27,20 +27,26 @@ export const setSubscriptionHandlers = ( ) => { server.server.setRequestHandler("resources/subscribe", async (request) => { const { uri } = request.params; - await server.sendLoggingMessage({ - level: "info", - data: `Received Subscribe Resource request for URI: ${uri}`, - }); + await server.sendLoggingMessage( + { + level: "info", + data: `Received Subscribe Resource request for URI: ${uri}`, + }, + server.server.transport?.sessionId, + ); subscribed.add(uri); return {}; }); server.server.setRequestHandler("resources/unsubscribe", async (request) => { const { uri } = request.params; - await server.sendLoggingMessage({ - level: "info", - data: `Received Unsubscribe Resource request: ${uri}`, - }); + await server.sendLoggingMessage( + { + level: "info", + data: `Received Unsubscribe Resource request: ${uri}`, + }, + server.server.transport?.sessionId, + ); subscribed.delete(uri); return {}; }); diff --git a/src/everything/server/logging.ts b/src/everything/server/logging.ts index 5d6f26afe6..4c9002f1e8 100644 --- a/src/everything/server/logging.ts +++ b/src/everything/server/logging.ts @@ -37,7 +37,12 @@ export const beginSimulatedLogging = (server: McpServer): (() => void) => { // logged rather than left to become an unhandled rejection. const send = () => server - .sendLoggingMessage(MESSAGES[Math.floor(Math.random() * MESSAGES.length)]) + .sendLoggingMessage( + MESSAGES[Math.floor(Math.random() * MESSAGES.length)], + // The SDK keeps the level the client set per transport session, so + // the filter is looked up by this connection's session id. + server.server.transport?.sessionId, + ) .catch((error: unknown) => console.error("Simulated logging message failed:", error), ); diff --git a/src/everything/server/roots.ts b/src/everything/server/roots.ts index c9d8c05bf1..24f5e5633d 100644 --- a/src/everything/server/roots.ts +++ b/src/everything/server/roots.ts @@ -31,11 +31,14 @@ export const syncRoots = async (server: McpServer): Promise => { const requestRoots = async () => { try { const response = await server.server.listRoots(); - await server.sendLoggingMessage({ - level: "info", - logger: "everything-server", - data: `Roots updated: ${response.roots.length} root(s) received from client`, - }); + await server.sendLoggingMessage( + { + level: "info", + logger: "everything-server", + data: `Roots updated: ${response.roots.length} root(s) received from client`, + }, + server.server.transport?.sessionId, + ); } catch (error) { console.error( `Failed to request roots from client: ${ diff --git a/src/everything/server/tasks.ts b/src/everything/server/tasks.ts index 277203bf70..71d7e2780e 100644 --- a/src/everything/server/tasks.ts +++ b/src/everything/server/tasks.ts @@ -28,7 +28,6 @@ import { ProtocolErrorCode, type CallToolResult, type InputRequests, - type InputResponses, type ServerContext, } from "@modelcontextprotocol/server"; import { z } from "zod"; @@ -44,6 +43,14 @@ export type TaskStatus = | "failed" | "cancelled"; +/** + * The client's answers to a task's input requests, by key, as the SDK hands + * them to a handler. + */ +export type TaskInputResponses = NonNullable< + ServerContext["mcpReq"]["inputResponses"] +>; + /** What a task kind decides about its task at one moment. */ export type TaskState = | { status: "working"; statusMessage: string } @@ -62,7 +69,7 @@ export type TaskBehavior = { /** The task's state at `now` (epoch milliseconds). */ stateAt: (now: number) => TaskState; /** Take the client's answers, sent with `tasks/update` at `now`. */ - receive: (responses: InputResponses, now: number) => void; + receive: (responses: TaskInputResponses, now: number) => void; }; /** A task in the store. */ @@ -213,12 +220,6 @@ const TaskIdParams = z.object({ taskId: z.string().describe("The task identifier"), }); -const UpdateParams = TaskIdParams.extend({ - inputResponses: z - .record(z.string(), z.unknown()) - .describe("Answers to the task's outstanding input requests, by key"), -}); - const TaskResult = z.looseObject({}); const EmptyResult = z.object({}); @@ -242,8 +243,12 @@ export function registerTasksExtension(server: McpServer): void { server.server.setRequestHandler( "tasks/update", - { params: UpdateParams, result: EmptyResult }, - async ({ taskId, inputResponses }) => { + { params: TaskIdParams, result: EmptyResult }, + async ({ taskId }, ctx) => { + // The SDK lifts `inputResponses` out of the params of every request + // (it is a reserved retry field on 2026-07-28) into the context. + const inputResponses: TaskInputResponses = + ctx.mcpReq.inputResponses ?? {}; const now = Date.now(); const task = find(taskId, now); const state = stateOf(task, now); @@ -263,7 +268,7 @@ export function registerTasksExtension(server: McpServer): void { `Task ${taskId} has no outstanding input request ${unknown.join(", ")}; it is waiting for ${outstanding.join(", ")}`, ); } - task.behavior.receive(inputResponses as InputResponses, now); + task.behavior.receive(inputResponses, now); return {}; }, ); diff --git a/src/everything/tools/simulate-research-query.ts b/src/everything/tools/simulate-research-query.ts index 8b2864a28b..77b687fa9a 100644 --- a/src/everything/tools/simulate-research-query.ts +++ b/src/everything/tools/simulate-research-query.ts @@ -20,7 +20,6 @@ import { acceptedContent, inputRequired, inputResponse, - type InputResponses, } from "@modelcontextprotocol/server"; import { z } from "zod"; import { @@ -28,6 +27,7 @@ import { clientSupportsTasks, createTask, tasksRequired, + type TaskInputResponses, type TaskState, } from "../server/tasks.js"; @@ -102,7 +102,7 @@ function getInterpretationsForTopic( } /** What the user's answer to the clarification request means. */ -function clarificationFrom(responses: InputResponses): string { +function clarificationFrom(responses: TaskInputResponses): string { const view = inputResponse(responses, "clarification"); const accepted = acceptedContent( responses, diff --git a/src/everything/transports/stdio.ts b/src/everything/transports/stdio.ts index dbc320e3c7..fbc24d4c78 100644 --- a/src/everything/transports/stdio.ts +++ b/src/everything/transports/stdio.ts @@ -75,19 +75,14 @@ export async function main( } /** - * Start the stdio server as the launcher does: announce it on stderr, run - * `main()` (on the process's stdio unless other streams are given), and exit 1 - * if it fails. + * Start the stdio server as the launcher does: announce it on stderr and run + * `main()` on the process's stdio unless other streams are given. A transport + * that fails, including one that cannot start, is reported on stderr. */ export async function startStdioServer( stdin?: Readable, stdout?: Writable, ): Promise { console.error("Starting default (STDIO) server..."); - try { - await main(stdin, stdout); - } catch (error) { - console.error("Server error:", error); - process.exit(1); - } + await main(stdin, stdout); } From 419200b8a4d1e66c294575930ac7be29d4b37330 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 11 Oct 2026 22:49:16 -0400 Subject: [PATCH 074/117] docs: show which feature belongs to which protocol era; changesets for #4852 - everything: README and docs mark each feature 2026-07-28, 2025-era or both, describe the per-era transports and factory, the tasks extension, minted resources and the deprecations; the instructions name the era-only tools. - AGENTS.md: the rule for serving both eras without session-keyed state. - Minor changesets for the four TypeScript servers. Part of #4852 Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .changeset/everything-modern-era.md | 5 + .changeset/filesystem-modern-era.md | 5 + .changeset/memory-modern-era.md | 5 + .changeset/sequentialthinking-modern-era.md | 5 + AGENTS.md | 8 ++ src/everything/AGENTS.md | 8 +- src/everything/README.md | 29 ++++-- src/everything/docs/architecture.md | 11 ++- src/everything/docs/extension.md | 8 +- src/everything/docs/features.md | 100 +++++++++++++------- src/everything/docs/how-it-works.md | 47 +++++---- src/everything/docs/startup.md | 61 ++++++------ src/everything/docs/structure.md | 83 ++++++++++++---- 13 files changed, 254 insertions(+), 121 deletions(-) create mode 100644 .changeset/everything-modern-era.md create mode 100644 .changeset/filesystem-modern-era.md create mode 100644 .changeset/memory-modern-era.md create mode 100644 .changeset/sequentialthinking-modern-era.md diff --git a/.changeset/everything-modern-era.md b/.changeset/everything-modern-era.md new file mode 100644 index 0000000000..e1b64ba1bb --- /dev/null +++ b/.changeset/everything-modern-era.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/server-everything": minor +--- + +Serve the 2026-07-28 protocol revision alongside the 2025 era (#4852), over stdio (`serveStdio`) and Streamable HTTP (one `/mcp` endpoint: `createMcpHandler` for 2026-07-28 requests, the existing sessionful transport for 2025-era clients). New for 2026-07-28 clients: `server/discover`, multi-round-trip requests (`input_required`) for the elicitation, sampling and roots tools, `subscriptions/listen` change notifications, cache hints (`ttlMs` / `cacheScope`), and the tasks extension (`io.modelcontextprotocol/tasks`, advertised under `capabilities.extensions`), on which `simulate-research-query`, `trigger-sampling-request-async` and `trigger-elicitation-request-async` return. New tools on both eras: `trigger-multi-step-elicitation` (`requestState` across rounds), `trigger-log-messages` (per-request log levels), `trigger-list-changed`, `trigger-resource-update`, and `get-header-params` (`x-mcp-header` and JSON Schema 2020-12 schemas). `toggle-simulated-logging` and `toggle-subscriber-updates` are offered to 2025-era clients only, labelled deprecated with the roots, sampling and logging tools. For 2025-era clients: `get-roots-list` asks the client for its roots on every call; a failed sampling, elicitation or roots request is reported as `Fulfilling input required by 'tools/call' failed: `; a read of a dynamic resource with an invalid id is answered `-32602` with the URI in `data`; and the toggle tools' messages and the subscription acknowledgements no longer name a session id. The HTTP+SSE transport prints a deprecation warning on start. diff --git a/.changeset/filesystem-modern-era.md b/.changeset/filesystem-modern-era.md new file mode 100644 index 0000000000..f4c18faff4 --- /dev/null +++ b/.changeset/filesystem-modern-era.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/server-filesystem": minor +--- + +Serve the 2026-07-28 protocol revision alongside the 2025 era on stdio, through the SDK's `serveStdio` entry (#4852). A 2026-07-28 connection has no `initialize` and no server-to-client `roots/list`, and Roots is deprecated as of that revision, so the command-line directories are its whole allow-list; a server started with none answers each tool call on such a connection with an error saying so. 2025-era clients keep the Roots behavior they had. The README now recommends command-line directories over Roots (#4844). diff --git a/.changeset/memory-modern-era.md b/.changeset/memory-modern-era.md new file mode 100644 index 0000000000..bf0591cf71 --- /dev/null +++ b/.changeset/memory-modern-era.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/server-memory": minor +--- + +Serve the 2026-07-28 protocol revision alongside the 2025 era on stdio, through the SDK's `serveStdio` entry (#4852). A 2026-07-28 client gets `notifications/resources/updated` for `memory://knowledge-graph` on a `subscriptions/listen` stream whose `resourceSubscriptions` name it; 2025-era clients still subscribe with `resources/subscribe`. diff --git a/.changeset/sequentialthinking-modern-era.md b/.changeset/sequentialthinking-modern-era.md new file mode 100644 index 0000000000..2b0ed4a37f --- /dev/null +++ b/.changeset/sequentialthinking-modern-era.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/server-sequential-thinking": minor +--- + +Serve the 2026-07-28 protocol revision alongside the 2025 era on stdio, through the SDK's `serveStdio` entry (#4852). The thought history lasts as long as the client's connection, on either era. diff --git a/AGENTS.md b/AGENTS.md index 9f90d72e78..56bce9effa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -594,6 +594,14 @@ would not, burns turns, and buries the result under no-op turns. `https://modelcontextprotocol.io/mcp`). For schema details use the versioned schemas (JSON and TypeScript) in [modelcontextprotocol/schema](https://github.com/modelcontextprotocol/modelcontextprotocol/tree/main/schema). +- **Protocol eras**: every TypeScript server serves both 2026-07-28 clients + and 2025-era clients from one factory: stdio through the SDK's + `serveStdio`, Streamable HTTP through `createMcpHandler`. Keep no state in + a map keyed by session id, since 2026-07-28 has no sessions: per-connection + state lives on the server instance, and state that must outlive a + 2026-07-28 request lives under a handle the server mints. Ask a client for + input (elicitation, sampling, roots) by returning `inputRequired(...)`, + which the SDK's legacy shim also serves to 2025-era clients. - **Transports**: stdio is the default and every server supports it. `everything` also serves Streamable HTTP and HTTP+SSE. **HTTP+SSE is deprecated**: keep what exists working, and never make it the default or the diff --git a/src/everything/AGENTS.md b/src/everything/AGENTS.md index 3e6e0f0422..cdf5449d4a 100644 --- a/src/everything/AGENTS.md +++ b/src/everything/AGENTS.md @@ -29,8 +29,9 @@ The server factory is `src/everything/server/index.ts` and registers all feature ### High-level -- Tools live under `src/everything/tools/` and are registered via `registerTools(server)`. -- Resources live under `src/everything/resources/` and are registered via `registerResources(server)`. +- The server serves both protocol eras (2026-07-28 and 2025-era); the factory is `createServer({ era })`, and what each era gets is wired in `server/index.ts` and `tools/index.ts`. +- Tools live under `src/everything/tools/` and are registered via `registerTools(server, context)` (both eras), `registerModernTools` (2026-07-28) or `registerLegacyTools` / `registerConditionalTools` (2025 era). +- Resources live under `src/everything/resources/` and are registered via `registerResources(server, context)`. - Prompts live under `src/everything/prompts/` and are registered via `registerPrompts(server)`. - Subscriptions and simulated update routines are under `src/everything/resources/subscriptions.ts`. - Logging helpers are under `src/everything/server/logging.ts`. @@ -39,7 +40,8 @@ The server factory is `src/everything/server/index.ts` and registers all feature ### When adding a new feature - Follow the existing file/module pattern in its folder (naming, exports, and registration function). -- Export a `registerX(server)` function that registers new items with the MCP SDK in the same style as existing ones. +- Export a `registerX(server)` function that registers new items with the MCP SDK in the same style as existing ones (taking the per-instance `context` too when it needs the era, the change publisher or per-connection state). +- Keep no state in module-level maps keyed by session id; see [Extension Points](docs/extension.md). - Wire your new module into the central index (e.g., update `tools/index.ts`, `resources/index.ts`, or `prompts/index.ts`). - Ensure schemas (for tools) are accurate JSON Schema and include helpful descriptions and examples. - Keep the docs in `src/everything/docs/` up to date if you add or modify noteworthy features. diff --git a/src/everything/README.md b/src/everything/README.md index e834d6d928..e226a1fda6 100644 --- a/src/everything/README.md +++ b/src/everything/README.md @@ -13,7 +13,14 @@ This MCP server attempts to exercise all the features of the MCP protocol. It is A complete list of the registered MCP primitives and other protocol features demonstrated can be found in the [Server Features](docs/features.md) document. -The task tools (`simulate-research-query`, `trigger-sampling-request-async` and `trigger-elicitation-request-async`) are not available for now: they were built on the TypeScript SDK's experimental tasks layer, which SDK v2 removed. They return on the MCP tasks extension in [#4852](https://github.com/modelcontextprotocol/servers/issues/4852). +## Protocol versions + +The server serves both protocol eras, over stdio and Streamable HTTP, from one process: + +- **2026-07-28** clients connect with `server/discover`, send their protocol version, identity and capabilities with each request, and have no session. The server demonstrates the revision's new features for them: multi-round-trip requests (`input_required`) with `requestState`, `subscriptions/listen` change notifications, per-request log levels, cache hints (`ttlMs` / `cacheScope`), the tasks extension (`io.modelcontextprotocol/tasks`) under the `extensions` capability, and `x-mcp-header` with JSON Schema 2020-12 tool schemas. +- **2025-era** clients connect with the `initialize` handshake, as before. Elicitation, sampling and roots are served to them from the same multi-round-trip handlers through the SDK's legacy shim. + +Roots, sampling and logging are deprecated as of 2026-07-28 and stay available, labelled deprecated. [Server Features](docs/features.md) marks which era each feature belongs to. ## Requirements @@ -91,22 +98,26 @@ On Windows, use: } ``` -## Running from source with [HTTP+SSE Transport](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse) (deprecated as of [2025-03-26](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports)) +## Run from source with [Streamable HTTP Transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) ```shell cd src/everything npm install -npm run start:sse +npm run start:streamableHttp ``` -## Run from source with [Streamable HTTP Transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) +The endpoint is `http://localhost:3001/mcp` (`PORT` overrides the port). It serves 2026-07-28 clients statelessly and 2025-era clients with sessions, from the same URL. + +## Running from source with [HTTP+SSE Transport](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse) (deprecated as of [2025-03-26](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports)) ```shell cd src/everything npm install -npm run start:streamableHttp +npm run start:sse ``` +The HTTP+SSE transport serves 2025-era clients only, and the server prints a deprecation warning when it starts. Prefer Streamable HTTP. + ## Running as an installed package ### Install @@ -127,16 +138,16 @@ npx @modelcontextprotocol/server-everything npx @modelcontextprotocol/server-everything stdio ``` -### Run the SSE server +### Run the streamable HTTP server ```shell -npx @modelcontextprotocol/server-everything sse +npx @modelcontextprotocol/server-everything streamableHttp ``` -### Run the streamable HTTP server +### Run the SSE server (deprecated, 2025-era clients only) ```shell -npx @modelcontextprotocol/server-everything streamableHttp +npx @modelcontextprotocol/server-everything sse ``` ## License diff --git a/src/everything/docs/architecture.md b/src/everything/docs/architecture.md index 728cfd4010..a371f83d85 100644 --- a/src/everything/docs/architecture.md +++ b/src/everything/docs/architecture.md @@ -14,18 +14,19 @@ It explains how the server starts, how transports are wired, where tools, prompt ### Purpose -A minimal, modular MCP server showcasing core Model Context Protocol features. It exposes simple tools, prompts, and resources, and can be run over multiple transports (STDIO, SSE, and Streamable HTTP). +A minimal, modular MCP server showcasing core Model Context Protocol features. It exposes simple tools, prompts, and resources, and can be run over multiple transports (STDIO, Streamable HTTP, and the deprecated HTTP+SSE). It serves both protocol eras: 2026-07-28 clients (`server/discover`, per-request `_meta`, no sessions) and 2025-era clients (`initialize`), from one server factory. ### Design -A small “server factory” constructs the MCP server and registers features. -Transports are separate entry points that create/connect the server and handle network concerns. +A small “server factory” constructs the MCP server for a protocol era and registers features. +Transports are separate entry points that serve the factory and handle network concerns: the SDK's `serveStdio` and `createMcpHandler` entries pick each client's era. Tools, prompts, and resources are organized in their own submodules. ### Multi‑client -The server supports multiple concurrent clients. Tracking per session data is demonstrated with -resource subscriptions and simulated logging. +The server supports multiple concurrent clients. Per-connection state (resource subscriptions, +simulated logging) lives on the server instance serving the connection, and state that must outlive +a 2026-07-28 request (tasks, minted resources) is stored under handles the server mints. ## Build and Distribution diff --git a/src/everything/docs/extension.md b/src/everything/docs/extension.md index 1d77730448..75109f53f5 100644 --- a/src/everything/docs/extension.md +++ b/src/everything/docs/extension.md @@ -9,8 +9,10 @@ ## Adding Tools -- Create a new file under `tools/` with your `registerXTool(server)` function that registers the tool via `server.registerTool(...)`. -- Export and call it from `tools/index.ts` inside `registerTools(server)`. +- Create a new file under `tools/` with your `registerXTool(server)` function that registers the tool via `server.registerTool(...)`. Take the `context` (`server/context.ts`) as a second argument if the tool needs the protocol era, the change publisher, or state that lives as long as the connection. +- Export and call it from `tools/index.ts`, in the function for the eras that get it: `registerTools(server, context)` for both, `registerModernTools` for 2026-07-28 only, `registerLegacyTools` or `registerConditionalTools` (capability-gated) for the 2025 era only. +- Never keep state in a module-level map keyed by session id: the 2026-07-28 protocol has no sessions, and its HTTP path builds a fresh server per request. Keep per-connection state in the registration closure, and state that must outlive a request under a handle the server mints (as `server/tasks.ts` and `resources/minted.ts` do). +- For client input (elicitation, sampling, roots), return `inputRequired(...)` from the handler rather than sending a request; the SDK's legacy shim serves the same handler to 2025-era clients. ## Adding Prompts @@ -20,4 +22,4 @@ ## Adding Resources - Create a new file under `resources/` with your `registerXResources(server)` function using `server.registerResource(...)` (optionally with `ResourceTemplate`). -- Export and call it from `resources/index.ts` inside `registerResources(server)`. +- Export and call it from `resources/index.ts` inside `registerResources(server, context)`. diff --git a/src/everything/docs/features.md b/src/everything/docs/features.md index 12f8435130..282258fac4 100644 --- a/src/everything/docs/features.md +++ b/src/everything/docs/features.md @@ -7,24 +7,54 @@ | [Extension Points](extension.md) | [How It Works](how-it-works.md)** +## Protocol eras + +The server serves two protocol eras from one process, over stdio and over Streamable HTTP. A client's first message picks its era: + +- **2026-07-28** (`modern`): the client opens with `server/discover`, with no `initialize` and no session, and sends its protocol version, identity and capabilities in each request's `_meta`. +- **2025-era** (`legacy`, 2024-10-07 through 2025-11-25): the client opens with the `initialize` handshake. The HTTP+SSE transport serves this era only. + +Each feature below is marked **both**, **2026-07-28** or **2025-era**. Roots, sampling and logging are deprecated as of 2026-07-28 (SEP-2577), as is the HTTP+SSE transport; they stay available and are labelled deprecated in their descriptions. + +| Feature | 2026-07-28 | 2025-era | +| --- | --- | --- | +| Discovery | `server/discover` | `initialize` | +| Client input (elicitation, sampling, roots) | in-band: the tool returns `input_required`, the client retries | the SDK's legacy shim sends real server-to-client requests for the same handler | +| State across rounds | `requestState`, signed and expiring | the same, replayed by the shim | +| Change notifications | on a `subscriptions/listen` stream the client opened | unsolicited, or after `resources/subscribe` | +| Log level | per request, `_meta` `io.modelcontextprotocol/logLevel` (none named: no logs) | per session, `logging/setLevel` | +| Tasks | the `io.modelcontextprotocol/tasks` extension | not served | +| Cache hints | `ttlMs` / `cacheScope` on lists, reads and `server/discover` | not sent | +| `x-mcp-header` | mirrored into `Mcp-Param-*` headers over Streamable HTTP | not mirrored | + ## Tools -- `echo` (tools/echo.ts): Echoes the provided `message: string`. Uses Zod to validate inputs. -- `get-annotated-message` (tools/get-annotated-message.ts): Returns a `text` message annotated with `priority` and `audience` based on `messageType` (`error`, `success`, or `debug`); can optionally include an annotated `image`. -- `get-env` (tools/get-env.ts): Returns all environment variables from the running process as pretty-printed JSON text. -- `get-resource-links` (tools/get-resource-links.ts): Returns an intro `text` block followed by multiple `resource_link` items. For a requested `count` (1–10), alternates between dynamic Text and Blob resources using URIs from `resources/templates.ts`. -- `get-resource-reference` (tools/get-resource-reference.ts): Accepts `resourceType` (`text` or `blob`) and `resourceId` (positive integer). Returns a concrete `resource` content block (with its `uri`, `mimeType`, and data) with surrounding explanatory `text`. -- `get-roots-list` (tools/get-roots-list.ts): Returns the last list of roots sent by the client. -- `gzip-file-as-resource` (tools/gzip-file-as-resource.ts): Accepts a `name` and `data` (URL or data URI), fetches the data subject to size/time/domain constraints, compresses it, registers it as a session resource at `demo://resource/session/` with `mimeType: application/gzip`, and returns either a `resource_link` (default) or an inline `resource` depending on `outputType`. -- `get-structured-content` (tools/get-structured-content.ts): Demonstrates structured responses. Accepts `location` input and returns both backward‑compatible `content` (a `text` block containing JSON) and `structuredContent` validated by an `outputSchema` (temperature, conditions, humidity). -- `get-sum` (tools/get-sum.ts): For two numbers `a` and `b` calculates and returns their sum. Uses Zod to validate inputs. -- `get-tiny-image` (tools/get-tiny-image.ts): Returns a tiny PNG MCP logo as an `image` content item with brief descriptive text before and after. -- `trigger-long-running-operation` (tools/trigger-long-running-operation.ts): Simulates a multi-step operation over a given `duration` and number of `steps`; reports progress via `notifications/progress` when a `progressToken` is provided by the client. -- `toggle-simulated-logging` (tools/toggle-simulated-logging.ts): Starts or stops simulated, random‑leveled logging for the invoking session. Respects the client’s selected minimum logging level. -- `toggle-subscriber-updates` (tools/toggle-subscriber-updates.ts): Starts or stops simulated resource update notifications for URIs the invoking session has subscribed to. -- `trigger-elicitation-request` (tools/trigger-elicitation-request.ts): Issues an `elicitation/create` request using form-mode fields (strings, numbers, booleans, enums, and format validation) and returns the resulting action/content. Requires client form-mode elicitation: `elicitation.form`, or an `elicitation` object that declares neither mode. -- `trigger-url-elicitation` (tools/trigger-url-elicitation.ts): Issues an `elicitation/create` request in URL mode (`mode: "url"`) with an `elicitationId`, or throws MCP error `-32042` (`UrlElicitationRequiredError`) when `errorPath=true`. On the error path the prerequisite elicitation it returns points at a different URL than the failing request (`https://modelcontextprotocol.io`); when the client satisfies it and retries the same call, the retry ignores `errorPath` and proceeds via the request path, so the client does not loop on the same error. The retry marker is one-shot per `(session, url, elicitationId)`: it is cleared on the recognized retry, so re-running the error path with identical arguments without an intervening prerequisite is treated as a retry and proceeds. Requires client capability `elicitation.url`. -- `trigger-sampling-request` (tools/trigger-sampling-request.ts): Issues a `sampling/createMessage` request to the client/LLM using provided `prompt` and optional generation controls; returns the LLM's response payload. +- `echo` (tools/echo.ts), **both**: Echoes the provided `message: string`. Uses Zod to validate inputs. +- `get-annotated-message` (tools/get-annotated-message.ts), **both**: Returns a `text` message annotated with `priority` and `audience` based on `messageType` (`error`, `success`, or `debug`); can optionally include an annotated `image`. +- `get-env` (tools/get-env.ts), **both**: Returns all environment variables from the running process as pretty-printed JSON text. +- `get-header-params` (tools/get-header-params.ts), **both**: Returns its `region` argument and the `Mcp-Param-Region` HTTP header it arrived in. Its input and output schemas are plain JSON Schema 2020-12, and `region` carries `x-mcp-header: "Region"`, so a 2026-07-28 Streamable HTTP client mirrors it into that header and the server refuses a call whose header disagrees with the body (`-32020`). The header is `null` on stdio and for a 2025-era client. +- `get-resource-links` (tools/get-resource-links.ts), **both**: Returns an intro `text` block followed by multiple `resource_link` items. For a requested `count` (1–10), alternates between dynamic Text and Blob resources using URIs from `resources/templates.ts`. +- `get-resource-reference` (tools/get-resource-reference.ts), **both**: Accepts `resourceType` (`text` or `blob`) and `resourceId` (positive integer). Returns a concrete `resource` content block (with its `uri`, `mimeType`, and data) with surrounding explanatory `text`. +- `get-roots-list` (tools/get-roots-list.ts), **both**, deprecated: Asks the client for its roots with a multi-round-trip request (`inputRequired.listRoots()`) and lists them. Needs client capability `roots`. +- `gzip-file-as-resource` (tools/gzip-file-as-resource.ts), **both**: Accepts a `name` and `data` (URL or data URI), fetches the data subject to size/time/domain constraints, compresses it, and returns either a `resource_link` (default) or an inline `resource` depending on `outputType`, with `mimeType: application/gzip`. The linked resource is registered as a session resource at `demo://resource/session/` for a 2025-era client, and stored for ten minutes at a minted `demo://resource/minted//` for a 2026-07-28 client. +- `get-structured-content` (tools/get-structured-content.ts), **both**: Demonstrates structured responses. Accepts `location` input and returns both backward‑compatible `content` (a `text` block containing JSON) and `structuredContent` validated by an `outputSchema` (temperature, conditions, humidity). +- `get-sum` (tools/get-sum.ts), **both**: For two numbers `a` and `b` calculates and returns their sum. Uses Zod to validate inputs. +- `get-tiny-image` (tools/get-tiny-image.ts), **both**: Returns a tiny PNG MCP logo as an `image` content item with brief descriptive text before and after. +- `simulate-research-query` (tools/simulate-research-query.ts), **2026-07-28**: Runs as a task on the tasks extension. The call returns a task handle; the task works through four stages, one a second, which the client follows with `tasks/get`. With `ambiguous: true` and an elicitation-capable client it pauses at `input_required`, carrying a clarification request the client answers with `tasks/update`. Needs a client that declares the extension. +- `toggle-simulated-logging` (tools/toggle-simulated-logging.ts), **2025-era**, deprecated: Starts or stops simulated, random‑leveled logging for the connection. Respects the level the client set with `logging/setLevel`. +- `toggle-subscriber-updates` (tools/toggle-subscriber-updates.ts), **2025-era**, deprecated: Starts or stops simulated resource update notifications for URIs the connection has subscribed to with `resources/subscribe`. +- `trigger-elicitation-request` (tools/trigger-elicitation-request.ts), **both**: Asks for a form of every field type form-mode elicitation supports (strings, numbers, booleans, enums, and format validation) with a multi-round-trip request, and returns the resulting action/content. Needs client form-mode elicitation: `elicitation.form`, or an `elicitation` object that declares neither mode. +- `trigger-elicitation-request-async` (tools/trigger-elicitation-request-async.ts), **2026-07-28**: Runs as a task whose `input_required` status carries a form elicitation; the client answers with `tasks/update` and the task completes with a summary. Needs a client that declares the tasks extension. +- `trigger-list-changed` (tools/trigger-list-changed.ts), **both**: Sends `notifications//list_changed` for `tools`, `prompts` or `resources`: on 2026-07-28 to each `subscriptions/listen` stream that asked for that list, on a 2025-era connection unsolicited. +- `trigger-log-messages` (tools/trigger-log-messages.ts), **both**, deprecated: Sends one log message at each level during the call. A 2026-07-28 request gets those at or above the level its `_meta` names (none if it names none); a 2025-era client those at or above its `logging/setLevel` level. +- `trigger-long-running-operation` (tools/trigger-long-running-operation.ts), **both**: Simulates a multi-step operation over a given `duration` and number of `steps`; reports progress via `notifications/progress` when a `progressToken` is provided by the client. +- `trigger-multi-step-elicitation` (tools/trigger-multi-step-elicitation.ts), **both**: Asks for a name, then for a favorite color addressed by that name, carrying the first answer to the second round in `requestState`. The state is HMAC-signed and expires after ten minutes; a tampered or expired one is refused with `-32602`. A blank name or a color that is not a choice is asked again. Needs form-mode elicitation. +- `trigger-resource-update` (tools/trigger-resource-update.ts), **both**: Sends `notifications/resources/updated` for a URI: on 2026-07-28 to each listen stream whose `resourceSubscriptions` name it, on a 2025-era connection once the client has subscribed to it. +- `trigger-sampling-request` (tools/trigger-sampling-request.ts), **both**, deprecated: Asks the client's LLM for a completion with a multi-round-trip request, using the provided `prompt` and `maxTokens`; returns the LLM's response payload. Needs client capability `sampling`. +- `trigger-sampling-request-async` (tools/trigger-sampling-request-async.ts), **2026-07-28**, deprecated: Runs as a task whose `input_required` status carries a sampling request; the client answers with `tasks/update` and the task completes with the result. Needs a client that declares the tasks extension. +- `trigger-url-elicitation` (tools/trigger-url-elicitation.ts), **both**: Directs the user to a URL (URL-mode elicitation). On 2026-07-28 the elicitation comes in-band (`input_required`), with no `elicitationId`. On a 2025-era connection it issues an `elicitation/create` request in URL mode with an `elicitationId`, or throws MCP error `-32042` (`UrlElicitationRequiredError`) when `errorPath=true`. On the error path the prerequisite elicitation it returns points at a different URL than the failing request (`https://modelcontextprotocol.io`); when the client satisfies it and retries the same call, the retry ignores `errorPath` and proceeds via the request path, so the client does not loop on the same error. The retry marker is one-shot per `(url, elicitationId)` on the connection: it is cleared on the recognized retry, so re-running the error path with identical arguments without an intervening prerequisite is treated as a retry and proceeds. Needs client capability `elicitation.url`. + +On a 2025-era connection the tools that need a client capability are registered only for a client that declared it at `initialize`. On 2026-07-28 they are always listed, because the capabilities arrive with each request, and the SDK refuses a call whose embedded request the client did not declare (`-32021`). ## Prompts @@ -37,27 +67,33 @@ - Dynamic Text: `demo://resource/dynamic/text/{index}` (content generated on the fly) - Dynamic Blob: `demo://resource/dynamic/blob/{index}` (base64 payload generated on the fly) -- Static Documents: `demo://resource/static/document/` (serves files from `src/everything/docs/` as static file-based resources) -- Session Scoped: `demo://resource/session/` (per-session resources registered dynamically; available only for the lifetime of the session) +- Static Documents: `demo://resource/static/document/` (serves files from `src/everything/docs/` as static file-based resources; a 2026-07-28 read carries a one-hour public cache hint) +- Session Scoped, **2025-era**: `demo://resource/session/` (per-session resources registered dynamically; available only for the lifetime of the session) +- Minted, **2026-07-28**: `demo://resource/minted//` (resources a tool created, under a server-minted id; readable by any client that has the URI, for ten minutes) + +A read of a resource that does not exist (an unknown URI, a template id that is not a positive integer, an expired minted resource) is answered with `-32602` and the URI in the error's `data`. ## Resource Subscriptions and Notifications -- Simulated update notifications are opt‑in and off by default. -- Clients may subscribe/unsubscribe to resource URIs using the MCP `resources/subscribe` and `resources/unsubscribe` requests. -- Use the `toggle-subscriber-updates` tool to start/stop a per‑session interval that emits `notifications/resources/updated { uri }` only for URIs that session has subscribed to. -- Multiple concurrent clients are supported; each client’s subscriptions are tracked per session and notifications are delivered independently via the server instance associated with that session. +- **2026-07-28**: a client opens a `subscriptions/listen` stream whose filter names the notifications it wants (`toolsListChanged`, `promptsListChanged`, `resourcesListChanged`, and `resourceSubscriptions` for per-resource updates). `trigger-list-changed` and `trigger-resource-update` publish onto those streams; over Streamable HTTP, through the handler's subscription bus, so a change made in one request reaches streams other requests opened. +- **2025-era**: clients subscribe and unsubscribe to resource URIs with `resources/subscribe` and `resources/unsubscribe`. `toggle-subscriber-updates` starts or stops a per-connection interval that emits `notifications/resources/updated { uri }` for the URIs that connection subscribed to, and `trigger-resource-update` sends one on demand. List changes arrive unsolicited. +- Each client's subscriptions belong to the server instance serving its connection, so concurrent clients are independent. + +## Logging -## Simulated Logging +Logging is deprecated as of 2026-07-28 (SEP-2577) and remains available on both eras. -- Simulated logging is available but off by default. -- Use the `toggle-simulated-logging` tool to start/stop periodic log messages of varying levels (debug, info, notice, warning, error, critical, alert, emergency) per session. -- Clients can control the minimum level they receive via the standard MCP `logging/setLevel` request. +- **2026-07-28**: a request names its log level in `_meta` (`io.modelcontextprotocol/logLevel`); a request that names none gets no log messages. Use `trigger-log-messages` to see the filter at work. +- **2025-era**: clients set the minimum level for the session with `logging/setLevel`. `toggle-simulated-logging` starts or stops periodic log messages of varying levels (debug, info, notice, warning, error, critical, alert, emergency). ## Tasks -The server does not support MCP Tasks at the moment. Its three task tools -(`simulate-research-query`, `trigger-sampling-request-async` and -`trigger-elicitation-request-async`) were built on the TypeScript SDK's -experimental tasks layer, which SDK v2 removed (SEP-2663). They return on the -`io.modelcontextprotocol/tasks` extension in -[#4852](https://github.com/modelcontextprotocol/servers/issues/4852). +**2026-07-28.** The server implements the server side of the [tasks extension](https://github.com/modelcontextprotocol/ext-tasks) (`io.modelcontextprotocol/tasks`, SEP-2663), advertised under `capabilities.extensions` in `server/discover`. `simulate-research-query`, `trigger-sampling-request-async` and `trigger-elicitation-request-async` answer `tools/call` with a task handle (`resultType: "task"`) for a client that declares the extension, and serve: + +- `tasks/get`: the task's status, `statusMessage`, `ttlMs` and `pollIntervalMs`, plus its `inputRequests` while `input_required` and its `result` once `completed`; +- `tasks/update`: the client's `inputResponses` to the outstanding input requests; +- `tasks/cancel`: stop a task that has not finished. + +An unknown or expired task, an update to a task that is not waiting for input or that answers a request it did not make, and a cancel of a finished task are refused with `-32602`. The task id is a server-minted UUID, so a task lives across requests (each 2026-07-28 HTTP request is served by a fresh server instance) and is reached only through its handle. A task's progress is computed from the clock when it is read, so nothing runs in the background. + +The 2025-11-25 core task methods (`tasks/result`, `tasks/list`, task-augmented requests) are not served: SDK v2 removed the experimental layer they were built on. diff --git a/src/everything/docs/how-it-works.md b/src/everything/docs/how-it-works.md index 514c6f5663..6059ac215d 100644 --- a/src/everything/docs/how-it-works.md +++ b/src/everything/docs/how-it-works.md @@ -9,37 +9,52 @@ # Conditional Tool Registration -### Module: `server/index.ts` +### Modules: `server/index.ts`, `tools/index.ts` - Some tools require client support for the capability they demonstrate. These are: - `get-roots-list` - `trigger-elicitation-request` + - `trigger-multi-step-elicitation` - `trigger-sampling-request` -- Client capabilities aren't known until after initilization handshake is complete. -- Most tools are registered immediately during the Server Factory execution, prior to client connection. -- To defer registration of these commands until client capabilities are known, a `registerConditionalTools(server)` function is invoked from an `onintitialized` handler. + - `trigger-url-elicitation` +- **2025 era**: client capabilities aren't known until the initialization handshake is complete, so these tools are registered by `registerConditionalTools(server, context)`, invoked from an `oninitialized` handler, for a client that declared the capability. +- **2026-07-28**: there is no handshake, and each request carries the client's capabilities in `_meta`, so `registerModernTools(server, context)` registers them up front. The SDK refuses a call whose embedded request the client did not declare (`-32021`). + +# Multi-Round-Trip Requests + +### Modules: the elicitation, sampling and roots tools + +- A tool that needs client input returns `inputRequired({ inputRequests })` instead of a result. A 2026-07-28 client fulfils the embedded requests and retries the call with `inputResponses`; for a 2025-era client the SDK's legacy shim sends real `elicitation/create`, `sampling/createMessage` or `roots/list` requests and re-enters the handler with the answers. Each handler is written once. +- The handler reads the answers with `inputResponse(...)` (any kind, including a decline) or `acceptedContent(..., schema)` (a validated form answer). +- A flow of several rounds carries what it has learned in `requestState`. `trigger-multi-step-elicitation` mints it with `roundStateCodec` (HMAC-signed, ten-minute expiry, process-wide key), passed to the server as `requestState.verify`, so a tampered or expired state is refused with `-32602` before the handler runs. + +# Tasks + +### Module: `server/tasks.ts` + +- Serves the tasks extension (`io.modelcontextprotocol/tasks`) on 2026-07-28: `tasks/get`, `tasks/update` and `tasks/cancel`, with explicit schemas. +- `createTask(behavior, options)` stores a task under a minted UUID and returns the `tools/call` result that hands its handle to the client (`resultType: "task"`). A task's `behavior` works out its state from the clock (`stateAt`) and takes the client's answers (`receive`), so nothing runs in the background. Tasks past their TTL are dropped when the store is next touched. ## Resource Subscriptions ### Module: `resources/subscriptions.ts` -- Tracks subscribers per URI: `Map>`. -- Installs handlers via `setSubscriptionHandlers(server)` to process subscribe/unsubscribe requests and keep the map updated. -- Updates are started/stopped on demand by the `toggle-subscriber-updates` tool, which calls `beginSimulatedResourceUpdates(server, sessionId)` and `stopSimulatedResourceUpdates(sessionId)`. -- `cleanup(sessionId?)` calls `stopSimulatedResourceUpdates(sessionId)` to clear intervals and remove session‑scoped state. +- **2025 era**: `setSubscriptionHandlers(server, subscribed)` processes subscribe/unsubscribe requests, keeping the instance's set of subscribed URIs. +- Updates are started/stopped on demand by the `toggle-subscriber-updates` tool, which calls `beginSimulatedResourceUpdates(server, subscribed)` and keeps the stop function it returns; the instance's `cleanup()` stops it too. +- **2026-07-28**: the serving entry handles `subscriptions/listen` itself. The context's publisher (`server/context.ts`) sends changes from the instance on stdio, where `serveStdio` routes them onto the open streams, and through the handler's bus over Streamable HTTP. -## Session‑scoped Resources +## Session‑scoped and Minted Resources -### Module: `resources/session.ts` +### Modules: `resources/session.ts`, `resources/minted.ts` - `getSessionResourceURI(name: string)`: Builds a session resource URI: `demo://resource/session/`. - `registerSessionResource(server, resource, type, payload)`: Registers a resource with the given `uri`, `name`, and `mimeType`, returning a `resource_link`. The content is served from memory for the life of the session only. Supports `type: "text" | "blob"` and returns data in the corresponding field. -- Intended usage: tools can create and expose per-session artifacts without persisting them. For example, `tools/gzip-file-as-resource.ts` compresses fetched content, registers it as a session resource with `mimeType: application/gzip`, and returns either a `resource_link` or an inline `resource` based on `outputType`. +- `mintBlobResource(name, mimeType, blob)`: on 2026-07-28, where an instance may not outlive the request, stores the resource process-wide under `demo://resource/minted//` for ten minutes and returns a `resource_link`; `registerMintedResources(server)` serves the store through one template. +- Intended usage: tools can create and expose artifacts without persisting them. For example, `tools/gzip-file-as-resource.ts` compresses fetched content and returns either a `resource_link` to a session or minted resource, by era, or an inline `resource`, based on `outputType`. -## Simulated Logging +## Logging -### Module: `server/logging.ts` +### Modules: `server/logging.ts`, `tools/trigger-log-messages.ts` -- Periodically sends randomized log messages at different levels. Messages can include the session ID for clarity during demos. -- Started/stopped on demand via the `toggle-simulated-logging` tool, which calls `beginSimulatedLogging(server, sessionId?)` and `stopSimulatedLogging(sessionId?)`. Note that transport disconnect triggers `cleanup()` which also stops any active intervals. -- Uses `server.sendLoggingMessage({ level, data }, sessionId?)` so that the client’s configured minimum logging level is respected by the SDK. +- **2025 era**: `beginSimulatedLogging(server)` periodically sends randomized log messages at different levels, and returns the function that stops it. `toggle-simulated-logging` starts and stops it; the instance's `cleanup()` stops it too. It uses `server.sendLoggingMessage(params, sessionId)` so that the level the client set with `logging/setLevel` is respected by the SDK. +- **Both eras**: `trigger-log-messages` sends one message per level with `ctx.mcpReq.log(...)`, which the SDK filters by the request's `_meta` log level on 2026-07-28 and by the session level on the 2025 era. diff --git a/src/everything/docs/startup.md b/src/everything/docs/startup.md index 2f95e5680f..e93a85577b 100644 --- a/src/everything/docs/startup.md +++ b/src/everything/docs/startup.md @@ -13,64 +13,59 @@ - Runs the specified **transport manager** to handle client connections. - Specify transport type on command line (default `stdio`) - `stdio` → `transports/stdio.js` - - `sse` → `transports/sse.js` + - `sse` → `transports/sse.js` (deprecated, 2025-era clients only; prints a warning) - `streamableHttp` → `transports/streamableHttp.js` ## 2. The Transport Manager -- Creates a server instance using `createServer()` from `server/index.ts` - - Connects it to the chosen transport type from the MCP SDK. +- Serves server instances built by `createServer()` from `server/index.ts`, one per connection (or per request), each for the protocol era the client speaks. - Handles communication according to the MCP specs for the chosen transport. - **STDIO**: - - One simple, process‑bound connection. - - Calls`clientConnect()` upon connection. - - Closes and calls `cleanup()` on `SIGINT`. - - **SSE**: - - Supports multiple client connections. - - Client transports are mapped to `sessionId`; - - Calls `clientConnect(sessionId)` upon connection. + - One process‑bound connection, served by the SDK's `serveStdio` entry. + - The client's first message picks the era: `server/discover` (or any request with the 2026-07-28 `_meta` envelope) for 2026-07-28, `initialize` for the 2025 era. `serveStdio` then builds the server for that era with `createServer({ era })` and pins it to the connection (it may build and discard one more first, to answer a discover probe). + - Runs an instance's `cleanup()` when it closes, and on `SIGINT` closes the connection, cleans up and exits. + - **SSE** (deprecated; 2025-era clients only): + - Supports multiple client connections, one server per `GET /sse` stream. + - Client transports are mapped to `sessionId`. - Hooks server’s `onclose` to clean and remove session. - Exposes - `/sse` **GET** (SSE stream) - `/message` **POST** (JSON‑RPC messages) - - **Streamable HTTP**: - - Supports multiple client connections. - - Client transports are mapped to `sessionId`; - - Calls `clientConnect(sessionId)` upon connection. - - Exposes `/mcp` for - - **POST** (JSON‑RPC messages) - - **GET** (SSE stream) - - **DELETE** (termination) - - Uses an event store for resumability and stores transports by `sessionId`. - - Calls `cleanup(sessionId)` on **DELETE**. + - **Streamable HTTP**: one `/mcp` endpoint for both eras. + - **2026-07-28**: a session-less **POST** carrying the per-request `_meta` envelope is served by the SDK's `createMcpHandler`, which builds a fresh server per request with `createServer({ era: "modern", publish })`. There are no sessions. Change notifications are published on the handler's subscription bus (`publish`), which reaches every open `subscriptions/listen` stream. + - **2025 era**: everything else, classified by the SDK's `isLegacyRequest`, keeps the sessionful wiring: a server per session, created on the `initialize` **POST**, with **GET** for the standalone SSE stream and **DELETE** to end the session. Transports are stored by `sessionId`, an event store makes streams resumable, and the session's `cleanup()` runs when it closes. Sessionful serving keeps the server-to-client channel the legacy shim needs for elicitation, sampling and roots. + - A session-less POST's body is parsed once (up to 4 MiB) to classify it and serve it. - **SSE** and **Streamable HTTP** bind their port (`PORT`, default `3001`) through `listenOrExit()` in `transports/listen.ts`. - The listening line is printed only once the port is bound. - A bind failure, such as a port already in use, is reported on stderr and the process exits non-zero. ## 3. The Server Factory -- Invoke `createServer()` from `server/index.ts` +- Invoke `createServer({ era, publish })` from `server/index.ts` - Creates a new `McpServer` instance with - **Capabilities**: - - `tools: {}` + - `tools: { listChanged: true }` - `logging: {}` - - `prompts: {}` - - `resources: { subscribe: true }` + - `prompts: { listChanged: true }` + - `resources: { subscribe: true, listChanged: true }` + - `extensions: { "io.modelcontextprotocol/tasks": {} }` (2026-07-28 only) - **Server Instructions** - Loaded from the docs folder (`instructions.md`). + - **Options** + - Cache hints for the 2026-07-28 cacheable results (`cacheHints`). + - The `requestState` verifier for multi-round tools (`roundStateCodec`). - **Registrations** - - Registers **tools** via `registerTools(server)`. - - Registers **resources** via `registerResources(server)`. + - Builds a per-instance **context** (`server/context.ts`): the era, where change notifications go, the 2025-era resource subscriptions, and a cleanup registry. + - Registers **tools** via `registerTools(server, context)`, and then by era: `registerModernTools` and the tasks extension (`server/tasks.ts`) for 2026-07-28; `registerLegacyTools` now and `registerConditionalTools` once the client has initialized, for the 2025 era. + - Registers **resources** via `registerResources(server, context)`. - Registers **prompts** via `registerPrompts(server)`. - - **Other Request Handlers** - - Sets up resource subscription handlers via `setSubscriptionHandlers(server)`. - - Roots list change handler is added post-connection via + - **Other Request Handlers** (2025 era) + - Sets up resource subscription handlers via `setSubscriptionHandlers(server, subscribed)`. + - Syncs the client's roots after `initialize` via `syncRoots(server)`, which also installs the `roots/list_changed` handler. - **Returns** - The `McpServer` instance - - A `clientConnect(sessionId)` callback that enables post-connection setup - - A `cleanup(sessionId?)` callback that stops any active intervals and removes any session‑scoped state + - A `cleanup()` callback that stops the instance's intervals and clears its state ## Enabling Multiple Clients -Some of the transport managers defined in the `transports` folder can support multiple clients. -In order to do so, they must map certain data to a session identifier. +Every piece of per-connection state lives on the server instance that serves the connection: there are no maps keyed by session id. On stdio and on a 2025-era session one instance serves the whole connection; on the 2026-07-28 HTTP path each request gets its own instance, so what must outlive a request (a task, a minted resource) is stored process-wide under a handle the server mints. diff --git a/src/everything/docs/structure.md b/src/everything/docs/structure.md index 2464b1c7c8..f8b361ef90 100644 --- a/src/everything/docs/structure.md +++ b/src/everything/docs/structure.md @@ -29,18 +29,22 @@ src/everything ├── resources │ ├── index.ts │ ├── files.ts + │ ├── minted.ts │ ├── session.ts │ ├── subscriptions.ts │ └── templates.ts ├── server │ ├── index.ts + │ ├── context.ts │ ├── logging.ts - │ └── roots.ts + │ ├── roots.ts + │ └── tasks.ts ├── tools │ ├── index.ts │ ├── echo.ts │ ├── get-annotated-message.ts │ ├── get-env.ts + │ ├── get-header-params.ts │ ├── get-resource-links.ts │ ├── get-resource-reference.ts │ ├── get-roots-list.ts @@ -48,10 +52,17 @@ src/everything │ ├── get-sum.ts │ ├── get-tiny-image.ts │ ├── gzip-file-as-resource.ts + │ ├── simulate-research-query.ts │ ├── toggle-simulated-logging.ts │ ├── toggle-subscriber-updates.ts + │ ├── trigger-elicitation-request-async.ts │ ├── trigger-elicitation-request.ts + │ ├── trigger-list-changed.ts + │ ├── trigger-log-messages.ts │ ├── trigger-long-running-operation.ts + │ ├── trigger-multi-step-elicitation.ts + │ ├── trigger-resource-update.ts + │ ├── trigger-sampling-request-async.ts │ ├── trigger-sampling-request.ts │ └── trigger-url-elicitation.ts └── transports @@ -114,58 +125,87 @@ src/everything ### `resources/` - `index.ts` - - `registerResources(server)` orchestrator; delegates to resource factory/registration methods from individual resource files. + - `registerResources(server, context)` orchestrator; delegates to resource factory/registration methods from individual resource files, adding the minted-resource template on 2026-07-28. - `templates.ts` - Registers two dynamic, template‑driven resources using `ResourceTemplate`: - Text: `demo://resource/dynamic/text/{index}` (MIME: `text/plain`) - Blob: `demo://resource/dynamic/blob/{index}` (MIME: `application/octet-stream`, Base64 payload) - - The `{index}` path variable must be a finite positive integer. Content is generated on demand with a timestamp. + - The `{index}` path variable must be a finite positive integer; any other is not found (`-32602`). Content is generated on demand with a timestamp. - Exposes helpers `textResource(uri, index)`, `textResourceUri(index)`, `blobResource(uri, index)`, and `blobResourceUri(index)` so other modules can construct and embed dynamic resources directly (e.g., from prompts). - `files.ts` - Registers static file-based resources for each file in the `docs/` folder. - URIs follow the pattern: `demo://resource/static/document/`. - Serves markdown files as `text/markdown`, `.txt` as `text/plain`, `.json` as `application/json`, others default to `text/plain`. + - Gives each a one-hour public cache hint for 2026-07-28 reads. +- `minted.ts` + - 2026-07-28: stores resources tools create under server-minted URIs (`demo://resource/minted//`) for ten minutes, process-wide, and serves them through one template. +- `session.ts` + - 2025 era: registers resources tools create on the session's server instance (`demo://resource/session/`). +- `subscriptions.ts` + - 2025 era: the `resources/subscribe` and `resources/unsubscribe` handlers, and the timed updates `toggle-subscriber-updates` drives. ### `server/` - `index.ts` - - Server factory that creates an `McpServer` with declared capabilities, loads server instructions, and registers tools, prompts, and resources. - - Sets resource subscription handlers via `setSubscriptionHandlers(server)`. - - Exposes `{ server, cleanup }` to the chosen transport. Cleanup stops any running intervals in the server when the transport disconnects. + - Server factory that creates an `McpServer` for a protocol era (`createServer({ era, publish })`) with declared capabilities, cache hints and the `requestState` verifier, loads server instructions, and registers tools, prompts, and resources for that era. + - On the 2025 era, sets resource subscription handlers via `setSubscriptionHandlers(server, subscribed)` and registers the capability-gated tools after `initialize`. + - Exposes `{ server, cleanup }` to the chosen transport. Cleanup stops any running intervals in the server when the connection ends. +- `context.ts` + - The per-instance context features receive: the era, the change publisher, the 2025-era subscriptions and a cleanup registry. - `logging.ts` - - Implements simulated logging. Periodically sends randomized log messages at various levels to the connected client session. Started/stopped on demand via a dedicated tool. + - Implements simulated logging (2025 era). Periodically sends randomized log messages at various levels to the connected client. Started/stopped on demand via a dedicated tool. +- `roots.ts` + - 2025 era: syncs the client's roots after `initialize` and on `roots/list_changed`, reporting each answer as a log message. +- `tasks.ts` + - 2026-07-28: the server side of the tasks extension (`tasks/get`, `tasks/update`, `tasks/cancel`) and the process-wide task store the task tools create tasks in. ### `tools/` - `index.ts` - - `registerTools(server)` orchestrator; delegates to tool factory/registration methods in individual tool files. + - Registration orchestrators, by era: `registerTools(server, context)` (both), `registerLegacyTools` and `registerConditionalTools` (2025 era), `registerModernTools` (2026-07-28). Each delegates to the registration methods in individual tool files. - `echo.ts` - Registers an `echo` tool that takes a message and returns `Echo: {message}`. - `get-annotated-message.ts` - Registers a `get-annotated-message` tool which demonstrates content-level annotations. Emits a primary `text` message with content `annotations` (`priority`, `audience`) that vary by `messageType` (`"error" | "success" | "debug"`), and optionally includes an annotated `image` (tiny PNG) when `includeImage` is true. All tools in this server include tool-level annotations (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`). - `get-env.ts` - Registers a `get-env` tool that returns the current process environment variables as formatted JSON text; useful for debugging configuration. +- `get-header-params.ts` + - Registers a `get-header-params` tool with JSON Schema 2020-12 input and output schemas whose `region` argument carries `x-mcp-header`; returns the argument and the `Mcp-Param-Region` header it arrived in. - `get-resource-links.ts` - Registers a `get-resource-links` tool that returns an intro `text` block followed by multiple `resource_link` items. - `get-resource-reference.ts` - Registers a `get-resource-reference` tool that returns a reference for a selected dynamic resource. - `get-roots-list.ts` - - Registers a `get-roots-list` tool that returns the last list of roots sent by the client. + - Registers a `get-roots-list` tool that asks the client for its roots with a multi-round-trip request and lists them. - `gzip-file-as-resource.ts` - Registers a `gzip-file-as-resource` tool that fetches content from a URL or data URI, compresses it, and then either: - - returns a `resource_link` to a session-scoped resource (default), or - - returns an inline `resource` with the gzipped data. The resource will be still discoverable for the duration of the session via `resources/list`. - - Uses `resources/session.ts` to register the gzipped blob as a per-session resource at a URI like `demo://resource/session/` with `mimeType: application/gzip`. + - returns a `resource_link` (default) to a resource it can read later, or + - returns an inline `resource` with the gzipped data. + - The linked resource is a per-session resource at `demo://resource/session/` (`resources/session.ts`) for a 2025-era client, and a minted one at `demo://resource/minted//` (`resources/minted.ts`) for a 2026-07-28 client, with `mimeType: application/gzip`. - Environment controls: - `GZIP_MAX_FETCH_SIZE` (bytes, default 10 MiB) - `GZIP_MAX_FETCH_TIME_MILLIS` (ms, default 30000) - `GZIP_ALLOWED_DOMAINS` (comma-separated allowlist; empty means all domains allowed) +- `simulate-research-query.ts` + - 2026-07-28: registers a `simulate-research-query` tool that runs as a task on the tasks extension, through four timed stages, pausing at `input_required` for a clarification on an ambiguous topic. - `trigger-elicitation-request.ts` - - Registers a `trigger-elicitation-request` tool that sends an `elicitation/create` request to the client/LLM and returns the elicitation result. + - Registers a `trigger-elicitation-request` tool that asks for a form with a multi-round-trip request and returns the elicitation result. +- `trigger-elicitation-request-async.ts` + - 2026-07-28: registers a `trigger-elicitation-request-async` tool that runs as a task waiting in `input_required` for a form elicitation. +- `trigger-list-changed.ts` + - Registers a `trigger-list-changed` tool that publishes a tools, prompts or resources list change. +- `trigger-log-messages.ts` + - Registers a `trigger-log-messages` tool that sends one log message per level, filtered by the request's or session's log level. +- `trigger-multi-step-elicitation.ts` + - Registers a `trigger-multi-step-elicitation` tool that asks two questions in sequence, carrying the first answer in a signed `requestState`. +- `trigger-resource-update.ts` + - Registers a `trigger-resource-update` tool that publishes a `notifications/resources/updated` for a URI. +- `trigger-sampling-request-async.ts` + - 2026-07-28: registers a `trigger-sampling-request-async` tool that runs as a task waiting in `input_required` for a sampling request. - `trigger-url-elicitation.ts` - - Registers a `trigger-url-elicitation` tool that either sends an out-of-band URL-mode `elicitation/create` request (`mode: "url"`) including an `elicitationId` (request path) or throws `UrlElicitationRequiredError` (`-32042`) for client-handled URL elicitation (error path). On the error path the carried prerequisite elicitation points at a different URL than the failing one (`https://modelcontextprotocol.io`), and when the client satisfies it and retries the same call, the retry ignores `errorPath` and proceeds via the request path — so the client does not loop on the same error. + - On 2026-07-28, registers a `trigger-url-elicitation` tool that returns its URL elicitation in-band (`input_required`). On the 2025 era, it either sends an out-of-band URL-mode `elicitation/create` request (`mode: "url"`) including an `elicitationId` (request path) or throws `UrlElicitationRequiredError` (`-32042`) for client-handled URL elicitation (error path). On the error path the carried prerequisite elicitation points at a different URL than the failing one (`https://modelcontextprotocol.io`), and when the client satisfies it and retries the same call, the retry ignores `errorPath` and proceeds via the request path — so the client does not loop on the same error. - `trigger-sampling-request.ts` - - Registers a `trigger-sampling-request` tool that sends a `sampling/createMessage` request to the client/LLM and returns the sampling result. + - Registers a `trigger-sampling-request` tool that asks the client's LLM for a completion with a multi-round-trip request and returns the sampling result. - `get-structured-content.ts` - Registers a `get-structured-content` tool that demonstrates structuredContent block responses. - `get-sum.ts` @@ -175,9 +215,9 @@ src/everything - `trigger-long-running-operation.ts` - Registers a `trigger-long-running-operation` tool that simulates a long-running task over a specified `duration` (seconds) and number of `steps`; emits `notifications/progress` updates when the client supplies a `progressToken`. - `toggle-simulated-logging.ts` - - Registers a `toggle-simulated-logging` tool, which starts or stops simulated logging for the invoking session. + - 2025 era: registers a `toggle-simulated-logging` tool, which starts or stops simulated logging for the connection. - `toggle-subscriber-updates.ts` - - Registers a `toggle-subscriber-updates` tool, which starts or stops simulated resource subscription update checks for the invoking session. + - 2025 era: registers a `toggle-subscriber-updates` tool, which starts or stops simulated resource subscription updates for the connection. ### `transports/` @@ -187,9 +227,10 @@ src/everything - `listenOrExit(app, port, listeningMessage)`: binds an Express app to its port for both HTTP transports. - Prints the listening message only once the port is bound; on a bind failure (such as a port already in use) prints why and exits non-zero. - `stdio.ts` - - Starts a `StdioServerTransport`, created the server via `createServer()`, and connects it. - - Handles `SIGINT` to close cleanly and calls `cleanup()` to remove any live intervals. + - Serves `createServer({ era })` over a `StdioServerTransport` with the SDK's `serveStdio`, which picks the connection's era from the client's first message. + - Runs each instance's `cleanup()` when it closes, and handles `SIGINT` to close cleanly. - `sse.ts` + - Deprecated; 2025-era clients only. Warns on start. - Express server exposing: - `GET /sse` to establish an SSE connection per session. - `POST /message` for client messages. @@ -197,7 +238,9 @@ src/everything - Starts an `SSEServerTransport` (from the frozen `@modelcontextprotocol/server-legacy/sse` bridge), created the server via `createServer()`, and connects it to a new transport. - On server disconnect, calls `cleanup()` to remove any live intervals. - `streamableHttp.ts` - - Express server exposing a single `/mcp` endpoint for POST (JSON‑RPC), GET (SSE stream), and DELETE (session termination) using `NodeStreamableHTTPServerTransport` from `@modelcontextprotocol/node`. + - Express server exposing a single `/mcp` endpoint for both protocol eras. + - 2026-07-28: a session-less POST carrying the per-request envelope (classified by the SDK's `isLegacyRequest`) is served by `createMcpHandler`, a fresh server per request, publishing change notifications on the handler's bus. + - 2025 era: POST (JSON‑RPC), GET (SSE stream), and DELETE (session termination) using `NodeStreamableHTTPServerTransport` from `@modelcontextprotocol/node`. - Uses an `InMemoryEventStore` for resumable sessions and tracks transports by `sessionId`. - Answers a request for an unknown or ended `Mcp-Session-Id` with `404 Not Found`, and closes every open session on `SIGINT`. - Connects a fresh server instance on initialization POST and reuses the transport for subsequent requests. From 4735ab3d2d83392e28c3e933a066906531326f48 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 11 Oct 2026 22:52:32 -0400 Subject: [PATCH 075/117] docs(client-smoke): the TypeScript servers now serve 2026-07-28 The protocol-eras section said no server implements server/discover. After with --protocol-era legacy and modern; the Python servers are still 2025-only until #4853. Also records what the Inspector CLI does with input requests (it declares no capabilities) and with task handles (it follows them), as observed on 2.9.0. Part of #4852 Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .claude/skills/client-smoke/SKILL.md | 49 ++++++++++++++-------------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/.claude/skills/client-smoke/SKILL.md b/.claude/skills/client-smoke/SKILL.md index cbe9a60222..2c90a081cf 100644 --- a/.claude/skills/client-smoke/SKILL.md +++ b/.claude/skills/client-smoke/SKILL.md @@ -172,28 +172,27 @@ CLI) or **Server Settings → Protocol Era** (the web UI): | `modern` | Pins 2026-07-28 through `server/discover`, with no fallback | | `auto` | Probes `server/discover`, and falls back to `initialize` | -**The Python servers serve both eras** (#4853): run every command twice, with -`--protocol-era legacy` and `--protocol-era modern`, and expect both to -succeed. A 2026-07-28 result carries `io.modelcontextprotocol/serverInfo` in -its `_meta`, which is the quick way to see which era answered. - -⚠️ **The TypeScript servers serve only the legacy era today.** They are on the -TS SDK v2 packages (#4856) but still serve only the `initialize` handshake, -with no `server/discover`. Against them: - -- `--protocol-era modern` fails, by design, with `Version negotiation failed: - the server did not offer pinned protocol version 2026-07-28 via - server/discover (no fallback in pin mode)` and exit 1. That is the expected - result for an unmigrated server, not a regression in your change. -- `--protocol-era auto` succeeds, but through the fallback, so it is a legacy - connection and proves nothing about 2026-07-28. - -The both-eras check that `/pr-flow` asks for (#4857) therefore has one half -that can pass for a TypeScript server today. Record the legacy run, and say in -the PR that the server does not yet speak 2026-07-28 rather than leaving the -modern era unmentioned. Modern-era support for them arrives with #4852; from -then on, run both eras as for the Python servers. Do not use `auto` for -evidence in either case: it does not say which era answered. +**Every server serves both eras**: the Python servers since #4853, the +TypeScript servers since #4852 (stdio through the SDK's `serveStdio`, and +`everything`'s Streamable HTTP endpoint through `createMcpHandler` for +2026-07-28 requests). Run every command twice, with `--protocol-era legacy` +and `--protocol-era modern`, and expect both to succeed. A 2026-07-28 result +carries `io.modelcontextprotocol/serverInfo` in its `_meta`, which is the +quick way to see which era answered. Do not use `auto` for evidence: it does +not say which era answered. + +`everything` lists some tools on one era only (the task tools on 2026-07-28, +the two `toggle-*` tools on the 2025 era), so a `tools/list` that differs +between the two runs is expected. + +The CLI declares no client capabilities, so a tool that asks the client for +input (elicitation, sampling, roots) cannot run from it: on the 2025 era +`everything` does not list it (`tool_not_found`, exit 5), and on 2026-07-28 +the SDK refuses the call because the request did not declare the capability +(`Cannot request input 'form' (elicitation/create): the request's client +capabilities do not declare the required capability`, exit 1). Drive those +from the web UI. On 2026-07-28 the CLI does follow a task handle: a call to +`simulate-research-query` polls the task and prints its final result. ## The Inspector web UI @@ -260,8 +259,10 @@ Claude Code, run this way, has no era switch and does not print the protocol version it negotiated. `/pr-flow` asks for the LLM client in both eras as well; where the client gives no way to choose, record the client and its version and say that the era could not be selected, rather than labelling the run with an -era it was not shown to use. Against a server without modern-era support the connection is legacy -whatever the client prefers, for the reason given under Protocol eras. +era it was not shown to use. `everything` shows which era a client +negotiated through its tool list: a 2026-07-28 client sees +`simulate-research-query` and no `toggle-*` tools, a 2025-era client the +reverse. Claude Code 2.1.296 negotiates 2026-07-28 against these servers. ## What to write down From 7f3b522fae6e3d176d30dc2000344d48ffbdebf8 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 11 Oct 2026 23:09:14 -0400 Subject: [PATCH 076/117] fix: task timestamps, task capability gates, async form validation, stdio exit status Copilot round 1 on #5131: - tasks: lastUpdatedAt is when the task entered its status (a stage reached, an answer or a cancel), not when a poll noticed it. - simulate-research-query asks for a clarification only from a form-capable client; trigger-elicitation-request-async needs form-mode elicitation and trigger-sampling-request-async needs sampling, since the SDK does not check the requests a task carries in its status. - trigger-elicitation-request-async validates an accepted answer against its form, and keeps waiting on one that does not fit. - All four stdio entry points set exit code 1 when the transport fails, since serveStdio reports a start failure through onerror, not a rejection. Part of #4852 Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/everything/__tests__/stdio.test.ts | 5 +- src/everything/__tests__/tasks.test.ts | 105 +++++++++++++++++- src/everything/server/tasks.ts | 82 +++++++++++--- .../tools/simulate-research-query.ts | 11 +- .../trigger-elicitation-request-async.ts | 78 ++++++++----- .../tools/trigger-sampling-request-async.ts | 15 ++- src/everything/transports/stdio.ts | 8 +- src/filesystem/__tests__/eras.test.ts | 3 + src/filesystem/index.ts | 8 +- src/memory/__tests__/eras.test.ts | 12 ++ src/memory/index.ts | 8 +- .../__tests__/entry-point.test.ts | 5 +- src/sequentialthinking/index.ts | 8 +- 13 files changed, 287 insertions(+), 61 deletions(-) diff --git a/src/everything/__tests__/stdio.test.ts b/src/everything/__tests__/stdio.test.ts index 86ca23e153..d6889d9a34 100644 --- a/src/everything/__tests__/stdio.test.ts +++ b/src/everything/__tests__/stdio.test.ts @@ -103,7 +103,7 @@ describe("stdio transport", () => { for (const listener of added) process.off("SIGINT", listener); }); - it("reports a transport that cannot start on stderr", async () => { + it("reports a transport that cannot start on stderr and in the exit status", async () => { const error = vi.spyOn(console, "error").mockImplementation(() => {}); const broken = new PassThrough(); broken.on = () => { @@ -117,6 +117,9 @@ describe("stdio transport", () => { expect.objectContaining({ message: "stdin unavailable" }), ), ); + // The failure is in the exit status too; reset it so the run is not failed. + expect(process.exitCode).toBe(1); + process.exitCode = undefined; for (const listener of process.listeners("SIGINT").slice(before)) { process.off("SIGINT", listener); } diff --git a/src/everything/__tests__/tasks.test.ts b/src/everything/__tests__/tasks.test.ts index 03581f9be6..d619e66f48 100644 --- a/src/everything/__tests__/tasks.test.ts +++ b/src/everything/__tests__/tasks.test.ts @@ -128,6 +128,40 @@ describe("simulate-research-query", () => { expect(resultText(done)).toContain("Stage 4: Generating report ✓"); }); + it("reports when each status began, not when a poll noticed it", async () => { + peer = await rawClient(TASKS); + const created = await peer.call("simulate-research-query", { topic: "t" }); + const start = Date.now(); + // Polled long after it finished: the timestamp is the completion's. + advance(STAGE_DURATION * STAGES.length * 3); + const done = await peer.get(created.taskId as string); + expect(done.status).toBe("completed"); + expect(done.lastUpdatedAt).toBe( + new Date(start + STAGE_DURATION * STAGES.length).toISOString(), + ); + }); + + it("reports the moment of a cancellation as the last update", async () => { + peer = await rawClient(TASKS); + const created = await peer.call("simulate-research-query", { topic: "t" }); + const taskId = created.taskId as string; + advance(500); + const cancelledAt = new Date(Date.now()).toISOString(); + await peer.result("tasks/cancel", { taskId }); + advance(STAGE_DURATION * 10); + expect((await peer.get(taskId)).lastUpdatedAt).toBe(cancelledAt); + }); + + it("does not ask a client that supports only URL-mode elicitation", async () => { + peer = await rawClient({ ...TASKS, elicitation: { url: {} } }); + const created = await peer.call("simulate-research-query", { + topic: "t", + ambiguous: true, + }); + advance(STAGE_DURATION * STAGES.length); + expect((await peer.get(created.taskId as string)).status).toBe("completed"); + }); + it("is a tool error for a client that does not declare the extension", async () => { peer = await rawClient({}); const result = await peer.call("simulate-research-query", { topic: "t" }); @@ -373,6 +407,21 @@ describe("trigger-sampling-request-async", () => { expect(resultText(done)).toContain('"text": "hi"'); }); + it("is a tool error for a client that does not declare sampling", async () => { + peer = await rawClient(TASKS); + const result = await peer.call("trigger-sampling-request-async", { + prompt: "p", + }); + expect(result).toMatchObject({ + isError: true, + content: [ + { + text: "trigger-sampling-request-async asks the client for input, so it needs a client that declares the sampling capability.", + }, + ], + }); + }); + it("keeps waiting after an answer of the wrong kind", async () => { peer = await rawClient({ ...TASKS, sampling: {} }); const created = await peer.call("trigger-sampling-request-async", { @@ -432,9 +481,59 @@ describe("trigger-elicitation-request-async", () => { ); }); - it("summarizes an accepted answer with no fields", async () => { - const done = await answer({ action: "accept", content: {} }); - expect(resultText(done)).toBe("Elicitation accept."); + it("summarizes an accepted answer with only the required name", async () => { + const done = await answer({ action: "accept", content: { name: "Ada" } }); + expect(resultText(done)).toBe("Elicitation accept.\nName: Ada"); + }); + + it("reports when the answer arrived as the last update", async () => { + peer = await rawClient(WITH_ELICITATION); + const created = await peer.call("trigger-elicitation-request-async"); + advance(1500); + const answeredAt = new Date(Date.now()).toISOString(); + await peer.result("tasks/update", { + taskId: created.taskId, + inputResponses: { form: { action: "decline" } }, + }); + advance(60_000); + expect((await peer.get(created.taskId as string)).lastUpdatedAt).toBe( + answeredAt, + ); + }); + + it.each([ + ["no name", {}], + ["a blank name", { name: "" }], + ["a color that is not a choice", { name: "Ada", favoriteColor: "mauve" }], + ])( + "keeps waiting after an accepted answer with %s", + async (_label, content) => { + peer = await rawClient(WITH_ELICITATION); + const created = await peer.call("trigger-elicitation-request-async"); + await peer.result("tasks/update", { + taskId: created.taskId, + inputResponses: { form: { action: "accept", content } }, + }); + expect((await peer.get(created.taskId as string)).status).toBe( + "input_required", + ); + }, + ); + + it.each([ + ["only the tasks extension", TASKS], + ["URL-mode elicitation only", { ...TASKS, elicitation: { url: {} } }], + ])("is a tool error for a client with %s", async (_label, capabilities) => { + peer = await rawClient(capabilities); + const result = await peer.call("trigger-elicitation-request-async"); + expect(result).toMatchObject({ + isError: true, + content: [ + { + text: "trigger-elicitation-request-async asks the client for input, so it needs a client that declares the elicitation (form mode) capability.", + }, + ], + }); }); it.each(["decline", "cancel"])("reports a %s", async (action) => { diff --git a/src/everything/server/tasks.ts b/src/everything/server/tasks.ts index 71d7e2780e..dbb612e487 100644 --- a/src/everything/server/tasks.ts +++ b/src/everything/server/tasks.ts @@ -52,14 +52,21 @@ export type TaskInputResponses = NonNullable< >; /** What a task kind decides about its task at one moment. */ -export type TaskState = +export type TaskState = ( | { status: "working"; statusMessage: string } | { status: "input_required"; statusMessage: string; inputRequests: InputRequests; } - | { status: "completed"; statusMessage?: string; result: CallToolResult }; + | { status: "completed"; statusMessage?: string; result: CallToolResult } +) & { + /** + * When the task entered this state (epoch milliseconds), reported as its + * `lastUpdatedAt`: the moment it changed, not the moment a poll noticed. + */ + since: number; +}; /** * One kind of task: how its status follows from the clock, and what it does @@ -76,14 +83,11 @@ export type TaskBehavior = { type StoredTask = { taskId: string; createdAt: number; - lastUpdatedAt: number; ttlMs: number; pollIntervalMs: number; behavior: TaskBehavior; - /** Set once the client cancels; a cancelled task stays cancelled. */ - cancelled: boolean; - /** The status last reported, so a change moves `lastUpdatedAt`. */ - lastStatus?: string; + /** When the client cancelled it; a cancelled task stays cancelled. */ + cancelledAt?: number; }; /** Every live task, by id, across every server instance in the process. */ @@ -111,20 +115,24 @@ function find(taskId: string, now: number): StoredTask { /** The task's state now, honoring a cancellation. */ function stateOf(task: StoredTask, now: number): TaskState | CancelledState { - return task.cancelled - ? { status: "cancelled", statusMessage: "Cancelled by the client" } + return task.cancelledAt !== undefined + ? { + status: "cancelled", + statusMessage: "Cancelled by the client", + since: task.cancelledAt, + } : task.behavior.stateAt(now); } -type CancelledState = { status: "cancelled"; statusMessage: string }; +type CancelledState = { + status: "cancelled"; + statusMessage: string; + since: number; +}; /** The wire form of a task (the `DetailedTask` of the extension's schema). */ function describe(task: StoredTask, now: number): Record { const state = stateOf(task, now); - if (state.status !== task.lastStatus) { - task.lastStatus = state.status; - task.lastUpdatedAt = now; - } return { taskId: task.taskId, status: state.status, @@ -132,7 +140,9 @@ function describe(task: StoredTask, now: number): Record { statusMessage: state.statusMessage, }), createdAt: new Date(task.createdAt).toISOString(), - lastUpdatedAt: new Date(task.lastUpdatedAt).toISOString(), + lastUpdatedAt: new Date( + Math.max(task.createdAt, state.since), + ).toISOString(), ttlMs: task.ttlMs, pollIntervalMs: task.pollIntervalMs, ...(state.status === "input_required" && { @@ -158,10 +168,8 @@ export function createTask( const task: StoredTask = { taskId: randomUUID(), createdAt: now, - lastUpdatedAt: now, ...options, behavior, - cancelled: false, }; tasks.set(task.taskId, task); // The SDK passes a tool result's `resultType` through to the wire on @@ -185,6 +193,44 @@ export function clientCapabilitiesOf( return isObject(capabilities) ? capabilities : undefined; } +/** + * Whether the client that sent this request supports form-mode elicitation: + * `elicitation.form`, or an `elicitation` object with neither mode, which the + * spec reads as form mode (#4985). + */ +export function clientSupportsFormElicitation(ctx: ServerContext): boolean { + const elicitation = clientCapabilitiesOf(ctx)?.elicitation; + return ( + isObject(elicitation) && + (elicitation.form !== undefined || elicitation.url === undefined) + ); +} + +/** Whether the client that sent this request supports sampling. */ +export function clientSupportsSampling(ctx: ServerContext): boolean { + return clientCapabilitiesOf(ctx)?.sampling !== undefined; +} + +/** + * The result for a task tool whose task would ask the client for input it + * did not declare it can give. The SDK checks a tool's own input requests, + * but not the ones a task carries in its status, so the tool checks here. + */ +export function capabilityRequired( + name: string, + capability: string, +): CallToolResult { + return { + content: [ + { + type: "text", + text: `${name} asks the client for input, so it needs a client that declares the ${capability} capability.`, + }, + ], + isError: true, + }; +} + /** * Whether the client that sent this request declared the tasks extension, so * it can follow a task handle. A tool may answer with a task only then. @@ -286,7 +332,7 @@ export function registerTasksExtension(server: McpServer): void { `Task ${taskId} is already ${status}`, ); } - task.cancelled = true; + task.cancelledAt = now; return {}; }, ); diff --git a/src/everything/tools/simulate-research-query.ts b/src/everything/tools/simulate-research-query.ts index 77b687fa9a..1cd2f41930 100644 --- a/src/everything/tools/simulate-research-query.ts +++ b/src/everything/tools/simulate-research-query.ts @@ -23,7 +23,7 @@ import { } from "@modelcontextprotocol/server"; import { z } from "zod"; import { - clientCapabilitiesOf, + clientSupportsFormElicitation, clientSupportsTasks, createTask, tasksRequired, @@ -189,7 +189,8 @@ export const registerSimulateResearchQueryTool = (server: McpServer) => { async (args, ctx): Promise => { if (!clientSupportsTasks(ctx)) return tasksRequired(name); const { topic, ambiguous } = SimulateResearchQuerySchema.parse(args); - const canAsk = clientCapabilitiesOf(ctx)?.elicitation !== undefined; + // The clarification is a form, so only a form-capable client is asked. + const canAsk = clientSupportsFormElicitation(ctx); // The clock time the stages started counting from, and the stage they // started at: an ambiguous query restarts the count once it is answered. @@ -204,8 +205,12 @@ export const registerSimulateResearchQueryTool = (server: McpServer) => { stateAt: (now): TaskState => { const stage = startStage + Math.floor((now - startedAt) / STAGE_DURATION); + // When the clock reached `stage`, the moment the task entered it. + const reached = (at: number) => + startedAt + (at - startStage) * STAGE_DURATION; if (needsClarification() && stage >= CLARIFY_AT) { return { + since: reached(CLARIFY_AT), status: "input_required", statusMessage: `Found multiple interpretations for "${topic}". Requesting clarification...`, inputRequests: { @@ -230,12 +235,14 @@ export const registerSimulateResearchQueryTool = (server: McpServer) => { } if (stage >= STAGES.length) { return { + since: reached(STAGES.length), status: "completed", statusMessage: "Research complete", result: generateResearchReport(topic, clarification), }; } return { + since: reached(stage), status: "working", statusMessage: clarification !== undefined && stage === CLARIFY_AT diff --git a/src/everything/tools/trigger-elicitation-request-async.ts b/src/everything/tools/trigger-elicitation-request-async.ts index 47d70fa003..88cbdd5b71 100644 --- a/src/everything/tools/trigger-elicitation-request-async.ts +++ b/src/everything/tools/trigger-elicitation-request-async.ts @@ -12,10 +12,14 @@ import { McpServer, CallToolResult, + acceptedContent, inputRequired, inputResponse, } from "@modelcontextprotocol/server"; +import { z } from "zod"; import { + capabilityRequired, + clientSupportsFormElicitation, clientSupportsTasks, createTask, tasksRequired, @@ -39,38 +43,37 @@ const config = { }, }; -/** The form the task asks the user to fill in. */ -const requestedSchema = { - type: "object" as const, - properties: { - name: { - type: "string" as const, - title: "Your Name", - description: "Enter your name", - }, - favoriteColor: { - type: "string" as const, - title: "Favorite Color", - description: "What is your favorite color?", - enum: ["red", "orange", "yellow", "green", "blue", "purple"], - }, - agreeToTerms: { - type: "boolean" as const, - title: "Agree to Terms", - description: "Do you agree to the terms and conditions?", - }, - }, - required: ["name"], -}; +/** + * The form the task asks the user to fill in. The same schema is sent as the + * requested schema and checks the answer, since the client's answer is + * untrusted: an accepted answer that does not fit leaves the task waiting. + */ +const FormSchema = z.object({ + name: z + .string() + .min(1) + .meta({ title: "Your Name" }) + .describe("Enter your name"), + favoriteColor: z + .enum(["red", "orange", "yellow", "green", "blue", "purple"]) + .optional() + .meta({ title: "Favorite Color" }) + .describe("What is your favorite color?"), + agreeToTerms: z + .boolean() + .optional() + .meta({ title: "Agree to Terms" }) + .describe("Do you agree to the terms and conditions?"), +}); /** The completed task's summary of the user's answer. */ function summarize( action: string, - content: Record | undefined, + content?: z.infer, ): CallToolResult { const lines = [`Elicitation ${action}.`]; - if (action === "accept" && content) { - if (content.name) lines.push(`Name: ${content.name}`); + if (content) { + lines.push(`Name: ${content.name}`); if (content.favoriteColor) { lines.push(`Favorite color: ${content.favoriteColor}`); } @@ -97,33 +100,48 @@ export const registerTriggerElicitationRequestAsyncTool = ( config, async (_args, ctx): Promise => { if (!clientSupportsTasks(ctx)) return tasksRequired(name); + if (!clientSupportsFormElicitation(ctx)) { + return capabilityRequired(name, "elicitation (form mode)"); + } + const createdAt = Date.now(); let summary: CallToolResult | undefined; + let answeredAt = createdAt; return createTask( { stateAt: (): TaskState => summary === undefined ? { + since: createdAt, status: "input_required", statusMessage: "Waiting for the user's input", inputRequests: { form: inputRequired.elicit({ message: "Please provide inputs for the following fields (async task demo):", - requestedSchema, + requestedSchema: FormSchema, }), }, } : { + since: answeredAt, status: "completed", statusMessage: "Input received", result: summary, }, - receive: (responses) => { + receive: (responses, now) => { const answer = inputResponse(responses, "form"); - if (answer.kind === "elicit") { - summary = summarize(answer.action, answer.content); + if (answer.kind !== "elicit") return; + if (answer.action !== "accept") { + summary = summarize(answer.action); + } else { + const content = acceptedContent(responses, "form", FormSchema); + // An accepted answer that does not fit the form is ignored, so + // the task keeps waiting for one that does. + if (content === undefined) return; + summary = summarize("accept", content); } + answeredAt = now; }, }, { ttlMs: 10 * 60_000, pollIntervalMs: 1000 }, diff --git a/src/everything/tools/trigger-sampling-request-async.ts b/src/everything/tools/trigger-sampling-request-async.ts index 349c0faba7..11608e25a0 100644 --- a/src/everything/tools/trigger-sampling-request-async.ts +++ b/src/everything/tools/trigger-sampling-request-async.ts @@ -19,6 +19,8 @@ import { } from "@modelcontextprotocol/server"; import { z } from "zod"; import { + capabilityRequired, + clientSupportsSampling, clientSupportsTasks, createTask, tasksRequired, @@ -73,15 +75,21 @@ export const registerTriggerSamplingRequestAsyncTool = (server: McpServer) => { config, async (args, ctx): Promise => { if (!clientSupportsTasks(ctx)) return tasksRequired(name); + if (!clientSupportsSampling(ctx)) { + return capabilityRequired(name, "sampling"); + } const { prompt, maxTokens } = TriggerSamplingRequestAsyncSchema.parse(args); + const createdAt = Date.now(); let result: SamplingResult | undefined; + let answeredAt = createdAt; return createTask( { stateAt: (): TaskState => result === undefined ? { + since: createdAt, status: "input_required", statusMessage: "Waiting for the client's LLM to respond", inputRequests: { @@ -102,6 +110,7 @@ export const registerTriggerSamplingRequestAsyncTool = (server: McpServer) => { }, } : { + since: answeredAt, status: "completed", statusMessage: "Sampling complete", result: { @@ -113,9 +122,11 @@ export const registerTriggerSamplingRequestAsyncTool = (server: McpServer) => { ], }, }, - receive: (responses) => { + receive: (responses, now) => { const answer = inputResponse(responses, "sampling"); - if (answer.kind === "sampling") result = answer.result; + if (answer.kind !== "sampling") return; + result = answer.result; + answeredAt = now; }, }, { ttlMs: 10 * 60_000, pollIntervalMs: 1000 }, diff --git a/src/everything/transports/stdio.ts b/src/everything/transports/stdio.ts index fbc24d4c78..8cfb56ca58 100644 --- a/src/everything/transports/stdio.ts +++ b/src/everything/transports/stdio.ts @@ -58,7 +58,13 @@ export async function main( }, { transport: new StdioServerTransport(stdin, stdout), - onerror: (error) => console.error("Transport error:", error), + // serveStdio reports a transport that fails, including one that cannot + // start, here rather than by rejecting, so record the failure in the + // exit status for whatever supervises the process. + onerror: (error) => { + console.error("Transport error:", error); + process.exitCode = 1; + }, }, ); diff --git a/src/filesystem/__tests__/eras.test.ts b/src/filesystem/__tests__/eras.test.ts index 025ce11ca5..90d6f43d4e 100644 --- a/src/filesystem/__tests__/eras.test.ts +++ b/src/filesystem/__tests__/eras.test.ts @@ -177,6 +177,9 @@ describe("main() over stdio", () => { const failure = new Error("stdin broke"); stdin.emit("error", failure); expect(stderr).toHaveBeenCalledWith("Transport error:", failure); + // The failure is in the exit status too; reset it so the run is not failed. + expect(process.exitCode).toBe(1); + process.exitCode = undefined; }); it("fails each 2026-07-28 call visibly when started with no directories", async () => { diff --git a/src/filesystem/index.ts b/src/filesystem/index.ts index 52333497db..ad4efda42b 100644 --- a/src/filesystem/index.ts +++ b/src/filesystem/index.ts @@ -121,7 +121,13 @@ export async function main( }), { transport: new StdioServerTransport(stdin, stdout), - onerror: (error) => console.error("Transport error:", error), + // serveStdio reports a transport that fails, including one that cannot + // start, here rather than by rejecting, so record the failure in the + // exit status for whatever supervises the process. + onerror: (error) => { + console.error("Transport error:", error); + process.exitCode = 1; + }, }, ); console.error("Secure MCP Filesystem Server running on stdio"); diff --git a/src/memory/__tests__/eras.test.ts b/src/memory/__tests__/eras.test.ts index b9097c1a73..cb77369240 100644 --- a/src/memory/__tests__/eras.test.ts +++ b/src/memory/__tests__/eras.test.ts @@ -130,6 +130,18 @@ describe("both protocol eras", () => { await expect(read).rejects.toMatchObject({ code: -32602 }); }); + it("reports a transport that cannot start on stderr and in the exit status", async () => { + const [, serverTransport] = InMemoryTransport.createLinkedPair(); + const failure = new Error("no pipe"); + vi.spyOn(serverTransport, "start").mockRejectedValue(failure); + const handle = await main(serverTransport); + await handle.close(); + expect(console.error).toHaveBeenCalledWith("Transport error:", failure); + // Reset it so the run is not failed. + expect(process.exitCode).toBe(1); + process.exitCode = undefined; + }); + it("stamps resultType and the cache fields on a 2026-07-28 read", async () => { const [peer, serverTransport] = InMemoryTransport.createLinkedPair(); const handle = await main(serverTransport); diff --git a/src/memory/index.ts b/src/memory/index.ts index 5d3bdb6273..d9a135de82 100644 --- a/src/memory/index.ts +++ b/src/memory/index.ts @@ -1184,7 +1184,13 @@ export async function main(transport?: Transport): Promise { const memoryFilePath = await ensureMemoryFilePath(); const handle = serveStdio(({ era }) => createServer(memoryFilePath, era), { transport, - onerror: (error) => console.error("Transport error:", error), + // serveStdio reports a transport that fails, including one that cannot + // start, here rather than by rejecting, so record the failure in the + // exit status for whatever supervises the process. + onerror: (error) => { + console.error("Transport error:", error); + process.exitCode = 1; + }, }); console.error("Knowledge Graph MCP Server running on stdio"); return handle; diff --git a/src/sequentialthinking/__tests__/entry-point.test.ts b/src/sequentialthinking/__tests__/entry-point.test.ts index 9a44fdff6a..96407c6329 100644 --- a/src/sequentialthinking/__tests__/entry-point.test.ts +++ b/src/sequentialthinking/__tests__/entry-point.test.ts @@ -53,13 +53,16 @@ describe("main", () => { } }); - it("reports a transport that cannot start on stderr", async () => { + it("reports a transport that cannot start on stderr and in the exit status", async () => { const [, serverTransport] = InMemoryTransport.createLinkedPair(); const failure = new Error("no pipe"); vi.spyOn(serverTransport, "start").mockRejectedValue(failure); const handle = main(serverTransport); await handle.close(); expect(errorSpy).toHaveBeenCalledWith("Transport error:", failure); + // The failure is in the exit status too; reset it so the run is not failed. + expect(process.exitCode).toBe(1); + process.exitCode = undefined; }); }); diff --git a/src/sequentialthinking/index.ts b/src/sequentialthinking/index.ts index bf0b63749f..c7015df353 100644 --- a/src/sequentialthinking/index.ts +++ b/src/sequentialthinking/index.ts @@ -191,7 +191,13 @@ You should: export function main(transport?: Transport): StdioServerHandle { const handle = serveStdio(() => createServer(), { transport, - onerror: (error) => console.error("Transport error:", error), + // serveStdio reports a transport that fails, including one that cannot + // start, here rather than by rejecting, so record the failure in the + // exit status for whatever supervises the process. + onerror: (error) => { + console.error("Transport error:", error); + process.exitCode = 1; + }, }); console.error("Sequential Thinking MCP Server running on stdio"); return handle; From d19037ad4d1c0d050d17f5c49fb66280d9b4cd44 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 11 Oct 2026 23:22:33 -0400 Subject: [PATCH 077/117] fix(everything): refuse task tools and tasks/* without the extension with -32021 Copilot round 2 on #5131: the tasks extension requires a -32021 error, with the extension in data.requiredCapabilities, for a client that does not declare it, both on a request that cannot be served without a task handle and on tasks/get, tasks/update and tasks/cancel. McpServer reports a thrown error from a tool handler as an isError result, so guardTaskTools wraps the tools/call handler McpServer installs; the factory no longer declares the tools capability in the constructor (the first registerTool advertises the same one), so the handler is installed after the guard. The sampling and form-elicitation requirements of the async task tools are answered the same way, replacing the isError results added in round 1. Part of #4852 Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/everything/__tests__/tasks.test.ts | 131 ++++++++-------- src/everything/docs/features.md | 2 +- src/everything/server/index.ts | 17 +- src/everything/server/tasks.ts | 147 +++++++++++++----- .../tools/simulate-research-query.ts | 8 +- .../trigger-elicitation-request-async.ts | 104 ++++++------- .../tools/trigger-sampling-request-async.ts | 120 +++++++------- 7 files changed, 290 insertions(+), 239 deletions(-) diff --git a/src/everything/__tests__/tasks.test.ts b/src/everything/__tests__/tasks.test.ts index d619e66f48..46da78376a 100644 --- a/src/everything/__tests__/tasks.test.ts +++ b/src/everything/__tests__/tasks.test.ts @@ -78,6 +78,76 @@ describe("the extension", () => { }); }); + it.each([ + ["simulate-research-query", {}, { extensions: { [TASKS_EXTENSION]: {} } }], + [ + "trigger-sampling-request-async", + { sampling: {} }, + { extensions: { [TASKS_EXTENSION]: {} } }, + ], + ["trigger-sampling-request-async", TASKS, { sampling: {} }], + [ + "trigger-sampling-request-async", + {}, + { extensions: { [TASKS_EXTENSION]: {} }, sampling: {} }, + ], + [ + "trigger-elicitation-request-async", + { elicitation: { form: {} } }, + { extensions: { [TASKS_EXTENSION]: {} } }, + ], + ["trigger-elicitation-request-async", TASKS, { elicitation: { form: {} } }], + [ + "trigger-elicitation-request-async", + { ...TASKS, elicitation: { url: {} } }, + { elicitation: { form: {} } }, + ], + ] as const)( + "refuses %s from a client declaring %j with -32021 naming what is missing", + async (tool, capabilities, requiredCapabilities) => { + peer = await rawClient(capabilities); + const response = await peer.request("tools/call", { + name: tool, + arguments: + tool === "trigger-elicitation-request-async" + ? {} + : tool === "simulate-research-query" + ? { topic: "t" } + : { prompt: "p" }, + }); + expect(response.error).toMatchObject({ + code: -32021, + data: { requiredCapabilities }, + }); + }, + ); + + it.each([ + ["tasks/get", {}], + ["tasks/update", { inputResponses: {} }], + ["tasks/cancel", {}], + ])( + "refuses %s from a client without the extension with -32021", + async (method, extra) => { + peer = await rawClient(TASKS); + const created = await peer.call("simulate-research-query", { + topic: "t", + }); + await peer.close(); + peer = await rawClient({}); + const response = await peer.request(method, { + taskId: created.taskId, + ...extra, + }); + expect(response.error).toMatchObject({ + code: -32021, + data: { + requiredCapabilities: { extensions: { [TASKS_EXTENSION]: {} } }, + }, + }); + }, + ); + it("drops a task once its TTL has passed", async () => { peer = await rawClient(TASKS); const created = await peer.call("simulate-research-query", { topic: "t" }); @@ -162,22 +232,6 @@ describe("simulate-research-query", () => { expect((await peer.get(created.taskId as string)).status).toBe("completed"); }); - it("is a tool error for a client that does not declare the extension", async () => { - peer = await rawClient({}); - const result = await peer.call("simulate-research-query", { topic: "t" }); - expect(result).toMatchObject({ - isError: true, - content: [ - { - type: "text", - text: expect.stringContaining( - `needs a client that declares the ${TASKS_EXTENSION} extension`, - ), - }, - ], - }); - }); - it("ignores 'ambiguous' for a client without elicitation", async () => { peer = await rawClient(TASKS); const created = await peer.call("simulate-research-query", { @@ -407,21 +461,6 @@ describe("trigger-sampling-request-async", () => { expect(resultText(done)).toContain('"text": "hi"'); }); - it("is a tool error for a client that does not declare sampling", async () => { - peer = await rawClient(TASKS); - const result = await peer.call("trigger-sampling-request-async", { - prompt: "p", - }); - expect(result).toMatchObject({ - isError: true, - content: [ - { - text: "trigger-sampling-request-async asks the client for input, so it needs a client that declares the sampling capability.", - }, - ], - }); - }); - it("keeps waiting after an answer of the wrong kind", async () => { peer = await rawClient({ ...TASKS, sampling: {} }); const created = await peer.call("trigger-sampling-request-async", { @@ -435,14 +474,6 @@ describe("trigger-sampling-request-async", () => { "input_required", ); }); - - it("is a tool error for a client that does not declare the extension", async () => { - peer = await rawClient({ sampling: {} }); - const result = await peer.call("trigger-sampling-request-async", { - prompt: "p", - }); - expect(result.isError).toBe(true); - }); }); describe("trigger-elicitation-request-async", () => { @@ -520,22 +551,6 @@ describe("trigger-elicitation-request-async", () => { }, ); - it.each([ - ["only the tasks extension", TASKS], - ["URL-mode elicitation only", { ...TASKS, elicitation: { url: {} } }], - ])("is a tool error for a client with %s", async (_label, capabilities) => { - peer = await rawClient(capabilities); - const result = await peer.call("trigger-elicitation-request-async"); - expect(result).toMatchObject({ - isError: true, - content: [ - { - text: "trigger-elicitation-request-async asks the client for input, so it needs a client that declares the elicitation (form mode) capability.", - }, - ], - }); - }); - it.each(["decline", "cancel"])("reports a %s", async (action) => { const done = await answer({ action }); expect(resultText(done)).toBe(`Elicitation ${action}.`); @@ -552,10 +567,4 @@ describe("trigger-elicitation-request-async", () => { "input_required", ); }); - - it("is a tool error for a client that does not declare the extension", async () => { - peer = await rawClient({ elicitation: {} }); - const result = await peer.call("trigger-elicitation-request-async"); - expect(result.isError).toBe(true); - }); }); diff --git a/src/everything/docs/features.md b/src/everything/docs/features.md index 282258fac4..f62a71d703 100644 --- a/src/everything/docs/features.md +++ b/src/everything/docs/features.md @@ -94,6 +94,6 @@ Logging is deprecated as of 2026-07-28 (SEP-2577) and remains available on both - `tasks/update`: the client's `inputResponses` to the outstanding input requests; - `tasks/cancel`: stop a task that has not finished. -An unknown or expired task, an update to a task that is not waiting for input or that answers a request it did not make, and a cancel of a finished task are refused with `-32602`. The task id is a server-minted UUID, so a task lives across requests (each 2026-07-28 HTTP request is served by a fresh server instance) and is reached only through its handle. A task's progress is computed from the clock when it is read, so nothing runs in the background. +A task tool called by a client whose request does not declare the extension, or the capability its task asks for input with (`sampling` for `trigger-sampling-request-async`, form-mode `elicitation` for `trigger-elicitation-request-async`), is refused with `-32021` (missing required client capability), naming what is missing in `data.requiredCapabilities`; so is a `tasks/*` request from a client without the extension. An unknown or expired task, an update to a task that is not waiting for input or that answers a request it did not make, and a cancel of a finished task are refused with `-32602`. The task id is a server-minted UUID, so a task lives across requests (each 2026-07-28 HTTP request is served by a fresh server instance) and is reached only through its handle. A task's progress is computed from the clock when it is read, so nothing runs in the background. The 2025-11-25 core task methods (`tasks/result`, `tasks/list`, task-augmented requests) are not served: SDK v2 removed the experimental layer they were built on. diff --git a/src/everything/server/index.ts b/src/everything/server/index.ts index c6d4968bb3..ee56f48eb1 100644 --- a/src/everything/server/index.ts +++ b/src/everything/server/index.ts @@ -33,7 +33,11 @@ import { } from "../tools/index.js"; import { registerResources, readInstructions } from "../resources/index.js"; import { registerPrompts } from "../prompts/index.js"; -import { registerTasksExtension, TASKS_EXTENSION } from "./tasks.js"; +import { + guardTaskTools, + registerTasksExtension, + TASKS_EXTENSION, +} from "./tasks.js"; import { syncRoots } from "./roots.js"; import { instancePublisher, @@ -127,9 +131,10 @@ export const createServer = ( }, { capabilities: { - tools: { - listChanged: true, - }, + // `tools: { listChanged: true }` is advertised by the first + // registerTool. Declaring it here would install McpServer's + // tools/call handler in the constructor, before guardTaskTools can + // wrap it. prompts: { listChanged: true, }, @@ -167,6 +172,10 @@ export const createServer = ( onCleanup: (fn) => cleanups.push(fn), }; + // A task tool refuses, with -32021, a client that has not declared what it + // needs; this wraps the tools/call handler the first registration installs. + if (era === "modern") guardTaskTools(server); + // Register the tools, resources and prompts every era gets registerTools(server, context); registerResources(server, context); diff --git a/src/everything/server/tasks.ts b/src/everything/server/tasks.ts index dbb612e487..9bd632c3a8 100644 --- a/src/everything/server/tasks.ts +++ b/src/everything/server/tasks.ts @@ -24,9 +24,11 @@ import { randomUUID } from "node:crypto"; import { CLIENT_CAPABILITIES_META_KEY, McpServer, + MissingRequiredClientCapabilityError, ProtocolError, ProtocolErrorCode, type CallToolResult, + type ClientCapabilities, type InputRequests, type ServerContext, } from "@modelcontextprotocol/server"; @@ -206,31 +208,6 @@ export function clientSupportsFormElicitation(ctx: ServerContext): boolean { ); } -/** Whether the client that sent this request supports sampling. */ -export function clientSupportsSampling(ctx: ServerContext): boolean { - return clientCapabilitiesOf(ctx)?.sampling !== undefined; -} - -/** - * The result for a task tool whose task would ask the client for input it - * did not declare it can give. The SDK checks a tool's own input requests, - * but not the ones a task carries in its status, so the tool checks here. - */ -export function capabilityRequired( - name: string, - capability: string, -): CallToolResult { - return { - content: [ - { - type: "text", - text: `${name} asks the client for input, so it needs a client that declares the ${capability} capability.`, - }, - ], - isError: true, - }; -} - /** * Whether the client that sent this request declared the tasks extension, so * it can follow a task handle. A tool may answer with a task only then. @@ -244,17 +221,101 @@ function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -/** The result for a task tool called by a client without the extension. */ -export function tasksRequired(name: string): CallToolResult { - return { - content: [ - { - type: "text", - text: `${name} runs as a task, so it needs a client that declares the ${TASKS_EXTENSION} extension in its capabilities.`, - }, - ], - isError: true, - }; +/** The tasks extension, as a client declares it. */ +const TASKS_CAPABILITY: ClientCapabilities = { + extensions: { [TASKS_EXTENSION]: {} }, +}; + +/** + * What each task tool needs the client to have declared: the tasks + * extension, to follow the handle, and the capability its task asks for + * input with. The SDK checks the input requests a tool returns itself, but + * not the ones a task carries in its status, so they are checked here. + */ +const TASK_TOOL_REQUIREMENTS: Record< + string, + (ctx: ServerContext) => ClientCapabilities | undefined +> = { + "simulate-research-query": (ctx) => missingTasks(ctx), + "trigger-sampling-request-async": (ctx) => + merged( + missingTasks(ctx), + clientCapabilitiesOf(ctx)?.sampling === undefined + ? { sampling: {} } + : undefined, + ), + "trigger-elicitation-request-async": (ctx) => + merged( + missingTasks(ctx), + clientSupportsFormElicitation(ctx) + ? undefined + : { elicitation: { form: {} } }, + ), +}; + +/** The tasks extension, if this request's client did not declare it. */ +function missingTasks(ctx: ServerContext): ClientCapabilities | undefined { + return clientSupportsTasks(ctx) ? undefined : TASKS_CAPABILITY; +} + +/** Both sets of missing capabilities, or undefined when neither is. */ +function merged( + a: ClientCapabilities | undefined, + b: ClientCapabilities | undefined, +): ClientCapabilities | undefined { + return a || b ? { ...a, ...b } : undefined; +} + +/** The -32021 error naming the capabilities the client did not declare. */ +function missingCapabilities( + requiredCapabilities: ClientCapabilities, +): MissingRequiredClientCapabilityError { + return new MissingRequiredClientCapabilityError( + { requiredCapabilities }, + `Missing required client capability: ${Object.keys(requiredCapabilities).join(", ")}`, + ); +} + +/** + * Refuse a call to a task tool from a client that did not declare what the + * tool needs, with the JSON-RPC error -32021 naming it, as the extension + * requires: such a client cannot be served without a task handle. + * + * `McpServer` reports anything a tool handler throws as an `isError` tool + * result, so the check cannot live in the tools. Instead it wraps the + * `tools/call` handler `McpServer` installs, which the SDK then runs behind + * its own era checks, so the error reaches the client as an error. Call this + * before the first tool is registered, on a server constructed without a + * `tools` capability: `McpServer` installs the handler at the first + * registration then, or in its constructor when `tools` is declared. + * + * @param {McpServer} server - The 2026-07-28 server whose task tools to guard. + */ +export function guardTaskTools(server: McpServer): void { + const lowLevel = server.server; + const setRequestHandler = lowLevel.setRequestHandler.bind(lowLevel); + // The SDK's overloads cannot be restated for a wrapper; this one only + // inspects the arguments and passes them through unchanged. + lowLevel.setRequestHandler = ((...args: unknown[]) => { + const handler = args.at(-1); + if (args[0] === "tools/call" && typeof handler === "function") { + args[args.length - 1] = ( + request: { params: { name: string } }, + ctx: ServerContext, + ) => { + const missing = TASK_TOOL_REQUIREMENTS[request.params.name]?.(ctx); + if (missing) throw missingCapabilities(missing); + return handler(request, ctx); + }; + } + return (setRequestHandler as (...a: unknown[]) => unknown)(...args); + }) as typeof lowLevel.setRequestHandler; +} + +/** Refuse a tasks/* request from a client without the extension (-32021). */ +function requireTasks(ctx: ServerContext): void { + const missing = missingTasks(ctx); + if (missing) throw missingCapabilities(missing); } /** Forget every task. For tests. */ @@ -271,9 +332,10 @@ const EmptyResult = z.object({}); /** * Serve the extension's methods on a 2026-07-28 server: `tasks/get`, - * `tasks/update` and `tasks/cancel`. An unknown or expired task id, and an - * update or cancellation that does not fit the task's status, is refused - * with -32602 (invalid params). + * `tasks/update` and `tasks/cancel`. A request from a client that does not + * declare the extension is refused with -32021. An unknown or expired task + * id, and an update or cancellation that does not fit the task's status, is + * refused with -32602 (invalid params). * * @param {McpServer} server - The 2026-07-28 server to serve them on. */ @@ -281,7 +343,8 @@ export function registerTasksExtension(server: McpServer): void { server.server.setRequestHandler( "tasks/get", { params: TaskIdParams, result: TaskResult }, - async ({ taskId }) => { + async ({ taskId }, ctx) => { + requireTasks(ctx); const now = Date.now(); return describe(find(taskId, now), now); }, @@ -291,6 +354,7 @@ export function registerTasksExtension(server: McpServer): void { "tasks/update", { params: TaskIdParams, result: EmptyResult }, async ({ taskId }, ctx) => { + requireTasks(ctx); // The SDK lifts `inputResponses` out of the params of every request // (it is a reserved retry field on 2026-07-28) into the context. const inputResponses: TaskInputResponses = @@ -322,7 +386,8 @@ export function registerTasksExtension(server: McpServer): void { server.server.setRequestHandler( "tasks/cancel", { params: TaskIdParams, result: EmptyResult }, - async ({ taskId }) => { + async ({ taskId }, ctx) => { + requireTasks(ctx); const now = Date.now(); const task = find(taskId, now); const { status } = stateOf(task, now); diff --git a/src/everything/tools/simulate-research-query.ts b/src/everything/tools/simulate-research-query.ts index 1cd2f41930..aef882d0d0 100644 --- a/src/everything/tools/simulate-research-query.ts +++ b/src/everything/tools/simulate-research-query.ts @@ -24,9 +24,7 @@ import { import { z } from "zod"; import { clientSupportsFormElicitation, - clientSupportsTasks, createTask, - tasksRequired, type TaskInputResponses, type TaskState, } from "../server/tasks.js"; @@ -176,8 +174,9 @@ carrying an elicitation request. The client answered it with \`tasks/update\` /** * Registers the 'simulate-research-query' tool, for 2026-07-28 clients. * - * A client that does not declare the tasks extension gets an error result, - * since it could not follow the task handle. `ambiguous` is ignored for a + * A client that does not declare the tasks extension is refused with -32021 + * before the tool runs (`guardTaskTools`), since it could not follow the task + * handle. `ambiguous` is ignored for a * client that does not support elicitation. * * @param {McpServer} server - The McpServer instance where the tool will be registered. @@ -187,7 +186,6 @@ export const registerSimulateResearchQueryTool = (server: McpServer) => { name, config, async (args, ctx): Promise => { - if (!clientSupportsTasks(ctx)) return tasksRequired(name); const { topic, ambiguous } = SimulateResearchQuerySchema.parse(args); // The clarification is a form, so only a form-capable client is asked. const canAsk = clientSupportsFormElicitation(ctx); diff --git a/src/everything/tools/trigger-elicitation-request-async.ts b/src/everything/tools/trigger-elicitation-request-async.ts index 88cbdd5b71..ad3c8a3ac3 100644 --- a/src/everything/tools/trigger-elicitation-request-async.ts +++ b/src/everything/tools/trigger-elicitation-request-async.ts @@ -17,14 +17,7 @@ import { inputResponse, } from "@modelcontextprotocol/server"; import { z } from "zod"; -import { - capabilityRequired, - clientSupportsFormElicitation, - clientSupportsTasks, - createTask, - tasksRequired, - type TaskState, -} from "../server/tasks.js"; +import { createTask, type TaskState } from "../server/tasks.js"; // Tool configuration const name = "trigger-elicitation-request-async"; @@ -88,64 +81,57 @@ function summarize( * Registers the 'trigger-elicitation-request-async' tool, for 2026-07-28 * clients. * - * A client that does not declare the tasks extension gets an error result. + * A client that does not declare both the tasks extension and form-mode + * elicitation is refused with -32021 before the tool runs (`guardTaskTools`). * * @param {McpServer} server - The McpServer instance where the tool will be registered. */ export const registerTriggerElicitationRequestAsyncTool = ( server: McpServer, ) => { - server.registerTool( - name, - config, - async (_args, ctx): Promise => { - if (!clientSupportsTasks(ctx)) return tasksRequired(name); - if (!clientSupportsFormElicitation(ctx)) { - return capabilityRequired(name, "elicitation (form mode)"); - } - const createdAt = Date.now(); - let summary: CallToolResult | undefined; - let answeredAt = createdAt; + server.registerTool(name, config, async (): Promise => { + const createdAt = Date.now(); + let summary: CallToolResult | undefined; + let answeredAt = createdAt; - return createTask( - { - stateAt: (): TaskState => - summary === undefined - ? { - since: createdAt, - status: "input_required", - statusMessage: "Waiting for the user's input", - inputRequests: { - form: inputRequired.elicit({ - message: - "Please provide inputs for the following fields (async task demo):", - requestedSchema: FormSchema, - }), - }, - } - : { - since: answeredAt, - status: "completed", - statusMessage: "Input received", - result: summary, + return createTask( + { + stateAt: (): TaskState => + summary === undefined + ? { + since: createdAt, + status: "input_required", + statusMessage: "Waiting for the user's input", + inputRequests: { + form: inputRequired.elicit({ + message: + "Please provide inputs for the following fields (async task demo):", + requestedSchema: FormSchema, + }), }, - receive: (responses, now) => { - const answer = inputResponse(responses, "form"); - if (answer.kind !== "elicit") return; - if (answer.action !== "accept") { - summary = summarize(answer.action); - } else { - const content = acceptedContent(responses, "form", FormSchema); - // An accepted answer that does not fit the form is ignored, so - // the task keeps waiting for one that does. - if (content === undefined) return; - summary = summarize("accept", content); - } - answeredAt = now; - }, + } + : { + since: answeredAt, + status: "completed", + statusMessage: "Input received", + result: summary, + }, + receive: (responses, now) => { + const answer = inputResponse(responses, "form"); + if (answer.kind !== "elicit") return; + if (answer.action !== "accept") { + summary = summarize(answer.action); + } else { + const content = acceptedContent(responses, "form", FormSchema); + // An accepted answer that does not fit the form is ignored, so + // the task keeps waiting for one that does. + if (content === undefined) return; + summary = summarize("accept", content); + } + answeredAt = now; }, - { ttlMs: 10 * 60_000, pollIntervalMs: 1000 }, - ); - }, - ); + }, + { ttlMs: 10 * 60_000, pollIntervalMs: 1000 }, + ); + }); }; diff --git a/src/everything/tools/trigger-sampling-request-async.ts b/src/everything/tools/trigger-sampling-request-async.ts index 11608e25a0..63f81b6f1e 100644 --- a/src/everything/tools/trigger-sampling-request-async.ts +++ b/src/everything/tools/trigger-sampling-request-async.ts @@ -18,14 +18,7 @@ import { type InputResponseView, } from "@modelcontextprotocol/server"; import { z } from "zod"; -import { - capabilityRequired, - clientSupportsSampling, - clientSupportsTasks, - createTask, - tasksRequired, - type TaskState, -} from "../server/tasks.js"; +import { createTask, type TaskState } from "../server/tasks.js"; // Tool input schema const TriggerSamplingRequestAsyncSchema = z.object({ @@ -63,74 +56,65 @@ const config = { /** * Registers the 'trigger-sampling-request-async' tool, for 2026-07-28 clients. * - * A client that does not declare the tasks extension gets an error result. - * The SDK checks the sampling capability when the client fetches the - * request, not here: the request travels inside the task's status. + * A client that does not declare both the tasks extension and sampling is + * refused with -32021 before the tool runs (`guardTaskTools`): the SDK does + * not check the requests a task carries in its status. * * @param {McpServer} server - The McpServer instance where the tool will be registered. */ export const registerTriggerSamplingRequestAsyncTool = (server: McpServer) => { - server.registerTool( - name, - config, - async (args, ctx): Promise => { - if (!clientSupportsTasks(ctx)) return tasksRequired(name); - if (!clientSupportsSampling(ctx)) { - return capabilityRequired(name, "sampling"); - } - const { prompt, maxTokens } = - TriggerSamplingRequestAsyncSchema.parse(args); - const createdAt = Date.now(); - let result: SamplingResult | undefined; - let answeredAt = createdAt; + server.registerTool(name, config, async (args): Promise => { + const { prompt, maxTokens } = TriggerSamplingRequestAsyncSchema.parse(args); + const createdAt = Date.now(); + let result: SamplingResult | undefined; + let answeredAt = createdAt; - return createTask( - { - stateAt: (): TaskState => - result === undefined - ? { - since: createdAt, - status: "input_required", - statusMessage: "Waiting for the client's LLM to respond", - inputRequests: { - sampling: inputRequired.createMessage({ - messages: [ - { - role: "user", - content: { - type: "text", - text: `Resource ${name} context: ${prompt}`, - }, - }, - ], - systemPrompt: "You are a helpful test server.", - maxTokens, - temperature: 0.7, - }), - }, - } - : { - since: answeredAt, - status: "completed", - statusMessage: "Sampling complete", - result: { - content: [ + return createTask( + { + stateAt: (): TaskState => + result === undefined + ? { + since: createdAt, + status: "input_required", + statusMessage: "Waiting for the client's LLM to respond", + inputRequests: { + sampling: inputRequired.createMessage({ + messages: [ { - type: "text", - text: `LLM sampling result: \n${JSON.stringify(result, null, 2)}`, + role: "user", + content: { + type: "text", + text: `Resource ${name} context: ${prompt}`, + }, }, ], - }, + systemPrompt: "You are a helpful test server.", + maxTokens, + temperature: 0.7, + }), + }, + } + : { + since: answeredAt, + status: "completed", + statusMessage: "Sampling complete", + result: { + content: [ + { + type: "text", + text: `LLM sampling result: \n${JSON.stringify(result, null, 2)}`, + }, + ], }, - receive: (responses, now) => { - const answer = inputResponse(responses, "sampling"); - if (answer.kind !== "sampling") return; - result = answer.result; - answeredAt = now; - }, + }, + receive: (responses, now) => { + const answer = inputResponse(responses, "sampling"); + if (answer.kind !== "sampling") return; + result = answer.result; + answeredAt = now; }, - { ttlMs: 10 * 60_000, pollIntervalMs: 1000 }, - ); - }, - ); + }, + { ttlMs: 10 * 60_000, pollIntervalMs: 1000 }, + ); + }); }; From c059fb65637b5a4250dd1b0e91dc883e63e2f178 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 11 Oct 2026 23:23:26 -0400 Subject: [PATCH 078/117] chore(release-notes): put the release-ledger line under the Packages table The ledger line came after every package's PR list, the Repository section and GitHub's trailer, so in v1.0.0 it sat below 102 PR lines and was moved by hand before publishing. assembleNotes() now emits it directly after the Packages table; the file header, the tests and the release skill's step 5a list follow. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .claude/skills/release/SKILL.md | 10 ++++++---- scripts/release-notes.mjs | 6 ++++-- scripts/release-notes.test.mjs | 13 +++++++++---- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 99090c68c2..5cf4bd5180 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -437,14 +437,16 @@ what changed in the server they use: whether this release publishes it (the registry does not have that version yet) or leaves it unchanged. The tag is the milestone's name, not a version, so this table is where a reader finds the versions. -2. **One `## ` section per published package**, whose +2. **The release-ledger line**, linking the artifact from 4d, directly under + the table, so what was published and the evidence behind it are read + together. +3. **One `## ` section per published package**, whose `### What's Changed` lists the PRs that change files under its `src//`. A PR that touches several servers is listed under each. -3. **`## Repository`**: every PR no published section took (CI, docs, skills, +4. **`## Repository`**: every PR no published section took (CI, docs, skills, a server whose version did not change), so no PR in the generated list is dropped. -4. GitHub's **New Contributors** block and **Full Changelog** link, verbatim. -5. **The release-ledger line**, linking the artifact from 4d. +5. GitHub's **New Contributors** block and **Full Changelog** link, verbatim. 6. **`## Known issues`**, only when you pass one. It names the issue, who is affected and the workaround. What counts as a known issue is a maintainer's judgment, so it is written by hand, never generated. diff --git a/scripts/release-notes.mjs b/scripts/release-notes.mjs index 913ad6f474..73ba9d7cba 100644 --- a/scripts/release-notes.mjs +++ b/scripts/release-notes.mjs @@ -11,13 +11,15 @@ // // ## Packages every package, its version, and whether // this release publishes it (registry check) +// Release ledger: []() right under the table, so what was +// published and the evidence behind it are +// read together, not below every PR list // ## one per PUBLISHED package, with its // ### What's Changed PRs // ## Repository every PR left over (CI, docs, skills, a // server whose version did not change), so // no PR in the generated list is dropped // ## New Contributors / Full Changelog GitHub's own trailer, verbatim -// Release ledger: []() // ## Known issues only when passed in: a maintainer's call // ## Thanks for helping us improve one release-wide list of the community // members whose issues the PRs close, laid @@ -569,9 +571,9 @@ export function assembleNotes({ return ( [ packages, + `Release ledger: [${mergeBranch}](${ledgerUrl})`, ...sections, trailer, - `Release ledger: [${mergeBranch}](${ledgerUrl})`, formatKnownIssues(knownIssues), thanks, ] diff --git a/scripts/release-notes.test.mjs b/scripts/release-notes.test.mjs index 5cfaf197fc..64f566eaec 100644 --- a/scripts/release-notes.test.mjs +++ b/scripts/release-notes.test.mjs @@ -287,7 +287,7 @@ test("formatKnownIssues pluralizes and is omitted when empty", () => { assert.equal(formatKnownIssues(["a", "b"]), "## Known issues\n\na\n\nb"); }); -test("assembleNotes: packages, sections, trailer, ledger, known issues, thanks", () => { +test("assembleNotes: packages, ledger, sections, trailer, known issues, thanks", () => { assert.equal( assembleNotes({ packages: "P", @@ -298,7 +298,7 @@ test("assembleNotes: packages, sections, trailer, ledger, known issues, thanks", knownIssues: ["K"], thanks: "## Thanks for helping us improve\n\nX", }), - "P\n\nS1\n\nS2\n\nT\n\nRelease ledger: [mb](https://l)\n\n## Known issue\n\nK\n\n## Thanks for helping us improve\n\nX\n", + "P\n\nRelease ledger: [mb](https://l)\n\nS1\n\nS2\n\nT\n\n## Known issue\n\nK\n\n## Thanks for helping us improve\n\nX\n", ); assert.equal( assembleNotes({ @@ -528,6 +528,13 @@ test("notes: per-server sections, every PR placed, one release-wide Thanks", asy "## Repository", "## Thanks for helping us improve", ]); + // The ledger sits directly under the Packages table (#5115), so the + // evidence for what was published is read with it, not below every PR list. + assert.match( + sectionOf(notes, "## Packages"), + /\|\n\nRelease ledger: \[v2\/chore\/1-release-v2\.0\.0\]\(https:\/\/l\.example\/x\)\n$/, + ); + assert.equal(notes.split("Release ledger:").length, 2); const git = sectionOf(notes, "## mcp-server-git 2.0.0"); assert.match(git, /change 10 .*\n\* change 11 [^\n]*\n*$/); @@ -550,8 +557,6 @@ test("notes: per-server sections, every PR placed, one release-wide Thanks", asy [ "**Full Changelog**: https://example/compare", "", - "Release ledger: [v2/chore/1-release-v2.0.0](https://l.example/x)", - "", "## Thanks for helping us improve", "", THANKS_LEAD_IN, From 4416643cd0d5aa8dd00ed11ca50af7bfcc0533d1 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 11 Oct 2026 23:33:19 -0400 Subject: [PATCH 079/117] fix(everything): tasks/update ignores non-outstanding answers; bound minted resources Copilot round 3 on #5131: - minted resources: the process keeps at most 100 and 64 MiB of content, dropping the oldest, so a burst of gzip-file-as-resource calls cannot exhaust memory before the ten-minute TTL frees anything. - tasks/update ignores answers to requests that are not outstanding (never issued, already answered, or sent while the task is not waiting) instead of refusing the update, as the extension's spec says a server SHOULD. - A re-asked request gets a new key (form-2, sampling-2): the spec forbids reusing a key once a response for it was delivered. - simulate-research-query takes a clarification only if it is one of the interpretations it offered. - memory: a test pins that a 2026-07-28 resources/subscribe gets -32601 from the SDK (the round's memory finding, declined). Part of #4852 Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/everything/__tests__/modern.test.ts | 32 ++++++ src/everything/__tests__/tasks.test.ts | 106 ++++++++++++------ src/everything/docs/features.md | 4 +- src/everything/resources/minted.ts | 24 ++++ src/everything/server/tasks.ts | 36 +++--- .../tools/simulate-research-query.ts | 19 +++- .../trigger-elicitation-request-async.ts | 31 +++-- .../tools/trigger-sampling-request-async.ts | 17 ++- src/memory/__tests__/eras.test.ts | 27 +++++ 9 files changed, 220 insertions(+), 76 deletions(-) diff --git a/src/everything/__tests__/modern.test.ts b/src/everything/__tests__/modern.test.ts index be3c9862df..5b7f0ffbc2 100644 --- a/src/everything/__tests__/modern.test.ts +++ b/src/everything/__tests__/modern.test.ts @@ -37,7 +37,10 @@ import { instancePublisher } from "../server/context.js"; import { STATIC_DOCUMENT_CACHE_HINT } from "../resources/files.js"; import { clearMintedResources, + MAX_MINTED_BYTES, + MAX_MINTED_RESOURCES, MINTED_RESOURCE_TTL_MS, + mintBlobResource, } from "../resources/minted.js"; import { LOGGING_LEVELS } from "../tools/trigger-log-messages.js"; import { @@ -601,6 +604,35 @@ describe("minted resources", () => { }); }); + it("keeps at most MAX_MINTED_RESOURCES, dropping the oldest", async () => { + const first = mintBlobResource("first", "text/plain", "AA==").uri; + for (let i = 1; i < MAX_MINTED_RESOURCES; i++) { + mintBlobResource(`r${i}`, "text/plain", "AA=="); + } + session = await connectModern(); + expect(await session.client.readResource({ uri: first })).toMatchObject({ + contents: [{ uri: first }], + }); + mintBlobResource("one-more", "text/plain", "AA=="); + await expect( + session.client.readResource({ uri: first }), + ).rejects.toMatchObject({ code: -32602 }); + }); + + it("keeps at most MAX_MINTED_BYTES of content, dropping the oldest", async () => { + const half = "A".repeat(MAX_MINTED_BYTES / 2); + const first = mintBlobResource("first", "text/plain", half).uri; + const second = mintBlobResource("second", "text/plain", half).uri; + mintBlobResource("third", "text/plain", "AA=="); + session = await connectModern(); + await expect( + session.client.readResource({ uri: first }), + ).rejects.toMatchObject({ code: -32602 }); + expect( + (await session.client.readResource({ uri: second })).contents, + ).toHaveLength(1); + }); + it("is not served to a 2025-era client", async () => { session = await connect(); const { resourceTemplates } = await session.client.listResourceTemplates(); diff --git a/src/everything/__tests__/tasks.test.ts b/src/everything/__tests__/tasks.test.ts index 46da78376a..2d8cfd61de 100644 --- a/src/everything/__tests__/tasks.test.ts +++ b/src/everything/__tests__/tasks.test.ts @@ -353,32 +353,63 @@ describe("simulate-research-query", () => { ); }); - it("refuses an answer to a request it did not make", async () => { + it("ignores an answer to a request it did not make, and keeps waiting", async () => { const { taskId } = await toClarification("t"); - const response = await peer.request("tasks/update", { + expect( + await peer.result("tasks/update", { + taskId, + inputResponses: { other: { action: "accept" } }, + }), + ).toMatchObject({ resultType: "complete" }); + expect((await peer.get(taskId)).status).toBe("input_required"); + }); + + it("uses only the outstanding answer from an update that also carries others", async () => { + const { taskId } = await toClarification("python"); + await peer.result("tasks/update", { taskId, - inputResponses: { other: { action: "accept" } }, + inputResponses: { + other: { action: "decline" }, + clarification: { + action: "accept", + content: { interpretation: "snake" }, + }, + }, }); - expect(response.error).toMatchObject({ - code: -32602, - message: expect.stringContaining( - "has no outstanding input request other; it is waiting for clarification", - ), + advance(STAGE_DURATION * 2); + expect(resultText(await peer.get(taskId))).toMatch( + /^# Research Report: python \(snake\)\n/, + ); + }); + + it("does not take an interpretation it did not offer", async () => { + const { taskId } = await toClarification("python"); + await peer.result("tasks/update", { + taskId, + inputResponses: { + clarification: { + action: "accept", + content: { interpretation: "astrology" }, + }, + }, }); + advance(STAGE_DURATION * 2); + expect(resultText(await peer.get(taskId))).toContain( + "No usable answer - using default interpretation", + ); }); }); - it("refuses tasks/update while the task is working", async () => { + it("acknowledges and ignores tasks/update while the task is working", async () => { peer = await rawClient(TASKS); const created = await peer.call("simulate-research-query", { topic: "t" }); - const response = await peer.request("tasks/update", { - taskId: created.taskId, - inputResponses: {}, - }); - expect(response.error).toMatchObject({ - code: -32602, - message: expect.stringContaining("is working, not waiting for input"), - }); + expect( + await peer.result("tasks/update", { + taskId: created.taskId, + inputResponses: { clarification: { action: "accept" } }, + }), + ).toMatchObject({ resultType: "complete" }); + expect((await peer.get(created.taskId as string)).status).toBe("working"); }); it("stays cancelled once the client cancels it, and refuses a second cancel", async () => { @@ -426,7 +457,7 @@ describe("trigger-sampling-request-async", () => { statusMessage: "Waiting for the client's LLM to respond", ttlMs: 600000, inputRequests: { - sampling: { + "sampling-1": { method: "sampling/createMessage", params: { maxTokens: 100, @@ -448,7 +479,7 @@ describe("trigger-sampling-request-async", () => { await peer.result("tasks/update", { taskId, inputResponses: { - sampling: { + "sampling-1": { role: "assistant", model: "test-model", content: { type: "text", text: "hi" }, @@ -461,18 +492,18 @@ describe("trigger-sampling-request-async", () => { expect(resultText(done)).toContain('"text": "hi"'); }); - it("keeps waiting after an answer of the wrong kind", async () => { + it("asks again, under a new key, after an answer of the wrong kind", async () => { peer = await rawClient({ ...TASKS, sampling: {} }); const created = await peer.call("trigger-sampling-request-async", { prompt: "p", }); await peer.result("tasks/update", { taskId: created.taskId, - inputResponses: { sampling: { action: "accept" } }, + inputResponses: { "sampling-1": { action: "accept" } }, }); - expect((await peer.get(created.taskId as string)).status).toBe( - "input_required", - ); + const again = await peer.get(created.taskId as string); + expect(again.status).toBe("input_required"); + expect(Object.keys(again.inputRequests as object)).toEqual(["sampling-2"]); }); }); @@ -484,7 +515,7 @@ describe("trigger-elicitation-request-async", () => { status: "input_required", statusMessage: "Waiting for the user's input", inputRequests: { - form: { + "form-1": { method: "elicitation/create", params: { mode: "form", requestedSchema: { required: ["name"] } }, }, @@ -493,7 +524,7 @@ describe("trigger-elicitation-request-async", () => { const taskId = created.taskId as string; await peer.result("tasks/update", { taskId, - inputResponses: { form: response }, + inputResponses: { "form-1": response }, }); return peer.get(taskId); } @@ -524,7 +555,7 @@ describe("trigger-elicitation-request-async", () => { const answeredAt = new Date(Date.now()).toISOString(); await peer.result("tasks/update", { taskId: created.taskId, - inputResponses: { form: { action: "decline" } }, + inputResponses: { "form-1": { action: "decline" } }, }); advance(60_000); expect((await peer.get(created.taskId as string)).lastUpdatedAt).toBe( @@ -537,13 +568,21 @@ describe("trigger-elicitation-request-async", () => { ["a blank name", { name: "" }], ["a color that is not a choice", { name: "Ada", favoriteColor: "mauve" }], ])( - "keeps waiting after an accepted answer with %s", + "asks again, under a new key, after an accepted answer with %s", async (_label, content) => { peer = await rawClient(WITH_ELICITATION); const created = await peer.call("trigger-elicitation-request-async"); await peer.result("tasks/update", { taskId: created.taskId, - inputResponses: { form: { action: "accept", content } }, + inputResponses: { "form-1": { action: "accept", content } }, + }); + const again = await peer.get(created.taskId as string); + expect(again.status).toBe("input_required"); + expect(Object.keys(again.inputRequests as object)).toEqual(["form-2"]); + // The spent key is no longer outstanding, so an answer to it is ignored. + await peer.result("tasks/update", { + taskId: created.taskId, + inputResponses: { "form-1": { action: "decline" } }, }); expect((await peer.get(created.taskId as string)).status).toBe( "input_required", @@ -556,15 +595,14 @@ describe("trigger-elicitation-request-async", () => { expect(resultText(done)).toBe(`Elicitation ${action}.`); }); - it("keeps waiting after an answer of the wrong kind", async () => { + it("asks again, under a new key, after an answer of the wrong kind", async () => { peer = await rawClient(WITH_ELICITATION); const created = await peer.call("trigger-elicitation-request-async"); await peer.result("tasks/update", { taskId: created.taskId, - inputResponses: { form: { roots: [] } }, + inputResponses: { "form-1": { roots: [] } }, }); - expect((await peer.get(created.taskId as string)).status).toBe( - "input_required", - ); + const again = await peer.get(created.taskId as string); + expect(Object.keys(again.inputRequests as object)).toEqual(["form-2"]); }); }); diff --git a/src/everything/docs/features.md b/src/everything/docs/features.md index f62a71d703..7cf5ff3eb9 100644 --- a/src/everything/docs/features.md +++ b/src/everything/docs/features.md @@ -69,7 +69,7 @@ On a 2025-era connection the tools that need a client capability are registered - Dynamic Blob: `demo://resource/dynamic/blob/{index}` (base64 payload generated on the fly) - Static Documents: `demo://resource/static/document/` (serves files from `src/everything/docs/` as static file-based resources; a 2026-07-28 read carries a one-hour public cache hint) - Session Scoped, **2025-era**: `demo://resource/session/` (per-session resources registered dynamically; available only for the lifetime of the session) -- Minted, **2026-07-28**: `demo://resource/minted//` (resources a tool created, under a server-minted id; readable by any client that has the URI, for ten minutes) +- Minted, **2026-07-28**: `demo://resource/minted//` (resources a tool created, under a server-minted id; readable by any client that has the URI, for ten minutes; the process keeps at most 100 of them and 64 MiB of content, dropping the oldest first) A read of a resource that does not exist (an unknown URI, a template id that is not a positive integer, an expired minted resource) is answered with `-32602` and the URI in the error's `data`. @@ -94,6 +94,6 @@ Logging is deprecated as of 2026-07-28 (SEP-2577) and remains available on both - `tasks/update`: the client's `inputResponses` to the outstanding input requests; - `tasks/cancel`: stop a task that has not finished. -A task tool called by a client whose request does not declare the extension, or the capability its task asks for input with (`sampling` for `trigger-sampling-request-async`, form-mode `elicitation` for `trigger-elicitation-request-async`), is refused with `-32021` (missing required client capability), naming what is missing in `data.requiredCapabilities`; so is a `tasks/*` request from a client without the extension. An unknown or expired task, an update to a task that is not waiting for input or that answers a request it did not make, and a cancel of a finished task are refused with `-32602`. The task id is a server-minted UUID, so a task lives across requests (each 2026-07-28 HTTP request is served by a fresh server instance) and is reached only through its handle. A task's progress is computed from the clock when it is read, so nothing runs in the background. +A task tool called by a client whose request does not declare the extension, or the capability its task asks for input with (`sampling` for `trigger-sampling-request-async`, form-mode `elicitation` for `trigger-elicitation-request-async`), is refused with `-32021` (missing required client capability), naming what is missing in `data.requiredCapabilities`; so is a `tasks/*` request from a client without the extension. An unknown or expired task and a cancel of a finished task are refused with `-32602`. An update's answers to requests that are not outstanding (never issued, already answered, or sent while the task is not waiting for input) are ignored, as the extension asks. Each input request a task makes has its own key, never reused once answered, so a request asked again after an answer that does not fit gets a new key (`form-2`, `sampling-2`). The task id is a server-minted UUID, so a task lives across requests (each 2026-07-28 HTTP request is served by a fresh server instance) and is reached only through its handle. A task's progress is computed from the clock when it is read, so nothing runs in the background. The 2025-11-25 core task methods (`tasks/result`, `tasks/list`, task-augmented requests) are not served: SDK v2 removed the experimental layer they were built on. diff --git a/src/everything/resources/minted.ts b/src/everything/resources/minted.ts index 6b1ff87297..19375bbda5 100644 --- a/src/everything/resources/minted.ts +++ b/src/everything/resources/minted.ts @@ -24,6 +24,15 @@ import { /** How long a minted resource stays readable, in milliseconds. */ export const MINTED_RESOURCE_TTL_MS = 10 * 60_000; +/** + * The most minted resources the process keeps, and the most bytes of content + * across them. Each one may be as large as the gzip tool's fetch limit, so + * without a budget a burst of calls could exhaust memory before the TTL frees + * anything; past it, the oldest are dropped first. + */ +export const MAX_MINTED_RESOURCES = 100; +export const MAX_MINTED_BYTES = 64 * 1024 * 1024; + /** The URI template every minted resource matches. */ const mintedUriTemplate = "demo://resource/minted/{id}/{name}"; @@ -44,6 +53,20 @@ function sweep(now: number): void { } } +/** + * Drop the oldest minted resources (the map keeps insertion order) until one + * more of `bytes` fits both budgets. + */ +function makeRoom(bytes: number): void { + let total = bytes; + for (const resource of minted.values()) total += resource.blob.length; + for (const [uri, resource] of minted) { + if (minted.size < MAX_MINTED_RESOURCES && total <= MAX_MINTED_BYTES) break; + minted.delete(uri); + total -= resource.blob.length; + } +} + /** * Store a blob resource under a freshly minted URI, and return a link to it. * @@ -58,6 +81,7 @@ export function mintBlobResource( ): ResourceLink { const now = Date.now(); sweep(now); + makeRoom(blob.length); const uri = `demo://resource/minted/${randomUUID()}/${encodeURIComponent(name)}`; minted.set(uri, { name, diff --git a/src/everything/server/tasks.ts b/src/everything/server/tasks.ts index 9bd632c3a8..8227e590ad 100644 --- a/src/everything/server/tasks.ts +++ b/src/everything/server/tasks.ts @@ -334,8 +334,11 @@ const EmptyResult = z.object({}); * Serve the extension's methods on a 2026-07-28 server: `tasks/get`, * `tasks/update` and `tasks/cancel`. A request from a client that does not * declare the extension is refused with -32021. An unknown or expired task - * id, and an update or cancellation that does not fit the task's status, is - * refused with -32602 (invalid params). + * id, and a cancellation of a finished task, is refused with -32602 (invalid + * params). An update's answers to requests that are not outstanding are + * ignored. Each input request a task makes has a key of its own, never reused + * once answered (the extension requires it), so a re-asked request gets a + * new key. * * @param {McpServer} server - The 2026-07-28 server to serve them on. */ @@ -362,23 +365,22 @@ export function registerTasksExtension(server: McpServer): void { const now = Date.now(); const task = find(taskId, now); const state = stateOf(task, now); - if (state.status !== "input_required") { - throw new ProtocolError( - ProtocolErrorCode.InvalidParams, - `Task ${taskId} is ${state.status}, not waiting for input`, - ); - } - const outstanding = Object.keys(state.inputRequests); - const unknown = Object.keys(inputResponses).filter( - (key) => !outstanding.includes(key), + // Only answers to requests outstanding now are passed on. The rest (a + // key never issued, one already answered, or any answer to a task not + // waiting for input) are ignored, as the extension asks, so a client + // that resends or batches responses is not failed for it. + const outstanding = + state.status === "input_required" + ? Object.keys(state.inputRequests) + : []; + const answered = Object.fromEntries( + Object.entries(inputResponses).filter(([key]) => + outstanding.includes(key), + ), ); - if (unknown.length > 0) { - throw new ProtocolError( - ProtocolErrorCode.InvalidParams, - `Task ${taskId} has no outstanding input request ${unknown.join(", ")}; it is waiting for ${outstanding.join(", ")}`, - ); + if (Object.keys(answered).length > 0) { + task.behavior.receive(answered, now); } - task.behavior.receive(inputResponses, now); return {}; }, ); diff --git a/src/everything/tools/simulate-research-query.ts b/src/everything/tools/simulate-research-query.ts index aef882d0d0..4fafef9088 100644 --- a/src/everything/tools/simulate-research-query.ts +++ b/src/everything/tools/simulate-research-query.ts @@ -73,9 +73,6 @@ const config = { }, }; -/** The clarification question's answer. */ -const ClarificationSchema = z.object({ interpretation: z.string() }); - /** * Returns contextual interpretation options based on the topic. */ @@ -99,8 +96,18 @@ function getInterpretationsForTopic( ]; } -/** What the user's answer to the clarification request means. */ -function clarificationFrom(responses: TaskInputResponses): string { +/** + * What the user's answer to the clarification request means. An accepted + * answer counts only if it is one of the interpretations offered for `topic`. + */ +function clarificationFrom( + responses: TaskInputResponses, + topic: string, +): string { + const offered = getInterpretationsForTopic(topic).map((o) => o.const); + const ClarificationSchema = z.object({ + interpretation: z.string().refine((value) => offered.includes(value)), + }); const view = inputResponse(responses, "clarification"); const accepted = acceptedContent( responses, @@ -249,7 +256,7 @@ export const registerSimulateResearchQueryTool = (server: McpServer) => { }; }, receive: (responses, now) => { - clarification = clarificationFrom(responses); + clarification = clarificationFrom(responses, topic); startedAt = now; startStage = CLARIFY_AT; }, diff --git a/src/everything/tools/trigger-elicitation-request-async.ts b/src/everything/tools/trigger-elicitation-request-async.ts index ad3c8a3ac3..d3ad5151ba 100644 --- a/src/everything/tools/trigger-elicitation-request-async.ts +++ b/src/everything/tools/trigger-elicitation-request-async.ts @@ -93,17 +93,22 @@ export const registerTriggerElicitationRequestAsyncTool = ( const createdAt = Date.now(); let summary: CallToolResult | undefined; let answeredAt = createdAt; + // The key of the outstanding request. A key is never reused once + // answered, so a re-asked form gets the next one. + let attempt = 1; + const key = () => `form-${attempt}`; return createTask( { stateAt: (): TaskState => summary === undefined ? { - since: createdAt, + // Waiting since the form, or the last re-ask, went out + since: answeredAt, status: "input_required", statusMessage: "Waiting for the user's input", inputRequests: { - form: inputRequired.elicit({ + [key()]: inputRequired.elicit({ message: "Please provide inputs for the following fields (async task demo):", requestedSchema: FormSchema, @@ -117,18 +122,20 @@ export const registerTriggerElicitationRequestAsyncTool = ( result: summary, }, receive: (responses, now) => { - const answer = inputResponse(responses, "form"); - if (answer.kind !== "elicit") return; - if (answer.action !== "accept") { + answeredAt = now; + const answer = inputResponse(responses, key()); + if (answer.kind === "elicit" && answer.action !== "accept") { summary = summarize(answer.action); - } else { - const content = acceptedContent(responses, "form", FormSchema); - // An accepted answer that does not fit the form is ignored, so - // the task keeps waiting for one that does. - if (content === undefined) return; - summary = summarize("accept", content); + return; } - answeredAt = now; + const content = + answer.kind === "elicit" + ? acceptedContent(responses, key(), FormSchema) + : undefined; + // An answer of another kind, or one that does not fit the form, + // spends the request: ask again under a new key. + if (content === undefined) attempt++; + else summary = summarize("accept", content); }, }, { ttlMs: 10 * 60_000, pollIntervalMs: 1000 }, diff --git a/src/everything/tools/trigger-sampling-request-async.ts b/src/everything/tools/trigger-sampling-request-async.ts index 63f81b6f1e..97fa54888a 100644 --- a/src/everything/tools/trigger-sampling-request-async.ts +++ b/src/everything/tools/trigger-sampling-request-async.ts @@ -68,17 +68,22 @@ export const registerTriggerSamplingRequestAsyncTool = (server: McpServer) => { const createdAt = Date.now(); let result: SamplingResult | undefined; let answeredAt = createdAt; + // The key of the outstanding request. A key is never reused once + // answered, so a re-asked request gets the next one. + let attempt = 1; + const key = () => `sampling-${attempt}`; return createTask( { stateAt: (): TaskState => result === undefined ? { - since: createdAt, + // Waiting since the request, or the last re-ask, went out + since: answeredAt, status: "input_required", statusMessage: "Waiting for the client's LLM to respond", inputRequests: { - sampling: inputRequired.createMessage({ + [key()]: inputRequired.createMessage({ messages: [ { role: "user", @@ -108,10 +113,12 @@ export const registerTriggerSamplingRequestAsyncTool = (server: McpServer) => { }, }, receive: (responses, now) => { - const answer = inputResponse(responses, "sampling"); - if (answer.kind !== "sampling") return; - result = answer.result; answeredAt = now; + const answer = inputResponse(responses, key()); + // An answer of another kind spends the request: ask again under a + // new key. + if (answer.kind === "sampling") result = answer.result; + else attempt++; }, }, { ttlMs: 10 * 60_000, pollIntervalMs: 1000 }, diff --git a/src/memory/__tests__/eras.test.ts b/src/memory/__tests__/eras.test.ts index cb77369240..3086ef8386 100644 --- a/src/memory/__tests__/eras.test.ts +++ b/src/memory/__tests__/eras.test.ts @@ -142,6 +142,33 @@ describe("both protocol eras", () => { process.exitCode = undefined; }); + it("answers resources/subscribe from a 2026-07-28 peer with -32601", async () => { + // The revision removed the method, so the SDK refuses it before any + // handler runs, though the factory installs one for 2025-era clients. + const [peer, serverTransport] = InMemoryTransport.createLinkedPair(); + const handle = await main(serverTransport); + closers.push(() => handle.close()); + const response = new Promise((resolve) => { + peer.onmessage = resolve; + }); + await peer.start(); + await peer.send({ + jsonrpc: "2.0", + id: 1, + method: "resources/subscribe", + params: { + uri: URI, + _meta: { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientCapabilities": {}, + }, + }, + }); + expect((await response) as Record).toMatchObject({ + error: { code: -32601 }, + }); + }); + it("stamps resultType and the cache fields on a 2026-07-28 read", async () => { const [peer, serverTransport] = InMemoryTransport.createLinkedPair(); const handle = await main(serverTransport); From d9ce091a65cb4d142889573f939be489181f2697 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 11 Oct 2026 23:43:16 -0400 Subject: [PATCH 080/117] fix(everything): bound the task store; document the async task tools' needs Copilot round 4 on #5131: - The process keeps at most 100 tasks, dropping the oldest, and the task tools bound the strings a task keeps (topic 1,000 characters, prompt 10,000), so a burst of task-tool calls cannot exhaust memory before the TTL frees anything. - The instructions name the sampling and form-elicitation capabilities trigger-sampling-request-async and trigger-elicitation-request-async need beyond the tasks extension, and the -32021 a call without them gets. Part of #4852 Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/everything/__tests__/tasks.test.ts | 22 ++++++++++++++++++- src/everything/docs/features.md | 2 +- src/everything/docs/instructions.md | 2 +- src/everything/server/tasks.ts | 13 +++++++++++ .../tools/simulate-research-query.ts | 7 +++++- .../tools/trigger-sampling-request-async.ts | 7 +++++- 6 files changed, 48 insertions(+), 5 deletions(-) diff --git a/src/everything/__tests__/tasks.test.ts b/src/everything/__tests__/tasks.test.ts index 2d8cfd61de..930409ba7f 100644 --- a/src/everything/__tests__/tasks.test.ts +++ b/src/everything/__tests__/tasks.test.ts @@ -22,7 +22,7 @@ * `tasks/result` and `tasks/list`, which the extension does not define. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { clearTasks, TASKS_EXTENSION } from "../server/tasks.js"; +import { clearTasks, MAX_TASKS, TASKS_EXTENSION } from "../server/tasks.js"; import { STAGE_DURATION, STAGES } from "../tools/simulate-research-query.js"; import { connectRaw as rawClient, type RawSession } from "./harness.js"; @@ -148,6 +148,26 @@ describe("the extension", () => { }, ); + it("keeps at most MAX_TASKS, dropping the oldest", async () => { + peer = await rawClient(TASKS); + const first = await peer.call("simulate-research-query", { topic: "t" }); + for (let i = 1; i < MAX_TASKS; i++) { + await peer.call("simulate-research-query", { topic: "t" }); + } + expect((await peer.get(first.taskId as string)).status).toBe("working"); + await peer.call("simulate-research-query", { topic: "t" }); + const response = await peer.request("tasks/get", { taskId: first.taskId }); + expect(response.error).toMatchObject({ code: -32602 }); + }); + + it("refuses a topic longer than it will keep", async () => { + peer = await rawClient(TASKS); + const result = await peer.call("simulate-research-query", { + topic: "x".repeat(1001), + }); + expect(result.isError).toBe(true); + }); + it("drops a task once its TTL has passed", async () => { peer = await rawClient(TASKS); const created = await peer.call("simulate-research-query", { topic: "t" }); diff --git a/src/everything/docs/features.md b/src/everything/docs/features.md index 7cf5ff3eb9..84b4d9b053 100644 --- a/src/everything/docs/features.md +++ b/src/everything/docs/features.md @@ -94,6 +94,6 @@ Logging is deprecated as of 2026-07-28 (SEP-2577) and remains available on both - `tasks/update`: the client's `inputResponses` to the outstanding input requests; - `tasks/cancel`: stop a task that has not finished. -A task tool called by a client whose request does not declare the extension, or the capability its task asks for input with (`sampling` for `trigger-sampling-request-async`, form-mode `elicitation` for `trigger-elicitation-request-async`), is refused with `-32021` (missing required client capability), naming what is missing in `data.requiredCapabilities`; so is a `tasks/*` request from a client without the extension. An unknown or expired task and a cancel of a finished task are refused with `-32602`. An update's answers to requests that are not outstanding (never issued, already answered, or sent while the task is not waiting for input) are ignored, as the extension asks. Each input request a task makes has its own key, never reused once answered, so a request asked again after an answer that does not fit gets a new key (`form-2`, `sampling-2`). The task id is a server-minted UUID, so a task lives across requests (each 2026-07-28 HTTP request is served by a fresh server instance) and is reached only through its handle. A task's progress is computed from the clock when it is read, so nothing runs in the background. +A task tool called by a client whose request does not declare the extension, or the capability its task asks for input with (`sampling` for `trigger-sampling-request-async`, form-mode `elicitation` for `trigger-elicitation-request-async`), is refused with `-32021` (missing required client capability), naming what is missing in `data.requiredCapabilities`; so is a `tasks/*` request from a client without the extension. An unknown or expired task and a cancel of a finished task are refused with `-32602`. An update's answers to requests that are not outstanding (never issued, already answered, or sent while the task is not waiting for input) are ignored, as the extension asks. Each input request a task makes has its own key, never reused once answered, so a request asked again after an answer that does not fit gets a new key (`form-2`, `sampling-2`). The process keeps at most 100 tasks, dropping the oldest first. The task id is a server-minted UUID, so a task lives across requests (each 2026-07-28 HTTP request is served by a fresh server instance) and is reached only through its handle. A task's progress is computed from the clock when it is read, so nothing runs in the background. The 2025-11-25 core task methods (`tasks/result`, `tasks/list`, task-augmented requests) are not served: SDK v2 removed the experimental layer they were built on. diff --git a/src/everything/docs/instructions.md b/src/everything/docs/instructions.md index 3c380b6b15..4eedf8fc83 100644 --- a/src/everything/docs/instructions.md +++ b/src/everything/docs/instructions.md @@ -7,7 +7,7 @@ Follow them to use, extend, and troubleshoot the server safely and effectively. The server serves two protocol eras. A client on protocol version 2026-07-28 connects with `server/discover` and sends its capabilities with each request; a 2025-era client connects with `initialize`. Some tools exist on one era only, so trust your `tools/list`: -- 2026-07-28 only: `simulate-research-query`, `trigger-sampling-request-async` and `trigger-elicitation-request-async`, which run as tasks on the `io.modelcontextprotocol/tasks` extension and need a client that declares it +- 2026-07-28 only: `simulate-research-query`, `trigger-sampling-request-async` and `trigger-elicitation-request-async`, which run as tasks on the `io.modelcontextprotocol/tasks` extension and need a client that declares it; `trigger-sampling-request-async` also needs `sampling`, and `trigger-elicitation-request-async` form-mode `elicitation`. A call without them fails with `-32021` - 2025-era only (deprecated): `toggle-simulated-logging` and `toggle-subscriber-updates` ## Cross-Feature Relationships diff --git a/src/everything/server/tasks.ts b/src/everything/server/tasks.ts index 8227e590ad..cf49143dcc 100644 --- a/src/everything/server/tasks.ts +++ b/src/everything/server/tasks.ts @@ -95,6 +95,14 @@ type StoredTask = { /** Every live task, by id, across every server instance in the process. */ const tasks = new Map(); +/** + * The most tasks the process keeps. A task holds its tool's arguments (each + * task tool bounds their length) and the client's answers (bounded by the + * transports' request size) until its TTL, so without a cap a burst of calls + * could exhaust memory first; past it, the oldest task is dropped. + */ +export const MAX_TASKS = 100; + /** Drop every task past its TTL. */ function sweep(now: number): void { for (const [taskId, task] of tasks) { @@ -167,6 +175,11 @@ export function createTask( ): CallToolResult { const now = Date.now(); sweep(now); + // The map keeps insertion order, so the first entries are the oldest. + for (const taskId of tasks.keys()) { + if (tasks.size < MAX_TASKS) break; + tasks.delete(taskId); + } const task: StoredTask = { taskId: randomUUID(), createdAt: now, diff --git a/src/everything/tools/simulate-research-query.ts b/src/everything/tools/simulate-research-query.ts index 4fafef9088..90ab8ee4ee 100644 --- a/src/everything/tools/simulate-research-query.ts +++ b/src/everything/tools/simulate-research-query.ts @@ -31,7 +31,12 @@ import { // Tool input schema const SimulateResearchQuerySchema = z.object({ - topic: z.string().describe("The research topic to investigate"), + topic: z + .string() + .max(1000) + .describe( + "The research topic to investigate (at most 1,000 characters; the task keeps it until it expires)", + ), ambiguous: z .boolean() .default(false) diff --git a/src/everything/tools/trigger-sampling-request-async.ts b/src/everything/tools/trigger-sampling-request-async.ts index 97fa54888a..f0e90b0bbc 100644 --- a/src/everything/tools/trigger-sampling-request-async.ts +++ b/src/everything/tools/trigger-sampling-request-async.ts @@ -22,7 +22,12 @@ import { createTask, type TaskState } from "../server/tasks.js"; // Tool input schema const TriggerSamplingRequestAsyncSchema = z.object({ - prompt: z.string().describe("The prompt to send to the LLM"), + prompt: z + .string() + .max(10000) + .describe( + "The prompt to send to the LLM (at most 10,000 characters; the task keeps it until it expires)", + ), maxTokens: z .number() .default(100) From ddbd1903a45d2c384bc1a1e8e7e74dae9c7c71fa Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 11 Oct 2026 23:54:48 -0400 Subject: [PATCH 081/117] fix(everything): catch failed list-change sends; require inputResponses on tasks/update Copilot round 5 on #5131: - The instance publisher sends list changes through the low-level Server methods and catches their failure, since McpServer's helpers start the same send and drop the promise. - tasks/update without inputResponses, which the extension's schema requires, is refused with -32602 instead of acknowledged. Part of #4852 Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/everything/__tests__/modern.test.ts | 31 +++++++++++++++++++++++++ src/everything/__tests__/tasks.test.ts | 12 ++++++++++ src/everything/server/context.ts | 11 ++++++--- src/everything/server/tasks.ts | 12 +++++++--- 4 files changed, 60 insertions(+), 6 deletions(-) diff --git a/src/everything/__tests__/modern.test.ts b/src/everything/__tests__/modern.test.ts index 5b7f0ffbc2..3e31440a8f 100644 --- a/src/everything/__tests__/modern.test.ts +++ b/src/everything/__tests__/modern.test.ts @@ -538,6 +538,37 @@ describe("change notifications", () => { expect(await arrived).toEqual({ uri: URI }); }); + it.each(["toolsChanged", "promptsChanged", "resourcesChanged"] as const)( + "logs, rather than leaves unhandled, a %s notification that cannot be sent", + async (method) => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const server = new McpServer({ name: "s", version: "0" }); + vi.spyOn(server, "isConnected").mockReturnValue(true); + const failure = new Error("closed"); + vi.spyOn(server.server, "sendToolListChanged").mockRejectedValue(failure); + vi.spyOn(server.server, "sendPromptListChanged").mockRejectedValue( + failure, + ); + vi.spyOn(server.server, "sendResourceListChanged").mockRejectedValue( + failure, + ); + instancePublisher(server, () => true)[method](); + await vi.waitFor(() => + expect(error).toHaveBeenCalledWith( + "Change notification failed:", + failure, + ), + ); + }, + ); + + it("sends no list change from an instance with no connection", () => { + const server = new McpServer({ name: "s", version: "0" }); + const send = vi.spyOn(server.server, "sendToolListChanged"); + instancePublisher(server, () => true).toolsChanged(); + expect(send).not.toHaveBeenCalled(); + }); + it("logs, rather than throws, a resource update that cannot be sent", async () => { const error = vi.spyOn(console, "error").mockImplementation(() => {}); const server = new McpServer({ name: "s", version: "0" }); diff --git a/src/everything/__tests__/tasks.test.ts b/src/everything/__tests__/tasks.test.ts index 930409ba7f..3c6b71896a 100644 --- a/src/everything/__tests__/tasks.test.ts +++ b/src/everything/__tests__/tasks.test.ts @@ -168,6 +168,18 @@ describe("the extension", () => { expect(result.isError).toBe(true); }); + it("refuses tasks/update without inputResponses with -32602", async () => { + peer = await rawClient(TASKS); + const created = await peer.call("simulate-research-query", { topic: "t" }); + const response = await peer.request("tasks/update", { + taskId: created.taskId, + }); + expect(response.error).toMatchObject({ + code: -32602, + message: "Invalid params for tasks/update: inputResponses is required", + }); + }); + it("drops a task once its TTL has passed", async () => { peer = await rawClient(TASKS); const created = await peer.call("simulate-research-query", { topic: "t" }); diff --git a/src/everything/server/context.ts b/src/everything/server/context.ts index 1827958aee..53acde0545 100644 --- a/src/everything/server/context.ts +++ b/src/everything/server/context.ts @@ -62,10 +62,15 @@ export function instancePublisher( ): ChangePublisher { const report = (error: unknown) => console.error("Change notification failed:", error); + // The low-level sends return their promise; McpServer's own helpers start + // the same send and drop it, so a failed one would go unhandled. + const send = (notify: () => Promise) => { + if (server.isConnected()) notify().catch(report); + }; return { - toolsChanged: () => server.sendToolListChanged(), - promptsChanged: () => server.sendPromptListChanged(), - resourcesChanged: () => server.sendResourceListChanged(), + toolsChanged: () => send(() => server.server.sendToolListChanged()), + promptsChanged: () => send(() => server.server.sendPromptListChanged()), + resourcesChanged: () => send(() => server.server.sendResourceListChanged()), resourceUpdated: (uri) => { if (isSubscribed(uri)) { server.server.sendResourceUpdated({ uri }).catch(report); diff --git a/src/everything/server/tasks.ts b/src/everything/server/tasks.ts index cf49143dcc..522f81b296 100644 --- a/src/everything/server/tasks.ts +++ b/src/everything/server/tasks.ts @@ -372,9 +372,15 @@ export function registerTasksExtension(server: McpServer): void { async ({ taskId }, ctx) => { requireTasks(ctx); // The SDK lifts `inputResponses` out of the params of every request - // (it is a reserved retry field on 2026-07-28) into the context. - const inputResponses: TaskInputResponses = - ctx.mcpReq.inputResponses ?? {}; + // (it is a reserved retry field on 2026-07-28) into the context. The + // extension requires it, so a request without it is malformed. + const inputResponses = ctx.mcpReq.inputResponses; + if (inputResponses === undefined) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + "Invalid params for tasks/update: inputResponses is required", + ); + } const now = Date.now(); const task = find(taskId, now); const state = stateOf(task, now); From 8eea6d972ab1668de8efbc5d7ea2067e14126dab Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 00:04:09 -0400 Subject: [PATCH 082/117] fix(everything): refuse a minted resource over the byte budget; simulate-research-query is not read-only Copilot round 6 on #5131: - A single blob larger than MAX_MINTED_BYTES is refused rather than stored after evicting everything else; gzip-file-as-resource reports it as a tool error. - simulate-research-query creates a task (and a full store drops its oldest), so it no longer claims readOnlyHint. Part of #4852 Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/everything/__tests__/modern.test.ts | 22 +++++++++++++++++++ src/everything/resources/minted.ts | 8 +++++++ .../tools/simulate-research-query.ts | 3 ++- 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/everything/__tests__/modern.test.ts b/src/everything/__tests__/modern.test.ts index 3e31440a8f..daa185ba7d 100644 --- a/src/everything/__tests__/modern.test.ts +++ b/src/everything/__tests__/modern.test.ts @@ -102,6 +102,20 @@ describe("the 2026-07-28 surface", () => { expect(session.client.getServerCapabilities()?.extensions).toBeUndefined(); }); + it("marks the task-creating tools as not read-only", async () => { + session = await connectModern(); + const { tools } = await session.client.listTools(); + for (const name of [ + "simulate-research-query", + "trigger-sampling-request-async", + "trigger-elicitation-request-async", + ]) { + expect( + tools.find((t) => t.name === name)?.annotations?.readOnlyHint, + ).toBe(false); + } + }); + it("lists the capability-gated and task tools up front, and not the 2025-only toggles", async () => { session = await connectModern(); expect(session.client.getProtocolEra()).toBe("modern"); @@ -650,6 +664,14 @@ describe("minted resources", () => { ).rejects.toMatchObject({ code: -32602 }); }); + it("refuses one resource larger than the whole byte budget", () => { + expect(() => + mintBlobResource("huge", "text/plain", "A".repeat(MAX_MINTED_BYTES + 1)), + ).toThrow( + `Cannot keep huge for later reading: its ${MAX_MINTED_BYTES + 1} bytes of content exceed the ${MAX_MINTED_BYTES}-byte limit for minted resources`, + ); + }); + it("keeps at most MAX_MINTED_BYTES of content, dropping the oldest", async () => { const half = "A".repeat(MAX_MINTED_BYTES / 2); const first = mintBlobResource("first", "text/plain", half).uri; diff --git a/src/everything/resources/minted.ts b/src/everything/resources/minted.ts index 19375bbda5..71f2bfe05c 100644 --- a/src/everything/resources/minted.ts +++ b/src/everything/resources/minted.ts @@ -69,6 +69,7 @@ function makeRoom(bytes: number): void { /** * Store a blob resource under a freshly minted URI, and return a link to it. + * Throws for a blob larger than `MAX_MINTED_BYTES`. * * @param name - The resource's name, the last segment of its URI. * @param mimeType - Its MIME type. @@ -79,6 +80,13 @@ export function mintBlobResource( mimeType: string, blob: string, ): ResourceLink { + // One resource larger than the whole budget is refused outright, rather + // than evicting everything else and still exceeding the budget. + if (blob.length > MAX_MINTED_BYTES) { + throw new Error( + `Cannot keep ${name} for later reading: its ${blob.length} bytes of content exceed the ${MAX_MINTED_BYTES}-byte limit for minted resources`, + ); + } const now = Date.now(); sweep(now); makeRoom(blob.length); diff --git a/src/everything/tools/simulate-research-query.ts b/src/everything/tools/simulate-research-query.ts index 90ab8ee4ee..b51f74fa6d 100644 --- a/src/everything/tools/simulate-research-query.ts +++ b/src/everything/tools/simulate-research-query.ts @@ -71,7 +71,8 @@ const config = { "input_required until the client answers its clarification request with tasks/update.", inputSchema: SimulateResearchQuerySchema, annotations: { - readOnlyHint: true, + // Each call creates a task, and a full task store drops its oldest one. + readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false, From 88410f8102fdc83a689c7c37d710d5cb704730c4 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 00:14:48 -0400 Subject: [PATCH 083/117] fix(everything): keep live tasks until their TTL; acknowledge any cancel; bound minted names Copilot round 7 on #5131: - At MAX_TASKS a new task is refused with a tool error instead of evicting a live one, since the extension lets a server discard a task only after its TTL. - tasks/cancel acknowledges a known task whatever its status (cancellation is cooperative); a finished task stays as it ended. - Minted resources count their name and URI against the byte budget, and gzip-file-as-resource bounds its name at 255 characters. - features.md names the async task tools' full requirements; the sequentialthinking README's era sentence is completed. Part of #4852 Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .changeset/everything-modern-era.md | 2 +- src/everything/__tests__/modern.test.ts | 25 +++++++++++- src/everything/__tests__/surface.test.ts | 3 +- src/everything/__tests__/tasks.test.ts | 40 +++++++++++-------- src/everything/docs/features.md | 26 ++++++------ src/everything/resources/minted.ts | 18 ++++++--- src/everything/server/tasks.ts | 33 +++++++++------ src/everything/tools/gzip-file-as-resource.ts | 6 ++- src/sequentialthinking/README.md | 2 +- 9 files changed, 101 insertions(+), 54 deletions(-) diff --git a/.changeset/everything-modern-era.md b/.changeset/everything-modern-era.md index e1b64ba1bb..6315ed7008 100644 --- a/.changeset/everything-modern-era.md +++ b/.changeset/everything-modern-era.md @@ -2,4 +2,4 @@ "@modelcontextprotocol/server-everything": minor --- -Serve the 2026-07-28 protocol revision alongside the 2025 era (#4852), over stdio (`serveStdio`) and Streamable HTTP (one `/mcp` endpoint: `createMcpHandler` for 2026-07-28 requests, the existing sessionful transport for 2025-era clients). New for 2026-07-28 clients: `server/discover`, multi-round-trip requests (`input_required`) for the elicitation, sampling and roots tools, `subscriptions/listen` change notifications, cache hints (`ttlMs` / `cacheScope`), and the tasks extension (`io.modelcontextprotocol/tasks`, advertised under `capabilities.extensions`), on which `simulate-research-query`, `trigger-sampling-request-async` and `trigger-elicitation-request-async` return. New tools on both eras: `trigger-multi-step-elicitation` (`requestState` across rounds), `trigger-log-messages` (per-request log levels), `trigger-list-changed`, `trigger-resource-update`, and `get-header-params` (`x-mcp-header` and JSON Schema 2020-12 schemas). `toggle-simulated-logging` and `toggle-subscriber-updates` are offered to 2025-era clients only, labelled deprecated with the roots, sampling and logging tools. For 2025-era clients: `get-roots-list` asks the client for its roots on every call; a failed sampling, elicitation or roots request is reported as `Fulfilling input required by 'tools/call' failed: `; a read of a dynamic resource with an invalid id is answered `-32602` with the URI in `data`; and the toggle tools' messages and the subscription acknowledgements no longer name a session id. The HTTP+SSE transport prints a deprecation warning on start. +Serve the 2026-07-28 protocol revision alongside the 2025 era (#4852), over stdio (`serveStdio`) and Streamable HTTP (one `/mcp` endpoint: `createMcpHandler` for 2026-07-28 requests, the existing sessionful transport for 2025-era clients). New for 2026-07-28 clients: `server/discover`, multi-round-trip requests (`input_required`) for the elicitation, sampling and roots tools, `subscriptions/listen` change notifications, cache hints (`ttlMs` / `cacheScope`), and the tasks extension (`io.modelcontextprotocol/tasks`, advertised under `capabilities.extensions`), on which `simulate-research-query`, `trigger-sampling-request-async` and `trigger-elicitation-request-async` return. New tools on both eras: `trigger-multi-step-elicitation` (`requestState` across rounds), `trigger-log-messages` (per-request log levels), `trigger-list-changed`, `trigger-resource-update`, and `get-header-params` (`x-mcp-header` and JSON Schema 2020-12 schemas). `toggle-simulated-logging` and `toggle-subscriber-updates` are offered to 2025-era clients only, labelled deprecated with the roots, sampling and logging tools. For 2025-era clients: `get-roots-list` asks the client for its roots on every call; a failed sampling, elicitation or roots request is reported as `Fulfilling input required by 'tools/call' failed: `; a read of a dynamic resource with an invalid id is answered `-32602` with the URI in `data`; and the toggle tools' messages and the subscription acknowledgements no longer name a session id. `gzip-file-as-resource` limits its `name` argument to 255 characters. The HTTP+SSE transport prints a deprecation warning on start. diff --git a/src/everything/__tests__/modern.test.ts b/src/everything/__tests__/modern.test.ts index daa185ba7d..6de89b020c 100644 --- a/src/everything/__tests__/modern.test.ts +++ b/src/everything/__tests__/modern.test.ts @@ -666,12 +666,33 @@ describe("minted resources", () => { it("refuses one resource larger than the whole byte budget", () => { expect(() => - mintBlobResource("huge", "text/plain", "A".repeat(MAX_MINTED_BYTES + 1)), + mintBlobResource("huge", "text/plain", "A".repeat(MAX_MINTED_BYTES)), ).toThrow( - `Cannot keep huge for later reading: its ${MAX_MINTED_BYTES + 1} bytes of content exceed the ${MAX_MINTED_BYTES}-byte limit for minted resources`, + /^Cannot keep huge for later reading: its \d+ bytes exceed the \d+-byte limit for minted resources$/, ); }); + it("counts a resource's name and URI against the byte budget", async () => { + // Two resources whose content alone fits, but not with their names. + const name = "n".repeat(1024); + const blob = "A".repeat(MAX_MINTED_BYTES / 2 - 1024); + const first = mintBlobResource(name, "text/plain", blob).uri; + mintBlobResource(name, "text/plain", blob); + session = await connectModern(); + await expect( + session.client.readResource({ uri: first }), + ).rejects.toMatchObject({ code: -32602 }); + }); + + it("refuses a gzip-file-as-resource name longer than it will keep", async () => { + session = await connectModern(); + const result = await session.client.callTool({ + name: "gzip-file-as-resource", + arguments: { name: "n".repeat(256), data: DATA_URI }, + }); + expect(result.isError).toBe(true); + }); + it("keeps at most MAX_MINTED_BYTES of content, dropping the oldest", async () => { const half = "A".repeat(MAX_MINTED_BYTES / 2); const first = mintBlobResource("first", "text/plain", half).uri; diff --git a/src/everything/__tests__/surface.test.ts b/src/everything/__tests__/surface.test.ts index fca3047d78..5febd2c995 100644 --- a/src/everything/__tests__/surface.test.ts +++ b/src/everything/__tests__/surface.test.ts @@ -579,7 +579,8 @@ describe("tools/list", () => { }, "name": { "default": "README.md.gz", - "description": "Name of the output file", + "description": "Name of the output file (at most 255 characters)", + "maxLength": 255, "type": "string", }, "outputType": { diff --git a/src/everything/__tests__/tasks.test.ts b/src/everything/__tests__/tasks.test.ts index 3c6b71896a..876a961f4b 100644 --- a/src/everything/__tests__/tasks.test.ts +++ b/src/everything/__tests__/tasks.test.ts @@ -148,16 +148,27 @@ describe("the extension", () => { }, ); - it("keeps at most MAX_TASKS, dropping the oldest", async () => { + it("refuses a new task at MAX_TASKS, keeping every live one until its TTL", async () => { peer = await rawClient(TASKS); const first = await peer.call("simulate-research-query", { topic: "t" }); for (let i = 1; i < MAX_TASKS; i++) { await peer.call("simulate-research-query", { topic: "t" }); } + const refused = await peer.call("simulate-research-query", { topic: "t" }); + expect(refused).toMatchObject({ + isError: true, + content: [ + { + text: `The server is already running ${MAX_TASKS} tasks, its limit. Try again once one has expired.`, + }, + ], + }); expect((await peer.get(first.taskId as string)).status).toBe("working"); - await peer.call("simulate-research-query", { topic: "t" }); - const response = await peer.request("tasks/get", { taskId: first.taskId }); - expect(response.error).toMatchObject({ code: -32602 }); + // Once the tasks expire, there is room again. + advance(first.ttlMs as number); + expect( + (await peer.call("simulate-research-query", { topic: "t" })).resultType, + ).toBe("task"); }); it("refuses a topic longer than it will keep", async () => { @@ -444,7 +455,7 @@ describe("simulate-research-query", () => { expect((await peer.get(created.taskId as string)).status).toBe("working"); }); - it("stays cancelled once the client cancels it, and refuses a second cancel", async () => { + it("stays cancelled once the client cancels it, and acknowledges a second cancel", async () => { peer = await rawClient(TASKS); const created = await peer.call("simulate-research-query", { topic: "t" }); const taskId = created.taskId as string; @@ -456,24 +467,21 @@ describe("simulate-research-query", () => { status: "cancelled", statusMessage: "Cancelled by the client", }); - const again = await peer.request("tasks/cancel", { taskId }); - expect(again.error).toMatchObject({ - code: -32602, - message: expect.stringContaining("is already cancelled"), + expect(await peer.result("tasks/cancel", { taskId })).toMatchObject({ + resultType: "complete", }); + expect((await peer.get(taskId)).status).toBe("cancelled"); }); - it("refuses to cancel a completed task", async () => { + it("acknowledges a cancel of a completed task, which stays completed", async () => { peer = await rawClient(TASKS); const created = await peer.call("simulate-research-query", { topic: "t" }); + const taskId = created.taskId as string; advance(STAGE_DURATION * STAGES.length); - const response = await peer.request("tasks/cancel", { - taskId: created.taskId, - }); - expect(response.error).toMatchObject({ - code: -32602, - message: expect.stringContaining("is already completed"), + expect(await peer.result("tasks/cancel", { taskId })).toMatchObject({ + resultType: "complete", }); + expect((await peer.get(taskId)).status).toBe("completed"); }); }); diff --git a/src/everything/docs/features.md b/src/everything/docs/features.md index 84b4d9b053..c4bca653b7 100644 --- a/src/everything/docs/features.md +++ b/src/everything/docs/features.md @@ -16,16 +16,16 @@ The server serves two protocol eras from one process, over stdio and over Stream Each feature below is marked **both**, **2026-07-28** or **2025-era**. Roots, sampling and logging are deprecated as of 2026-07-28 (SEP-2577), as is the HTTP+SSE transport; they stay available and are labelled deprecated in their descriptions. -| Feature | 2026-07-28 | 2025-era | -| --- | --- | --- | -| Discovery | `server/discover` | `initialize` | -| Client input (elicitation, sampling, roots) | in-band: the tool returns `input_required`, the client retries | the SDK's legacy shim sends real server-to-client requests for the same handler | -| State across rounds | `requestState`, signed and expiring | the same, replayed by the shim | -| Change notifications | on a `subscriptions/listen` stream the client opened | unsolicited, or after `resources/subscribe` | -| Log level | per request, `_meta` `io.modelcontextprotocol/logLevel` (none named: no logs) | per session, `logging/setLevel` | -| Tasks | the `io.modelcontextprotocol/tasks` extension | not served | -| Cache hints | `ttlMs` / `cacheScope` on lists, reads and `server/discover` | not sent | -| `x-mcp-header` | mirrored into `Mcp-Param-*` headers over Streamable HTTP | not mirrored | +| Feature | 2026-07-28 | 2025-era | +| ------------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| Discovery | `server/discover` | `initialize` | +| Client input (elicitation, sampling, roots) | in-band: the tool returns `input_required`, the client retries | the SDK's legacy shim sends real server-to-client requests for the same handler | +| State across rounds | `requestState`, signed and expiring | the same, replayed by the shim | +| Change notifications | on a `subscriptions/listen` stream the client opened | unsolicited, or after `resources/subscribe` | +| Log level | per request, `_meta` `io.modelcontextprotocol/logLevel` (none named: no logs) | per session, `logging/setLevel` | +| Tasks | the `io.modelcontextprotocol/tasks` extension | not served | +| Cache hints | `ttlMs` / `cacheScope` on lists, reads and `server/discover` | not sent | +| `x-mcp-header` | mirrored into `Mcp-Param-*` headers over Streamable HTTP | not mirrored | ## Tools @@ -44,14 +44,14 @@ Each feature below is marked **both**, **2026-07-28** or **2025-era**. Roots, sa - `toggle-simulated-logging` (tools/toggle-simulated-logging.ts), **2025-era**, deprecated: Starts or stops simulated, random‑leveled logging for the connection. Respects the level the client set with `logging/setLevel`. - `toggle-subscriber-updates` (tools/toggle-subscriber-updates.ts), **2025-era**, deprecated: Starts or stops simulated resource update notifications for URIs the connection has subscribed to with `resources/subscribe`. - `trigger-elicitation-request` (tools/trigger-elicitation-request.ts), **both**: Asks for a form of every field type form-mode elicitation supports (strings, numbers, booleans, enums, and format validation) with a multi-round-trip request, and returns the resulting action/content. Needs client form-mode elicitation: `elicitation.form`, or an `elicitation` object that declares neither mode. -- `trigger-elicitation-request-async` (tools/trigger-elicitation-request-async.ts), **2026-07-28**: Runs as a task whose `input_required` status carries a form elicitation; the client answers with `tasks/update` and the task completes with a summary. Needs a client that declares the tasks extension. +- `trigger-elicitation-request-async` (tools/trigger-elicitation-request-async.ts), **2026-07-28**: Runs as a task whose `input_required` status carries a form elicitation; the client answers with `tasks/update` and the task completes with a summary. Needs a client that declares the tasks extension and form-mode elicitation. - `trigger-list-changed` (tools/trigger-list-changed.ts), **both**: Sends `notifications//list_changed` for `tools`, `prompts` or `resources`: on 2026-07-28 to each `subscriptions/listen` stream that asked for that list, on a 2025-era connection unsolicited. - `trigger-log-messages` (tools/trigger-log-messages.ts), **both**, deprecated: Sends one log message at each level during the call. A 2026-07-28 request gets those at or above the level its `_meta` names (none if it names none); a 2025-era client those at or above its `logging/setLevel` level. - `trigger-long-running-operation` (tools/trigger-long-running-operation.ts), **both**: Simulates a multi-step operation over a given `duration` and number of `steps`; reports progress via `notifications/progress` when a `progressToken` is provided by the client. - `trigger-multi-step-elicitation` (tools/trigger-multi-step-elicitation.ts), **both**: Asks for a name, then for a favorite color addressed by that name, carrying the first answer to the second round in `requestState`. The state is HMAC-signed and expires after ten minutes; a tampered or expired one is refused with `-32602`. A blank name or a color that is not a choice is asked again. Needs form-mode elicitation. - `trigger-resource-update` (tools/trigger-resource-update.ts), **both**: Sends `notifications/resources/updated` for a URI: on 2026-07-28 to each listen stream whose `resourceSubscriptions` name it, on a 2025-era connection once the client has subscribed to it. - `trigger-sampling-request` (tools/trigger-sampling-request.ts), **both**, deprecated: Asks the client's LLM for a completion with a multi-round-trip request, using the provided `prompt` and `maxTokens`; returns the LLM's response payload. Needs client capability `sampling`. -- `trigger-sampling-request-async` (tools/trigger-sampling-request-async.ts), **2026-07-28**, deprecated: Runs as a task whose `input_required` status carries a sampling request; the client answers with `tasks/update` and the task completes with the result. Needs a client that declares the tasks extension. +- `trigger-sampling-request-async` (tools/trigger-sampling-request-async.ts), **2026-07-28**, deprecated: Runs as a task whose `input_required` status carries a sampling request; the client answers with `tasks/update` and the task completes with the result. Needs a client that declares the tasks extension and sampling. - `trigger-url-elicitation` (tools/trigger-url-elicitation.ts), **both**: Directs the user to a URL (URL-mode elicitation). On 2026-07-28 the elicitation comes in-band (`input_required`), with no `elicitationId`. On a 2025-era connection it issues an `elicitation/create` request in URL mode with an `elicitationId`, or throws MCP error `-32042` (`UrlElicitationRequiredError`) when `errorPath=true`. On the error path the prerequisite elicitation it returns points at a different URL than the failing request (`https://modelcontextprotocol.io`); when the client satisfies it and retries the same call, the retry ignores `errorPath` and proceeds via the request path, so the client does not loop on the same error. The retry marker is one-shot per `(url, elicitationId)` on the connection: it is cleared on the recognized retry, so re-running the error path with identical arguments without an intervening prerequisite is treated as a retry and proceeds. Needs client capability `elicitation.url`. On a 2025-era connection the tools that need a client capability are registered only for a client that declared it at `initialize`. On 2026-07-28 they are always listed, because the capabilities arrive with each request, and the SDK refuses a call whose embedded request the client did not declare (`-32021`). @@ -94,6 +94,6 @@ Logging is deprecated as of 2026-07-28 (SEP-2577) and remains available on both - `tasks/update`: the client's `inputResponses` to the outstanding input requests; - `tasks/cancel`: stop a task that has not finished. -A task tool called by a client whose request does not declare the extension, or the capability its task asks for input with (`sampling` for `trigger-sampling-request-async`, form-mode `elicitation` for `trigger-elicitation-request-async`), is refused with `-32021` (missing required client capability), naming what is missing in `data.requiredCapabilities`; so is a `tasks/*` request from a client without the extension. An unknown or expired task and a cancel of a finished task are refused with `-32602`. An update's answers to requests that are not outstanding (never issued, already answered, or sent while the task is not waiting for input) are ignored, as the extension asks. Each input request a task makes has its own key, never reused once answered, so a request asked again after an answer that does not fit gets a new key (`form-2`, `sampling-2`). The process keeps at most 100 tasks, dropping the oldest first. The task id is a server-minted UUID, so a task lives across requests (each 2026-07-28 HTTP request is served by a fresh server instance) and is reached only through its handle. A task's progress is computed from the clock when it is read, so nothing runs in the background. +A task tool called by a client whose request does not declare the extension, or the capability its task asks for input with (`sampling` for `trigger-sampling-request-async`, form-mode `elicitation` for `trigger-elicitation-request-async`), is refused with `-32021` (missing required client capability), naming what is missing in `data.requiredCapabilities`; so is a `tasks/*` request from a client without the extension. An unknown or expired task is refused with `-32602`. A `tasks/cancel` is acknowledged whatever the task's status (cancellation is cooperative), and a finished task stays as it ended. An update's answers to requests that are not outstanding (never issued, already answered, or sent while the task is not waiting for input) are ignored, as the extension asks. Each input request a task makes has its own key, never reused once answered, so a request asked again after an answer that does not fit gets a new key (`form-2`, `sampling-2`). The process keeps at most 100 tasks, each until its TTL; at that limit a new task is refused with a tool error. The task id is a server-minted UUID, so a task lives across requests (each 2026-07-28 HTTP request is served by a fresh server instance) and is reached only through its handle. A task's progress is computed from the clock when it is read, so nothing runs in the background. The 2025-11-25 core task methods (`tasks/result`, `tasks/list`, task-augmented requests) are not served: SDK v2 removed the experimental layer they were built on. diff --git a/src/everything/resources/minted.ts b/src/everything/resources/minted.ts index 71f2bfe05c..2ed51ed8b1 100644 --- a/src/everything/resources/minted.ts +++ b/src/everything/resources/minted.ts @@ -59,14 +59,19 @@ function sweep(now: number): void { */ function makeRoom(bytes: number): void { let total = bytes; - for (const resource of minted.values()) total += resource.blob.length; + for (const [uri, resource] of minted) total += sizeOf(uri, resource); for (const [uri, resource] of minted) { if (minted.size < MAX_MINTED_RESOURCES && total <= MAX_MINTED_BYTES) break; minted.delete(uri); - total -= resource.blob.length; + total -= sizeOf(uri, resource); } } +/** What a stored resource counts against the budget: everything it keeps. */ +function sizeOf(uri: string, resource: MintedResource): number { + return uri.length + resource.name.length + resource.blob.length; +} + /** * Store a blob resource under a freshly minted URI, and return a link to it. * Throws for a blob larger than `MAX_MINTED_BYTES`. @@ -82,15 +87,16 @@ export function mintBlobResource( ): ResourceLink { // One resource larger than the whole budget is refused outright, rather // than evicting everything else and still exceeding the budget. - if (blob.length > MAX_MINTED_BYTES) { + const uri = `demo://resource/minted/${randomUUID()}/${encodeURIComponent(name)}`; + const size = uri.length + name.length + blob.length; + if (size > MAX_MINTED_BYTES) { throw new Error( - `Cannot keep ${name} for later reading: its ${blob.length} bytes of content exceed the ${MAX_MINTED_BYTES}-byte limit for minted resources`, + `Cannot keep ${name} for later reading: its ${size} bytes exceed the ${MAX_MINTED_BYTES}-byte limit for minted resources`, ); } const now = Date.now(); sweep(now); - makeRoom(blob.length); - const uri = `demo://resource/minted/${randomUUID()}/${encodeURIComponent(name)}`; + makeRoom(size); minted.set(uri, { name, mimeType, diff --git a/src/everything/server/tasks.ts b/src/everything/server/tasks.ts index 522f81b296..cc7f101aa9 100644 --- a/src/everything/server/tasks.ts +++ b/src/everything/server/tasks.ts @@ -99,7 +99,9 @@ const tasks = new Map(); * The most tasks the process keeps. A task holds its tool's arguments (each * task tool bounds their length) and the client's answers (bounded by the * transports' request size) until its TTL, so without a cap a burst of calls - * could exhaust memory first; past it, the oldest task is dropped. + * could exhaust memory first. A live task is kept until its TTL, since the + * extension lets a server discard one only after that, so at the cap a new + * task is refused rather than an old one dropped. */ export const MAX_TASKS = 100; @@ -175,10 +177,16 @@ export function createTask( ): CallToolResult { const now = Date.now(); sweep(now); - // The map keeps insertion order, so the first entries are the oldest. - for (const taskId of tasks.keys()) { - if (tasks.size < MAX_TASKS) break; - tasks.delete(taskId); + if (tasks.size >= MAX_TASKS) { + return { + content: [ + { + type: "text", + text: `The server is already running ${MAX_TASKS} tasks, its limit. Try again once one has expired.`, + }, + ], + isError: true, + }; } const task: StoredTask = { taskId: randomUUID(), @@ -347,8 +355,8 @@ const EmptyResult = z.object({}); * Serve the extension's methods on a 2026-07-28 server: `tasks/get`, * `tasks/update` and `tasks/cancel`. A request from a client that does not * declare the extension is refused with -32021. An unknown or expired task - * id, and a cancellation of a finished task, is refused with -32602 (invalid - * params). An update's answers to requests that are not outstanding are + * id is refused with -32602 (invalid params). A cancellation is acknowledged + * whatever the task's status, and changes only a task still running. An update's answers to requests that are not outstanding are * ignored. Each input request a task makes has a key of its own, never reused * once answered (the extension requires it), so a re-asked request gets a * new key. @@ -411,14 +419,13 @@ export function registerTasksExtension(server: McpServer): void { requireTasks(ctx); const now = Date.now(); const task = find(taskId, now); + // Cancellation is cooperative: the request is acknowledged whatever + // the task's status, and a task that has already finished stays as it + // ended. const { status } = stateOf(task, now); - if (status === "completed" || status === "cancelled") { - throw new ProtocolError( - ProtocolErrorCode.InvalidParams, - `Task ${taskId} is already ${status}`, - ); + if (status !== "completed" && status !== "cancelled") { + task.cancelledAt = now; } - task.cancelledAt = now; return {}; }, ); diff --git a/src/everything/tools/gzip-file-as-resource.ts b/src/everything/tools/gzip-file-as-resource.ts index 8cda5b29d9..b2c1b18418 100644 --- a/src/everything/tools/gzip-file-as-resource.ts +++ b/src/everything/tools/gzip-file-as-resource.ts @@ -42,7 +42,11 @@ const GZIP_ALLOWED_DOMAINS = (process.env.GZIP_ALLOWED_DOMAINS ?? "") // Tool input schema const GZipFileAsResourceSchema = z.object({ - name: z.string().describe("Name of the output file").default("README.md.gz"), + name: z + .string() + .max(255) + .describe("Name of the output file (at most 255 characters)") + .default("README.md.gz"), data: z .url() .describe("URL or data URI of the file content to compress") diff --git a/src/sequentialthinking/README.md b/src/sequentialthinking/README.md index ddb093eb82..6d40f1e66d 100644 --- a/src/sequentialthinking/README.md +++ b/src/sequentialthinking/README.md @@ -11,7 +11,7 @@ Published on npm as [`@modelcontextprotocol/server-sequential-thinking`](https:/ - Branch into alternative paths of reasoning - Adjust the total number of thoughts dynamically - Generate and verify solution hypotheses -- Serves both protocol eras on stdio: 2026-07-28 clients (`server/discover`, no handshake) and 2025-era clients (`initialize`). The thought history lasts as long as the client's connection, on either +- Serves both protocol eras on stdio: 2026-07-28 clients (`server/discover`, no handshake) and 2025-era clients (`initialize`). The thought history lasts as long as the client's connection, on either era ## Tool From 0d01ee5dfb481738c3d17da2be5d30e9472777c9 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 00:25:05 -0400 Subject: [PATCH 084/117] fix(everything): answer a JSON primitive POST body as an invalid request, not a parse error Copilot round 8 on #5131: express.json is strict by default, so a body of valid JSON that is not an object or array (null, "x", 42) failed to parse and was answered -32700, which MCP reserves for text that is not JSON. The parser now accepts any JSON value and the SDK answers a primitive with -32600. Part of #4852 Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .../__tests__/streamable-http.test.ts | 17 +++++++++++++++++ src/everything/transports/streamableHttp.ts | 8 +++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/everything/__tests__/streamable-http.test.ts b/src/everything/__tests__/streamable-http.test.ts index 6e8d70a00a..89d387f668 100644 --- a/src/everything/__tests__/streamable-http.test.ts +++ b/src/everything/__tests__/streamable-http.test.ts @@ -654,6 +654,23 @@ describe("2026-07-28 clients", () => { }); }); + it.each(["null", '"x"', "42", "true"])( + "answers a session-less POST of the JSON value %s as an invalid request, not a parse error", + async (body) => { + const response = await fetch(mcp, { + method: "POST", + headers: JSON_HEADERS, + body, + }); + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ + jsonrpc: "2.0", + error: { code: -32600 }, + id: null, + }); + }, + ); + it("answers a session-less POST over 4 MiB with 413", async () => { const response = await fetch(mcp, { method: "POST", diff --git a/src/everything/transports/streamableHttp.ts b/src/everything/transports/streamableHttp.ts index 6f060d8b14..47a3986539 100644 --- a/src/everything/transports/streamableHttp.ts +++ b/src/everything/transports/streamableHttp.ts @@ -86,7 +86,13 @@ function sessionNotFound(res: Response, id: string | number | null = null) { const MAX_BODY_BYTES = 4 * 1024 * 1024; /** Parses a session-less POST's JSON body, to classify it and to serve it. */ -const parseJson = express.json({ limit: MAX_BODY_BYTES, type: () => true }); +const parseJson = express.json({ + limit: MAX_BODY_BYTES, + type: () => true, + // Any JSON value parses, so a primitive body reaches the SDK, which answers + // it as an invalid request (-32600); -32700 is only for text that is not JSON. + strict: false, +}); /** * Answer a session-less POST whose body could not be parsed the way the SDK From 2b830d43d66cbed6345aec24889ec9df5618c21b Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 00:33:53 -0400 Subject: [PATCH 085/117] fix(everything): bound the answers a task keeps; correct the task-limit comment Copilot round 9 on #5131: - tasks/update refuses answers over MAX_ANSWER_BYTES (64 KiB serialized) with -32602, leaving the task unchanged. With MAX_TASKS this bounds what the task store holds, where the transports alone accept 10 MiB messages. - The readOnlyHint comment on simulate-research-query no longer says a full store drops its oldest task (round 7 made it refuse a new one instead). Part of #4852 Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/everything/__tests__/tasks.test.ts | 33 ++++++++++++++++++- src/everything/docs/features.md | 2 +- src/everything/server/tasks.ts | 15 +++++++++ .../tools/simulate-research-query.ts | 2 +- 4 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/everything/__tests__/tasks.test.ts b/src/everything/__tests__/tasks.test.ts index 876a961f4b..ef7f2a0c63 100644 --- a/src/everything/__tests__/tasks.test.ts +++ b/src/everything/__tests__/tasks.test.ts @@ -22,7 +22,12 @@ * `tasks/result` and `tasks/list`, which the extension does not define. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { clearTasks, MAX_TASKS, TASKS_EXTENSION } from "../server/tasks.js"; +import { + clearTasks, + MAX_ANSWER_BYTES, + MAX_TASKS, + TASKS_EXTENSION, +} from "../server/tasks.js"; import { STAGE_DURATION, STAGES } from "../tools/simulate-research-query.js"; import { connectRaw as rawClient, type RawSession } from "./harness.js"; @@ -179,6 +184,32 @@ describe("the extension", () => { expect(result.isError).toBe(true); }); + it("refuses answers over MAX_ANSWER_BYTES with -32602, leaving the task waiting", async () => { + peer = await rawClient({ ...TASKS, sampling: {} }); + const created = await peer.call("trigger-sampling-request-async", { + prompt: "p", + }); + const response = await peer.request("tasks/update", { + taskId: created.taskId, + inputResponses: { + "sampling-1": { + role: "assistant", + model: "m", + content: { type: "text", text: "x".repeat(MAX_ANSWER_BYTES) }, + }, + }, + }); + expect(response.error).toMatchObject({ + code: -32602, + message: expect.stringContaining( + `over the ${MAX_ANSWER_BYTES}-byte limit; the task is unchanged`, + ), + }); + const task = await peer.get(created.taskId as string); + expect(task.status).toBe("input_required"); + expect(Object.keys(task.inputRequests as object)).toEqual(["sampling-1"]); + }); + it("refuses tasks/update without inputResponses with -32602", async () => { peer = await rawClient(TASKS); const created = await peer.call("simulate-research-query", { topic: "t" }); diff --git a/src/everything/docs/features.md b/src/everything/docs/features.md index c4bca653b7..585c5182f7 100644 --- a/src/everything/docs/features.md +++ b/src/everything/docs/features.md @@ -94,6 +94,6 @@ Logging is deprecated as of 2026-07-28 (SEP-2577) and remains available on both - `tasks/update`: the client's `inputResponses` to the outstanding input requests; - `tasks/cancel`: stop a task that has not finished. -A task tool called by a client whose request does not declare the extension, or the capability its task asks for input with (`sampling` for `trigger-sampling-request-async`, form-mode `elicitation` for `trigger-elicitation-request-async`), is refused with `-32021` (missing required client capability), naming what is missing in `data.requiredCapabilities`; so is a `tasks/*` request from a client without the extension. An unknown or expired task is refused with `-32602`. A `tasks/cancel` is acknowledged whatever the task's status (cancellation is cooperative), and a finished task stays as it ended. An update's answers to requests that are not outstanding (never issued, already answered, or sent while the task is not waiting for input) are ignored, as the extension asks. Each input request a task makes has its own key, never reused once answered, so a request asked again after an answer that does not fit gets a new key (`form-2`, `sampling-2`). The process keeps at most 100 tasks, each until its TTL; at that limit a new task is refused with a tool error. The task id is a server-minted UUID, so a task lives across requests (each 2026-07-28 HTTP request is served by a fresh server instance) and is reached only through its handle. A task's progress is computed from the clock when it is read, so nothing runs in the background. +A task tool called by a client whose request does not declare the extension, or the capability its task asks for input with (`sampling` for `trigger-sampling-request-async`, form-mode `elicitation` for `trigger-elicitation-request-async`), is refused with `-32021` (missing required client capability), naming what is missing in `data.requiredCapabilities`; so is a `tasks/*` request from a client without the extension. An unknown or expired task, and a `tasks/update` whose answers exceed 64 KiB serialized, are refused with `-32602`. A `tasks/cancel` is acknowledged whatever the task's status (cancellation is cooperative), and a finished task stays as it ended. An update's answers to requests that are not outstanding (never issued, already answered, or sent while the task is not waiting for input) are ignored, as the extension asks. Each input request a task makes has its own key, never reused once answered, so a request asked again after an answer that does not fit gets a new key (`form-2`, `sampling-2`). The process keeps at most 100 tasks, each until its TTL; at that limit a new task is refused with a tool error. The task id is a server-minted UUID, so a task lives across requests (each 2026-07-28 HTTP request is served by a fresh server instance) and is reached only through its handle. A task's progress is computed from the clock when it is read, so nothing runs in the background. The 2025-11-25 core task methods (`tasks/result`, `tasks/list`, task-augmented requests) are not served: SDK v2 removed the experimental layer they were built on. diff --git a/src/everything/server/tasks.ts b/src/everything/server/tasks.ts index cc7f101aa9..af761b1ac3 100644 --- a/src/everything/server/tasks.ts +++ b/src/everything/server/tasks.ts @@ -105,6 +105,14 @@ const tasks = new Map(); */ export const MAX_TASKS = 100; +/** + * The most bytes of answers, serialized, one `tasks/update` may hand a task. + * A task keeps its answers until its TTL, and a transport accepts messages + * far larger than any of these tasks needs (10 MiB on stdio), so with + * `MAX_TASKS` this bounds what the store holds. + */ +export const MAX_ANSWER_BYTES = 64 * 1024; + /** Drop every task past its TTL. */ function sweep(now: number): void { for (const [taskId, task] of tasks) { @@ -405,6 +413,13 @@ export function registerTasksExtension(server: McpServer): void { outstanding.includes(key), ), ); + const size = JSON.stringify(answered).length; + if (size > MAX_ANSWER_BYTES) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Invalid params for tasks/update: the answers are ${size} bytes, over the ${MAX_ANSWER_BYTES}-byte limit; the task is unchanged`, + ); + } if (Object.keys(answered).length > 0) { task.behavior.receive(answered, now); } diff --git a/src/everything/tools/simulate-research-query.ts b/src/everything/tools/simulate-research-query.ts index b51f74fa6d..fc558ad2db 100644 --- a/src/everything/tools/simulate-research-query.ts +++ b/src/everything/tools/simulate-research-query.ts @@ -71,7 +71,7 @@ const config = { "input_required until the client answers its clarification request with tasks/update.", inputSchema: SimulateResearchQuerySchema, annotations: { - // Each call creates a task, and a full task store drops its oldest one. + // Each call creates a task in the process-wide task store. readOnlyHint: false, destructiveHint: false, idempotentHint: false, From 7bb70f6f78acea1e986fe0c1f716304bb6bdcc2d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 01:03:12 -0400 Subject: [PATCH 086/117] fix(everything): declare get-header-params' nullable regionHeader with anyOf The Inspector's schema-portability check warns on `type: ["string", "null"]`: legal JSON Schema, but a client that maps tool schemas onto a single-`type` dialect (the OpenAPI subset of Gemini function declarations) may reject the tool or drop the constraint. regionHeader is now `anyOf: [{ type: "string" }, { type: "null" }]`; null still validates. Part of #4852 Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/everything/tools/get-header-params.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/everything/tools/get-header-params.ts b/src/everything/tools/get-header-params.ts index ce82a10747..89d3278379 100644 --- a/src/everything/tools/get-header-params.ts +++ b/src/everything/tools/get-header-params.ts @@ -55,8 +55,12 @@ const outputSchema: JsonSchemaType = { type: "object", properties: { region: { type: "string" }, + // Nullable as anyOf with one type per branch, not `type: ["string", + // "null"]`: some clients map tool schemas onto a dialect that allows a + // single `type` per property (the OpenAPI subset of Gemini function + // declarations, for one) and reject or drop the array form. regionHeader: { - type: ["string", "null"], + anyOf: [{ type: "string" }, { type: "null" }], description: "The Mcp-Param-Region header the request carried, if any", }, repeated: { type: "array", items: { type: "string" } }, From e923f51b59bc2da79da64434ac7ae84d37403a2d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 23:13:09 -0400 Subject: [PATCH 087/117] docs: always start PR work in a new git worktree AGENTS.md's "When work begins" rule now creates the feature branch in a new git worktree rather than by switching branches in the primary checkout, which concurrent agent sessions share, and removes it once the PR merges or closes. pr-flow step 2 carries the recipe (a sibling worktree, --no-track, the npm install inline, stacked branches cut from the parent) and step 10 the removal. Mirrors modelcontextprotocol/inspector#2663. Closes #5138 Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .claude/skills/pr-flow/SKILL.md | 53 +++++++++++++++++++++++++++++---- AGENTS.md | 6 +++- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/.claude/skills/pr-flow/SKILL.md b/.claude/skills/pr-flow/SKILL.md index 23da7d7c15..51df335d3b 100644 --- a/.claude/skills/pr-flow/SKILL.md +++ b/.claude/skills/pr-flow/SKILL.md @@ -39,13 +39,40 @@ gh issue edit --repo modelcontextprotocol/servers --add-assignee @me ## 2. Branch -`v2//-`, cut from **`origin/v2/main`**: +`v2//-`, cut from **`origin/v2/main`** into a **new git +worktree**, never by switching branches in the primary checkout. Several agent +sessions work this repo on one machine at once and share the primary checkout: +a `git switch` there moves the branch out from under every other session using +it, and carries one task's uncommitted edits, `dist/` output and +`node_modules` into another's. A worktree per issue gives each task its own +tree and leaves the primary checkout alone. + +Run it from the primary checkout. The worktree is a **sibling** directory named +for the checkout plus the issue number (`servers` → `../servers-4810`), never a +directory inside the repo, where every format, lint and typecheck scope would +see a second copy of the source: ```sh git fetch origin v2/main -git switch -c v2/fix/4810-filesystem-symlink-escape origin/v2/main +git worktree add --no-track "../$(basename "$PWD")-4810" \ + -b v2/fix/4810-filesystem-symlink-escape origin/v2/main +cd "../$(basename "$PWD")-4810" +npm install ``` +- `--no-track` keeps the new branch from tracking `origin/v2/main`, so + `git status` does not report your work as "ahead of v2/main", and the first + push is an explicit `git push -u origin ` to a branch of its own name. +- ⚠️ **The `npm install` is part of the step.** A worktree starts with no + `node_modules`, no `dist/` and no `.venv` (`/local-dev`). The root install + builds every TypeScript server; a Python server builds its `.venv` on its + first `uv run`. Background the install if you have edits to make first; the + gate needs it finished. +- Every later step (edits, the gate, commits, the push) runs **inside the + worktree**. +- The harness's `EnterWorktree` tool, where available, does the same; name the + branch by the rule above. + `` follows the issue's type label: `bug` → `fix`, `enhancement` → `feat`, `documentation` → `docs`, `chore` → `chore`. A `question` has no branch type, because answering one ships nothing: if it turns out to need a change, relabel @@ -56,10 +83,11 @@ branch and keeps the work legible in `git branch -a` next to `main`. history, so the PR diff would show unrelated changes. **Stacking.** When one issue's work depends on another PR that has not merged -yet, branch from that PR's branch and open the PR with `--base` set to it. Say -in the body that it is stacked and on which PR. When the lower PR merges, -retarget the stacked one to `v2/main` (`gh pr edit --base v2/main`) and -rebase it if GitHub reports conflicts. +yet, branch from that PR's branch (its worktree is cut from it: +`git worktree add … -b `) and open the PR with `--base` +set to it. Say in the body that it is stacked and on which PR. When the lower +PR merges, retarget the stacked one to `v2/main` +(`gh pr edit --base v2/main`) and rebase it if GitHub reports conflicts. ## 3. Sign off every commit (DCO) @@ -391,3 +419,16 @@ issue will not be done, the issue shipped nothing: close it as not planned and **delete its card** (`/board-ops`) rather than moving it to Done. If PRs were stacked on this one, retarget each to `v2/main` (step 2). + +**Then remove the worktree and its local branch**, from the primary checkout, +once the PR shows as merged (or closed, for abandoned work). A stale worktree +keeps its branch checked out, so git refuses to check that branch out anywhere +else, and it holds a full `node_modules` on disk per issue: + +```sh +git worktree remove "../$(basename "$PWD")-" +git branch -D v2/fix/- +``` + +`git worktree remove` refuses a worktree with uncommitted or untracked changes; +read what is there before reaching for `--force`. diff --git a/AGENTS.md b/AGENTS.md index 56bce9effa..c59601bd03 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -491,7 +491,11 @@ here. - **When work begins**, assign the issue to yourself, set Status to **In Progress**, and create a branch named `v2//-` (for example - `v2/fix/4810-filesystem-symlink-escape`) from `origin/v2/main`. + `v2/fix/4810-filesystem-symlink-escape`) from `origin/v2/main`, **in a new + git worktree**: never by switching branches in the primary checkout, which + concurrent agent sessions share. Remove the worktree once the PR merges or + is closed. The recipe is the `pr-flow` skill (step 2, and step 10 for the + removal). - **When work is complete**, run the checks under [Before pushing](#before-pushing), open a PR against `v2/main` labeled `v2` with `Closes #` as the body's first line, and set Status to From dedd4119fe49a60448f53e2762e6ebfc94faea7b Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 23:15:34 -0400 Subject: [PATCH 088/117] docs: record the v2/main ruleset in AGENTS.md v2/main now has a repository ruleset, "v2/main: PRs only", that refuses deletion, force-pushes and direct pushes, with no bypass actors and no required approvals. No automation pushes to v2/main, so nothing needs a bypass. Record the rule next to the branch table, and note that the main guard's trust in v2/main's history rests on it. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- AGENTS.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 56bce9effa..4c9d3372f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -274,6 +274,15 @@ Cut feature branches from **`origin/v2/main`**. **Never open a PR against `main`**, with one exception: a milestone's merge PR, which the `release` skill opens. There is no `v1` line in this repository. +**`v2/main` changes only through a merged PR.** Its ruleset, `v2/main: PRs +only`, refuses deletion, force-pushes and direct pushes, and has no bypass +actors. It requires no approvals, so a maintainer can merge their own PR. No +automation pushes to `v2/main`: the Version Packages and Prepare Python +Release workflows push branches of their own and open PRs. A workflow that +needs to push to `v2/main` directly is a change to this rule and its ruleset, +decided first. The `main` guard (**Source is v2/main**) trusts `v2/main`'s +history, so this ruleset is what makes that trust hold. + - **Repo**: https://github.com/modelcontextprotocol/servers - **Project board**: [Servers V2 (#43)](https://github.com/orgs/modelcontextprotocol/projects/43) From 3875197d77e2618ab7f83d6c5b3fedd19b299971 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 23:18:15 -0400 Subject: [PATCH 089/117] docs(pr-flow): use the generic branch pattern in the worktree cleanup Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .claude/skills/pr-flow/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/skills/pr-flow/SKILL.md b/.claude/skills/pr-flow/SKILL.md index 51df335d3b..dcf4d25482 100644 --- a/.claude/skills/pr-flow/SKILL.md +++ b/.claude/skills/pr-flow/SKILL.md @@ -427,7 +427,7 @@ else, and it holds a full `node_modules` on disk per issue: ```sh git worktree remove "../$(basename "$PWD")-" -git branch -D v2/fix/- +git branch -D v2//- ``` `git worktree remove` refuses a worktree with uncommitted or untracked changes; From d01c3df80b350ee339647ed2003285f7dc104844 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 23:23:37 -0400 Subject: [PATCH 090/117] docs(release): cut the release branches into worktrees too The release skill's audit, hand-run CalVer and merge-branch recipes still ran git switch -c in the shared checkout, contradicting the new rule. Each now uses the pr-flow worktree form, and close-out removes them. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .claude/skills/release/SKILL.md | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 5cf4bd5180..7f334f578a 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -69,13 +69,21 @@ branch and reach `main` with everything else. **All are merged before the merge PR is opened.** A milestone that touches only one language, with a clean audit, has one. +Each branch this skill cuts by hand (the audit, the hand-run CalVer stamp, the +merge branch) goes into **its own sibling worktree**, as `/pr-flow` step 2 +does, never by switching branches in the primary checkout. Run each recipe from +the primary checkout; the worktree is named for the checkout, the release +issue and the branch's role, since one issue owns several branches. + ### 2a. The audit Audit first, so the version bumps sit on a tree that was just checked. ```sh git fetch origin v2/main -git switch -c "v2/chore/$N-release-audit" origin/v2/main +git worktree add --no-track "../$(basename "$PWD")-$N-audit" \ + -b "v2/chore/$N-release-audit" origin/v2/main +cd "../$(basename "$PWD")-$N-audit" && npm install # npm: one lockfile covers the root and all four TypeScript workspaces. npm audit --audit-level=high @@ -197,7 +205,9 @@ the same script: ```sh git fetch origin v2/main -git switch -c "v2/chore/$N-python-calver-$(date -u +%Y.%-m.%-d)" origin/v2/main +git worktree add --no-track "../$(basename "$PWD")-$N-calver" \ + -b "v2/chore/$N-python-calver-$(date -u +%Y.%-m.%-d)" origin/v2/main +cd "../$(basename "$PWD")-$N-calver" && npm install node scripts/prepare-python-release.mjs # prints "name: old -> new" per stamped package git commit -s -am "chore: stamp Python CalVer versions for $(date -u +%Y.%-m.%-d)" ``` @@ -216,7 +226,9 @@ PR would then add a second.) ```sh git fetch origin main v2/main -git switch -c "v2/chore/$N-release-$MILESTONE" origin/v2/main +git worktree add --no-track "../$(basename "$PWD")-$N-merge" \ + -b "v2/chore/$N-release-$MILESTONE" origin/v2/main +cd "../$(basename "$PWD")-$N-merge" ``` **The merge's result is the release candidate. Prove it will be `v2/main`'s @@ -336,8 +348,8 @@ is not optional: if it prints anything but `active`, set it back by hand Work from a **dedicated worktree** with its own install, so nothing stale from another branch is tested. It is added **detached** at the pushed merge branch: -the branch itself is still checked out where step 3 created it, and git -refuses to check one branch out twice. +the branch itself is still checked out in step 3's worktree, and git refuses +to check one branch out twice. ```sh git worktree add --detach ../servers-release "origin/v2/chore/$N-release-$MILESTONE" @@ -617,9 +629,10 @@ Then close out, by hand, since nothing closed the release issue: gh issue close "$N" --repo modelcontextprotocol/servers --reason completed ``` -Move its card to **Done** (`/board-ops`), remove the worktree -(`git worktree remove ../servers-release`), and tell the maintainer the -milestone can be closed. +Move its card to **Done** (`/board-ops`), remove the worktrees from the +primary checkout (`git worktree remove ../servers-release`, and each +`../-$N-` from steps 2 and 3 with its local branch, as in +`/pr-flow` step 10), and tell the maintainer the milestone can be closed. **If the run failed**, `RELEASING.md` **When a publish fails** has the two cases: a transient failure is re-run on the same run; a defect is fixed on From ce15905d0a9866f51831e208fd97d0da76975e41 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 12 Oct 2026 23:34:57 -0400 Subject: [PATCH 091/117] ci: declare read-only GITHUB_TOKEN permissions in python.yml and typescript.yml Neither workflow declared a permissions block, so every job's token took the repository default. Scope it to contents: read in the file, which is all these jobs use. The other workflows already declare theirs. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .github/workflows/python.yml | 7 +++++++ .github/workflows/typescript.yml | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 4f71471f89..d6180243a5 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -12,6 +12,13 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} +# Every job here only reads the repository (checkout, caches, artifacts), so +# the token is scoped in the file rather than inherited from the repository's +# default workflow permission, which could be widened in the UI with no commit +# (#5091). +permissions: + contents: read + jobs: detect-packages: timeout-minutes: 5 diff --git a/.github/workflows/typescript.yml b/.github/workflows/typescript.yml index ce55bd4477..2a909fa4bb 100644 --- a/.github/workflows/typescript.yml +++ b/.github/workflows/typescript.yml @@ -12,6 +12,13 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} +# Every job here only reads the repository (checkout, caches, artifacts), so +# the token is scoped in the file rather than inherited from the repository's +# default workflow permission, which could be widened in the UI with no commit +# (#5091). +permissions: + contents: read + jobs: # Root-level guards: the checks over repo-wide tooling that no per-package # matrix leg covers. Created by #4863 for the skills gates; #4864 extended it From 0d40c33ac9785884826e10499f15c0883c071647 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 13 Oct 2026 00:46:37 -0400 Subject: [PATCH 092/117] docs: add an AGENTS.md rule for pacing bulk GitHub writes GitHub's secondary rate limit refuses bulk writes while the primary quota still shows headroom, and a refused create can still land. Add the rule under "Pacing GitHub writes", and point contribution-model.md, issue-triage, board-ops and pr-flow at it instead of stating their own pacing. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .claude/skills/board-ops/SKILL.md | 6 +++++- .claude/skills/issue-triage/SKILL.md | 7 ++++--- .claude/skills/pr-flow/SKILL.md | 3 +++ AGENTS.md | 8 ++++++++ docs/contribution-model.md | 6 +++--- 5 files changed, 23 insertions(+), 7 deletions(-) diff --git a/.claude/skills/board-ops/SKILL.md b/.claude/skills/board-ops/SKILL.md index 159146614e..33b88796b3 100644 --- a/.claude/skills/board-ops/SKILL.md +++ b/.claude/skills/board-ops/SKILL.md @@ -429,13 +429,17 @@ if [ -s "$BOARD_TMP/lost-ids.json" ]; then gh project item-edit --project-id PVT_kwDOCt2Azc4BcZgq --id "$id" \ --field-id PVTSSF_lADOCt2Azc4BcZgqzhXCpm0 --single-select-option-id \ || { echo "item-edit failed on $id — stopping; re-run step 1 to see what is left" >&2; break; } - sleep 0.4 + sleep 1 # AGENTS.md "Pacing GitHub writes" done else echo "no lost-ids.json — step 1 did not complete; nothing re-applied" >&2 fi ``` +Step 3 paces its edits by `AGENTS.md` **Pacing GitHub writes**; a board of +several hundred cards takes more than one hour's allowance, so expect to resume +it. + Step 1's join is the safety check: only a card that held the deleted option in the snapshot is re-applied, so you don't overwrite a card that was already blank or that someone legitimately cleared in the meantime. Read its "NOT restored" diff --git a/.claude/skills/issue-triage/SKILL.md b/.claude/skills/issue-triage/SKILL.md index d0dfbaae7d..344913d7e9 100644 --- a/.claude/skills/issue-triage/SKILL.md +++ b/.claude/skills/issue-triage/SKILL.md @@ -42,9 +42,9 @@ reporter is notified of each one. So: manifest** that lists each item, its class, and the issue it will point at. A wrong class is cheap to fix in a file and expensive to fix after a notification went out. Closing a single item the maintainer named is fine. -- **Pace every write loop**: a few seconds between calls, and **stop on the - first failed call** rather than carrying on blind. GitHub's secondary rate - limit on content creation trips well before its primary one, and a loop that +- **Pace every write loop** by `AGENTS.md` **Pacing GitHub writes**, and + **stop on the first failed call** rather than carrying on blind. GitHub's + secondary rate limit trips well before its primary one, and a loop that keeps going after a 403 leaves a half-applied batch nobody can see. - **Never restate a vulnerability in public**, not even to summarize a report that was already filed publicly. See [Security reports](#security-reports). @@ -447,6 +447,7 @@ while IFS=$'\t' read -r PR CLASS ISSUE _; do if [ -n "$SWEEP_LABEL" ]; then gh pr edit "$PR" --repo modelcontextprotocol/servers --add-label "$SWEEP_LABEL" &2; break; } + sleep 1 # AGENTS.md "Pacing GitHub writes": one request per second, even within an item fi gh pr close "$PR" --repo modelcontextprotocol/servers --comment "$BODY" &2; break; } diff --git a/.claude/skills/pr-flow/SKILL.md b/.claude/skills/pr-flow/SKILL.md index dcf4d25482..9b5551dc7c 100644 --- a/.claude/skills/pr-flow/SKILL.md +++ b/.claude/skills/pr-flow/SKILL.md @@ -338,6 +338,9 @@ worth doing on its own. Implementing a suggestion differently is fine. -f body='Fixed in : …' ``` + A round with many comments is a bulk write: send the replies one at a time, + paced by `AGENTS.md` **Pacing GitHub writes**. + - ⚠️ **Then post a PR-level summary of the round, in addition, never instead.** Inline replies go hidden once the fix is pushed, because the threads become outdated, so the summary is what keeps the round readable afterwards. It does diff --git a/AGENTS.md b/AGENTS.md index b790099673..4a0736f1f0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -600,6 +600,14 @@ would not, burns turns, and buries the result under no-op turns. - ⚠️ **If a notifier is already armed, wait for it.** Re-checking by hand alongside it is the polling this rule forbids. +## Pacing GitHub writes + +**Pace bulk GitHub writes: 1 s between requests, never in parallel, under 80 a +minute and 500 an hour.** The secondary limit refuses while `gh api rate_limit` +still shows quota, and most `gh` writes are GraphQL. After a refusal, wait out +`retry-after`, or a minute without one; a refused create can still land, so +look before retrying. + ## MCP protocol - **Look protocol questions up; do not answer them from memory.** The repo diff --git a/docs/contribution-model.md b/docs/contribution-model.md index 496fe1c2c8..7da06a063a 100644 --- a/docs/contribution-model.md +++ b/docs/contribution-model.md @@ -137,9 +137,9 @@ issue so triage can score it. ### 3. Close Close each outside PR with the comment for its class, naming the harvest issue -where there is one. Pace the closes (a pause of a few seconds between them, in -batches) to stay under GitHub's secondary rate limit on content creation, and -stop on the first failed call rather than carrying on blind. +where there is one. Pace the closes as +[`AGENTS.md` **Pacing GitHub writes**](../AGENTS.md#pacing-github-writes) says, +and stop on the first failed call rather than carrying on blind. Closing a PR does not delete it: its branch, diff and discussion stay readable, and the harvest issue links it. From fd55745f663d25622ed1478ae199f8e145bc15d1 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 13 Oct 2026 00:48:44 -0400 Subject: [PATCH 093/117] fix(scripts): double backslashes before a quote in win-shell-args An argument ending in a backslash was quoted as "...\\", which the CRT parser reads as an escaped quote, swallowing the rest of the command line. Backslash runs before an embedded quote or the closing quote are now doubled, with tests including a round trip through a reference CRT parser. Closes #5111 Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- scripts/lib/win-shell-args.mjs | 12 +++++- scripts/lib/win-shell-args.test.mjs | 62 +++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/scripts/lib/win-shell-args.mjs b/scripts/lib/win-shell-args.mjs index 5467b37688..151ee87cab 100644 --- a/scripts/lib/win-shell-args.mjs +++ b/scripts/lib/win-shell-args.mjs @@ -43,8 +43,16 @@ export function quoteWinArg(arg) { ); } if (!NEEDS_QUOTING.test(arg)) return arg; - // A literal `"` inside a quoted string is doubled, per the CRT parser. - return `"${arg.replace(/"/g, '""')}"`; + // The CRT parser reads backslashes literally except before a `"`, where 2n + // backslashes yield n and leave the quote special. So any run of backslashes + // that ends up in front of a quote is doubled: one before an embedded `"`, + // and a trailing run, which the closing quote follows (#5111). Undoubled, + // `C:\dir\` becomes `"C:\dir\"`, whose `\"` is an escaped quote and swallows + // the rest of the command line. The embedded `"` itself is then doubled + // rather than written `\"`: `""` keeps `cmd.exe`'s own quote tracking in + // step, while `\"` would flip it and expose any metacharacter after it. + const escaped = arg.replace(/(\\*)"/g, '$1$1""').replace(/(\\+)$/, "$1$1"); + return `"${escaped}"`; } /** diff --git a/scripts/lib/win-shell-args.test.mjs b/scripts/lib/win-shell-args.test.mjs index dac6481f4a..5794446cdf 100644 --- a/scripts/lib/win-shell-args.test.mjs +++ b/scripts/lib/win-shell-args.test.mjs @@ -39,6 +39,68 @@ test("doubles an embedded quote", () => { assert.equal(quoteWinArg('a"b c'), '"a""b c"'); }); +test("doubles a trailing backslash run, which the closing quote follows (#5111)", () => { + // Undoubled, `"C:\dir with space\"` ends in `\"`, an escaped quote, and the + // CRT parser runs the argument on into the next one. + assert.equal(quoteWinArg("C:\\a b\\"), '"C:\\a b\\\\"'); + assert.equal(quoteWinArg("C:\\a b\\\\"), '"C:\\a b\\\\\\\\"'); +}); + +test("doubles backslashes before an embedded quote, then doubles the quote", () => { + assert.equal(quoteWinArg('a\\"b c'), '"a\\\\""b c"'); + assert.equal(quoteWinArg('a\\\\"b'), '"a\\\\\\\\""b"'); +}); + +test("leaves backslashes not followed by a quote alone", () => { + assert.equal(quoteWinArg("C:\\a b\\c"), '"C:\\a b\\c"'); + // An inert trailing-backslash path is not quoted, so nothing follows it. + assert.equal(quoteWinArg("C:\\dir\\"), "C:\\dir\\"); +}); + +test("quoted output parses back to the original under the CRT rules", () => { + // A reference parser for one quoted argument (the MSVCRT 2008+ rules), so + // the expectations above are checked against what the child actually sees. + const parse = (s) => { + let out = ""; + let inQuotes = false; + for (let i = 0; i < s.length; ) { + let n = 0; + while (s[i] === "\\") { + n++; + i++; + } + if (s[i] === '"') { + out += "\\".repeat(n >> 1); + if (n & 1) { + out += '"'; + } else if (inQuotes && s[i + 1] === '"') { + out += '"'; + i++; + } else { + inQuotes = !inQuotes; + } + i++; + } else { + out += "\\".repeat(n); + if (i < s.length) out += s[i++]; + } + } + return out; + }; + for (const arg of [ + "C:\\a b\\", + "C:\\a b\\\\", + 'a\\"b c', + 'a\\\\"b', + 'a"b c', + '"', + "\\ \\", + "C:\\Temp(1)\\", + ]) { + assert.equal(parse(quoteWinArg(arg)), arg, `round trip: ${arg}`); + } +}); + test("throws on `%` rather than silently mis-expanding it", () => { // cmd.exe expands %NAME% before it processes quotes, so quoting cannot save // this one — the child would receive a different path than we passed. From d6fd666467c4615828067f3bc2ab4e6d08c3e4a3 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 13 Oct 2026 00:49:29 -0400 Subject: [PATCH 094/117] fix(sequentialthinking): pad the thought box's header row to the border The header row was drawn unpadded, so it sat 2 columns short of the border rows. Pad it to the border's width, measured on the visible text, as the thought row already is. Closes #5108 Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .changeset/sequentialthinking-header-padding.md | 5 +++++ .../__tests__/thought-logging.test.ts | 15 ++++++++++++++- src/sequentialthinking/lib.ts | 9 +++++++-- 3 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 .changeset/sequentialthinking-header-padding.md diff --git a/.changeset/sequentialthinking-header-padding.md b/.changeset/sequentialthinking-header-padding.md new file mode 100644 index 0000000000..abfbfc207a --- /dev/null +++ b/.changeset/sequentialthinking-header-padding.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/server-sequential-thinking": patch +--- + +Pad the header row of the thought box logged to stderr to the border's width, so its right edge lines up with the rest of the box (#5108). diff --git a/src/sequentialthinking/__tests__/thought-logging.test.ts b/src/sequentialthinking/__tests__/thought-logging.test.ts index 1153d1fadc..7ed39ae1a2 100644 --- a/src/sequentialthinking/__tests__/thought-logging.test.ts +++ b/src/sequentialthinking/__tests__/thought-logging.test.ts @@ -46,7 +46,7 @@ describe("sequentialthinking thought logging", () => { const border = "─".repeat(Math.max(header.length, text.length) + 4); return ` ┌${border}┐ -│ ${header} │ +│ ${header.padEnd(border.length - 2)} │ ├${border}┤ │ ${text.padEnd(border.length - 2)} │ └${border}┘`; @@ -165,6 +165,19 @@ describe("sequentialthinking thought logging", () => { }); }); + // #5108: the header row is padded to the border's width, so every row of + // the box is the same width, whether the header or the thought is longer. + it.each([ + ["the header", "t"], + ["the thought", "a much longer thought than the header line is"], + ])("draws every row the same width when %s is longer", async (_l, text) => { + conn = await connect({ disableThoughtLogging: undefined }); + await conn.think(thought({ thought: text })); + const rows = logged()[0].split("\n").slice(1); + expect(rows).toHaveLength(5); + expect(new Set(rows.map((row) => row.length)).size).toBe(1); + }); + // #5000: when colour is on, the header carries chalk's escape codes, but the // border is sized from the visible text, so the box matches the uncoloured one. it("sizes the border from the visible header, not its escape codes", async () => { diff --git a/src/sequentialthinking/lib.ts b/src/sequentialthinking/lib.ts index cffc8210c0..3e836fce65 100644 --- a/src/sequentialthinking/lib.ts +++ b/src/sequentialthinking/lib.ts @@ -36,7 +36,8 @@ export class SequentialThinkingServer { } = thoughtData; // The label is coloured only after the header is measured, so the border - // is sized from the visible text, not from chalk's escape codes. A missing + // and the header's padding are sized from the visible text, not from + // chalk's escape codes. A missing // revisesThought or branchId is left out of the header rather than printed // as "undefined". let label: string; @@ -63,10 +64,14 @@ export class SequentialThinkingServer { const counts = ` ${thoughtNumber}/${totalThoughts}${context}`; const visibleHeader = `${label}${counts}`; - const header = `${colour(label)}${counts}`; const border = "─".repeat( Math.max(visibleHeader.length, thought.length) + 4, ); + // The header row is padded to the border's width like the thought row, by + // the visible text's length. Each label's emoji is one surrogate pair, so + // its length of 2 is also its display width in a terminal. + const padding = " ".repeat(border.length - 2 - visibleHeader.length); + const header = `${colour(label)}${counts}${padding}`; return ` ┌${border}┐ From c2df185d1b0a35cb8b02a76435efd9225160e58d Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 13 Oct 2026 00:50:37 -0400 Subject: [PATCH 095/117] fix(git): prefix git_show diff headers with a/ and b/ git_show printed bare paths in each file's --- / +++ header, so its output was not a patch that `git apply` or `patch -p1` accepts: their default strips one leading component, reading logic/a.py as a.py. The headers now match `git show`: a/ and b/, with /dev/null unprefixed for an added or deleted side. Fix as proposed by @udbhav-shrinet on the issue. Closes #5107 Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- src/git/src/mcp_server_git/server.py | 8 +++--- src/git/tests/test_protocol.py | 38 +++++++++++++++++++++++----- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/git/src/mcp_server_git/server.py b/src/git/src/mcp_server_git/server.py index 24a3195381..6d1ccfc780 100644 --- a/src/git/src/mcp_server_git/server.py +++ b/src/git/src/mcp_server_git/server.py @@ -405,9 +405,11 @@ def git_show(repo: git.Repo, revision: str) -> str: else: diff = commit.diff(git.NULL_TREE, create_patch=True) for d in diff: - # git prints /dev/null for the missing side of an added or deleted file. - a_path = "/dev/null" if d.new_file or d.a_path is None else d.a_path - b_path = "/dev/null" if d.deleted_file or d.b_path is None else d.b_path + # git prints /dev/null for the missing side of an added or deleted file, + # and prefixes an existing side with a/ or b/, which `git apply` and + # `patch -p1` strip as the first path component. + a_path = "/dev/null" if d.new_file or d.a_path is None else f"a/{d.a_path}" + b_path = "/dev/null" if d.deleted_file or d.b_path is None else f"b/{d.b_path}" output.append(f"\n--- {a_path}\n+++ {b_path}\n") if d.diff is None: continue # pragma: no cover # with create_patch=True GitPython always assigns the patch as bytes diff --git a/src/git/tests/test_protocol.py b/src/git/tests/test_protocol.py index dbac72c881..269c02d77d 100644 --- a/src/git/tests/test_protocol.py +++ b/src/git/tests/test_protocol.py @@ -759,7 +759,7 @@ async def test_git_show_commit_with_parent(repo: git.Repo): ) assert show_text(result) == ( show_header(commit) - + "\n--- test.txt\n+++ test.txt\n" + + "\n--- a/test.txt\n+++ b/test.txt\n" + "@@ -1,5 +1,5 @@\n line 1\n line 2\n-line 3\n+SHOWN\n line 4\n line 5\n" ) text = show_text(result) @@ -779,7 +779,7 @@ async def test_git_show_multiline_message_is_indented(repo: git.Repo): result = await call(None, "git_show", {"repo_path": str(root), "revision": "HEAD"}) text = show_text(result) assert text.startswith(show_header(commit)) - assert "\n\n subject\n \n body line\n\n--- test.txt\n" in text + assert "\n\n subject\n \n body line\n\n--- a/test.txt\n" in text async def test_git_show_initial_commit_diffs_against_empty_tree(repo: git.Repo): @@ -791,7 +791,7 @@ async def test_git_show_initial_commit_diffs_against_empty_tree(repo: git.Repo): ) assert show_text(result) == ( show_header(initial) - + "\n--- /dev/null\n+++ test.txt\n" + + "\n--- /dev/null\n+++ b/test.txt\n" + "@@ -0,0 +1,5 @@\n+line 1\n+line 2\n+line 3\n+line 4\n+line 5\n" ) @@ -804,11 +804,35 @@ async def test_git_show_deleted_file_diffs_to_dev_null(repo: git.Repo): ) assert show_text(result) == ( show_header(commit) - + "\n--- test.txt\n+++ /dev/null\n" + + "\n--- a/test.txt\n+++ /dev/null\n" + "@@ -1,5 +0,0 @@\n-line 1\n-line 2\n-line 3\n-line 4\n-line 5\n" ) +async def test_git_show_patch_applies_with_git_apply(repo: git.Repo): + # The a/ and b/ prefixes make the output a patch `git apply` accepts as + # is: without them its default -p1 strips `logic/` from a nested path + # (#5107). Reverse-applying the commit's own patch must check clean. + root = root_of(repo) + (root / "logic").mkdir() + (root / "logic" / "a.py").write_bytes(b"x = 1\n") + repo.index.add(["logic/a.py"]) + repo.index.commit("add logic/a.py") + (root / "logic" / "a.py").write_bytes(b"x = 2\n") + repo.index.add(["logic/a.py"]) + commit = repo.index.commit("change logic/a.py") + result = await call(None, "git_show", {"repo_path": str(root), "revision": "HEAD"}) + text = show_text(result) + assert text == ( + show_header(commit) + + "\n--- a/logic/a.py\n+++ b/logic/a.py\n" + + "@@ -1 +1 @@\n-x = 1\n+x = 2\n" + ) + patch_file = root.parent / "show.patch" + patch_file.write_bytes(text.encode()) + repo.git.apply("-R", "--check", str(patch_file)) + + async def test_git_show_rename_only_commit_has_header_and_no_patch(repo: git.Repo): # A pure rename yields a diff entry whose patch is empty bytes. repo.index.move(["test.txt", "renamed.txt"]) @@ -817,7 +841,7 @@ async def test_git_show_rename_only_commit_has_header_and_no_patch(repo: git.Rep None, "git_show", {"repo_path": str(root_of(repo)), "revision": "HEAD"} ) assert show_text(result) == ( - show_header(commit) + "\n--- test.txt\n+++ renamed.txt\n" + show_header(commit) + "\n--- a/test.txt\n+++ b/renamed.txt\n" ) @@ -829,7 +853,7 @@ async def test_git_show_binary_commit(repo: git.Repo): result = await call(None, "git_show", {"repo_path": str(root), "revision": "HEAD"}) assert show_text(result) == ( show_header(commit) - + "\n--- /dev/null\n+++ blob.bin\n" + + "\n--- /dev/null\n+++ b/blob.bin\n" + "Binary files /dev/null and b/blob.bin differ\n" ) @@ -844,7 +868,7 @@ async def test_git_show_non_utf8_patch_is_decoded_with_replacement(repo: git.Rep result = await call(None, "git_show", {"repo_path": str(root), "revision": "HEAD"}) assert show_text(result) == ( show_header(commit) - + "\n--- /dev/null\n+++ latin.txt\n" + + "\n--- /dev/null\n+++ b/latin.txt\n" + "@@ -0,0 +1 @@\n+caf\ufffd\n" ) From 6e44a2cec0dd319cc912d9e4d5fb4e65a86b4e45 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 13 Oct 2026 00:53:28 -0400 Subject: [PATCH 096/117] docs: pace the triage and recovery loops under the hourly cap A 1 s or 5 s cadence passes 500 writes an hour within minutes, so both sustained loops now sleep 8 s (450 an hour). The triage loop also splits `gh pr close --comment` into a paced comment and close. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .claude/skills/board-ops/SKILL.md | 8 ++++---- .claude/skills/issue-triage/SKILL.md | 17 +++++++++++++---- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/.claude/skills/board-ops/SKILL.md b/.claude/skills/board-ops/SKILL.md index 33b88796b3..e6b4e67557 100644 --- a/.claude/skills/board-ops/SKILL.md +++ b/.claude/skills/board-ops/SKILL.md @@ -429,16 +429,16 @@ if [ -s "$BOARD_TMP/lost-ids.json" ]; then gh project item-edit --project-id PVT_kwDOCt2Azc4BcZgq --id "$id" \ --field-id PVTSSF_lADOCt2Azc4BcZgqzhXCpm0 --single-select-option-id \ || { echo "item-edit failed on $id — stopping; re-run step 1 to see what is left" >&2; break; } - sleep 1 # AGENTS.md "Pacing GitHub writes" + sleep 8 # 450 an hour: under AGENTS.md "Pacing GitHub writes" for a sustained loop done else echo "no lost-ids.json — step 1 did not complete; nothing re-applied" >&2 fi ``` -Step 3 paces its edits by `AGENTS.md` **Pacing GitHub writes**; a board of -several hundred cards takes more than one hour's allowance, so expect to resume -it. +Step 3 paces its edits by `AGENTS.md` **Pacing GitHub writes**. The 1 s floor +alone would pass the hourly cap within ten minutes, so the loop sleeps 8 s: a +restore of 250 cards takes about 35 minutes. Step 1's join is the safety check: only a card that held the deleted option in the snapshot is re-applied, so you don't overwrite a card that was already blank diff --git a/.claude/skills/issue-triage/SKILL.md b/.claude/skills/issue-triage/SKILL.md index 344913d7e9..9822c62353 100644 --- a/.claude/skills/issue-triage/SKILL.md +++ b/.claude/skills/issue-triage/SKILL.md @@ -447,14 +447,23 @@ while IFS=$'\t' read -r PR CLASS ISSUE _; do if [ -n "$SWEEP_LABEL" ]; then gh pr edit "$PR" --repo modelcontextprotocol/servers --add-label "$SWEEP_LABEL" &2; break; } - sleep 1 # AGENTS.md "Pacing GitHub writes": one request per second, even within an item + sleep 8 fi - gh pr close "$PR" --repo modelcontextprotocol/servers --comment "$BODY" &2; break; } - sleep 5 + # Comment and close as two writes, so each is paced: `gh pr close --comment` + # sends both back to back. + gh pr comment "$PR" --repo modelcontextprotocol/servers --body "$BODY" &2; break; } + sleep 8 + gh pr close "$PR" --repo modelcontextprotocol/servers &2; break; } + sleep 8 done < "$D/manifest.tsv" ``` +Each `sleep 8` paces a sustained loop by `AGENTS.md` **Pacing GitHub writes**: +one write per 8 s is 450 an hour, under the hourly cap that a 1 s floor alone +would pass in under ten minutes. + The step 0 issue closes take the same two sweep additions during the backlog sweep: the close label (`gh issue edit --add-label`) and the `#4875` sentence at the end of the response, in the original comment rather than a From dba6d5f3c33f045ee32701dccf2773c91a0a1e98 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 13 Oct 2026 00:59:09 -0400 Subject: [PATCH 097/117] docs(issue-triage): pace step 0's comment and close as separate writes `gh issue close --comment` sends both writes back to back, so step 0 now comments, pauses, then closes, and the duplicate path paces its three writes the same way. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .claude/skills/issue-triage/SKILL.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/.claude/skills/issue-triage/SKILL.md b/.claude/skills/issue-triage/SKILL.md index 9822c62353..fdc5560b80 100644 --- a/.claude/skills/issue-triage/SKILL.md +++ b/.claude/skills/issue-triage/SKILL.md @@ -86,12 +86,18 @@ D=${D:-$(mktemp -d)} # Copy the class's response from Canned responses into "$D/response.md" (the # text inside the quote, with #ORIGINAL or the SDK link filled in) first. # Close as not planned, with that response (body in a file, so quoting is -# safe). NOT for a duplicate: post the Duplicate response with `gh issue -# comment`, close it with the `board-ops` state_reason=duplicate call, and -# delete its card if it has one (`board-ops`, Delete a card). An empty or missing response stops here instead of closing silently. +# safe). The comment and the close are two writes, paced apart by AGENTS.md +# "Pacing GitHub writes" (`gh issue close --comment` sends them back to back). +# NOT for a duplicate: post the Duplicate response with `gh issue comment`, +# close it with the `board-ops` state_reason=duplicate call, and delete its +# card if it has one (`board-ops`, Delete a card), pacing those three writes +# the same way. An empty or missing response stops here instead of closing +# silently. BODY=$(cat "$D/response.md") && [ -n "$BODY" ] \ - && gh issue close --repo modelcontextprotocol/servers --reason "not planned" --comment "$BODY" \ - || { echo "no response or close failed for # — stop and check it" >&2; false; } + && gh issue comment --repo modelcontextprotocol/servers --body "$BODY" \ + && sleep 1 \ + && gh issue close --repo modelcontextprotocol/servers --reason "not planned" \ + || { echo "no response, or the comment or close failed for # — look before retrying" >&2; false; } # Spam or empty, the one class with no response: gh issue close --repo modelcontextprotocol/servers --reason "not planned" ``` From 810ec72b339f4211c4996e6af9862d77c63c3470 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 13 Oct 2026 01:04:56 -0400 Subject: [PATCH 098/117] docs(skills): drop the restated pacing limits and pace step 0 at 8 s The skills now point to AGENTS.md "Pacing GitHub writes" rather than quoting its numbers, and issue-triage step 0 sleeps 8 s after every write, the same as the PR-close loop. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .claude/skills/board-ops/SKILL.md | 8 ++++---- .claude/skills/issue-triage/SKILL.md | 25 +++++++++++++------------ 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/.claude/skills/board-ops/SKILL.md b/.claude/skills/board-ops/SKILL.md index e6b4e67557..2e07082423 100644 --- a/.claude/skills/board-ops/SKILL.md +++ b/.claude/skills/board-ops/SKILL.md @@ -429,16 +429,16 @@ if [ -s "$BOARD_TMP/lost-ids.json" ]; then gh project item-edit --project-id PVT_kwDOCt2Azc4BcZgq --id "$id" \ --field-id PVTSSF_lADOCt2Azc4BcZgqzhXCpm0 --single-select-option-id \ || { echo "item-edit failed on $id — stopping; re-run step 1 to see what is left" >&2; break; } - sleep 8 # 450 an hour: under AGENTS.md "Pacing GitHub writes" for a sustained loop + sleep 8 # a sustained loop: see AGENTS.md "Pacing GitHub writes" done else echo "no lost-ids.json — step 1 did not complete; nothing re-applied" >&2 fi ``` -Step 3 paces its edits by `AGENTS.md` **Pacing GitHub writes**. The 1 s floor -alone would pass the hourly cap within ten minutes, so the loop sleeps 8 s: a -restore of 250 cards takes about 35 minutes. +Step 3 is a sustained loop, so it sleeps 8 s between edits, long enough to stay +inside every limit in `AGENTS.md` **Pacing GitHub writes** for as long as it +runs: a restore of 250 cards takes about 35 minutes. Step 1's join is the safety check: only a card that held the deleted option in the snapshot is re-applied, so you don't overwrite a card that was already blank diff --git a/.claude/skills/issue-triage/SKILL.md b/.claude/skills/issue-triage/SKILL.md index fdc5560b80..24c56ab051 100644 --- a/.claude/skills/issue-triage/SKILL.md +++ b/.claude/skills/issue-triage/SKILL.md @@ -86,20 +86,22 @@ D=${D:-$(mktemp -d)} # Copy the class's response from Canned responses into "$D/response.md" (the # text inside the quote, with #ORIGINAL or the SDK link filled in) first. # Close as not planned, with that response (body in a file, so quoting is -# safe). The comment and the close are two writes, paced apart by AGENTS.md -# "Pacing GitHub writes" (`gh issue close --comment` sends them back to back). -# NOT for a duplicate: post the Duplicate response with `gh issue comment`, -# close it with the `board-ops` state_reason=duplicate call, and delete its -# card if it has one (`board-ops`, Delete a card), pacing those three writes -# the same way. An empty or missing response stops here instead of closing -# silently. +# safe). The comment and the close are two writes (`gh issue close --comment` +# sends them back to back), and a run closes several issues, so every write +# here is followed by the PR-close loop's `sleep 8`, including the last one +# before the next issue. NOT for a duplicate: post the Duplicate response with +# `gh issue comment`, close it with the `board-ops` state_reason=duplicate +# call, and delete its card if it has one (`board-ops`, Delete a card), with +# the same `sleep 8` after each of those three writes. An empty or missing +# response stops here instead of closing silently. BODY=$(cat "$D/response.md") && [ -n "$BODY" ] \ && gh issue comment --repo modelcontextprotocol/servers --body "$BODY" \ - && sleep 1 \ + && sleep 8 \ && gh issue close --repo modelcontextprotocol/servers --reason "not planned" \ + && sleep 8 \ || { echo "no response, or the comment or close failed for # — look before retrying" >&2; false; } # Spam or empty, the one class with no response: -gh issue close --repo modelcontextprotocol/servers --reason "not planned" +gh issue close --repo modelcontextprotocol/servers --reason "not planned" && sleep 8 ``` ## Pass 1 — sweep onto the board (no approval implied) @@ -466,9 +468,8 @@ while IFS=$'\t' read -r PR CLASS ISSUE _; do done < "$D/manifest.tsv" ``` -Each `sleep 8` paces a sustained loop by `AGENTS.md` **Pacing GitHub writes**: -one write per 8 s is 450 an hour, under the hourly cap that a 1 s floor alone -would pass in under ten minutes. +Each `sleep 8` paces a sustained loop, long enough to stay inside every limit +in `AGENTS.md` **Pacing GitHub writes** for as long as the batch runs. The step 0 issue closes take the same two sweep additions during the backlog sweep: the close label (`gh issue edit --add-label`) and the `#4875` From 2ffe79f5aca1ef3214f8e38b3a5d6f4629630488 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 13 Oct 2026 01:12:23 -0400 Subject: [PATCH 099/117] ci: dispatch CI on the version PRs that automation opens The Version Packages and Prepare Python Release workflows push their PR branches with the workflow token, which starts no workflow, and since #5048 the TypeScript and Python workflows run on push only, so a close and reopen (which fires pull_request) never started them either. Both CI workflows now also accept workflow_dispatch, the one event that token can start, and both version workflows dispatch them on the PR's branch after opening or updating it. RELEASING.md and the release skill describe that path instead of close-and-reopen. Closes #5110 Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .claude/skills/release/SKILL.md | 16 +++++++++---- .github/workflows/prepare-python-release.yml | 16 ++++++++++++- .github/workflows/python.yml | 6 +++++ .github/workflows/typescript.yml | 6 +++++ .github/workflows/version-packages.yml | 24 ++++++++++++++++++++ RELEASING.md | 2 +- 6 files changed, 64 insertions(+), 6 deletions(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 7f334f578a..01c3e7b6b5 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -177,8 +177,14 @@ Then, on the PR: 1. **Tie it to the release issue with a comment**, `Part of #N`. The action rewrites the PR's body on every push to `v2/main`, so a `Closes` line there would not survive; this is the exception `AGENTS.md` names. -2. **Close and reopen it to start CI.** A PR opened with the workflow token - does not trigger workflows. +2. **Wait for its CI to pass.** The workflow token's push starts no workflow, + so the Version Packages workflow dispatches the TypeScript and Python + workflows on `changeset-release/v2/main` after each update; their checks + report on the PR's head commit. Closing and reopening the PR does not start + them (that fires `pull_request`; they run on `push`). If none appear, run + them by hand: + `gh workflow run typescript.yml --ref changeset-release/v2/main` (and + `python.yml`). 3. **Read it**: each bump matches the semver policy in `RELEASING.md`, each `CHANGELOG.md` entry says what a user of the server needs to know, the consumed changeset files are deleted, and `package-lock.json` records the @@ -195,8 +201,10 @@ gh workflow run prepare-python-release.yml --repo modelcontextprotocol/servers \ ``` It opens a PR that starts with `Closes #N`, on a branch named -`v2/chore/-python-calver-`, labeled `v2`. Close and reopen it to start -CI, check that each stamped package really changed, and a maintainer merges it. +`v2/chore/-python-calver-`, labeled `v2`, and dispatches the +TypeScript and Python workflows on that branch, as the Version Packages +workflow does. Wait for that CI to pass, check that each stamped package really +changed, and a maintainer merges it. No PR means no Python server has a shipped change since its last stamp. ⚠️ GitHub only dispatches a workflow that exists on the default branch. Until diff --git a/.github/workflows/prepare-python-release.yml b/.github/workflows/prepare-python-release.yml index 8acd858d89..d6cc064783 100644 --- a/.github/workflows/prepare-python-release.yml +++ b/.github/workflows/prepare-python-release.yml @@ -13,6 +13,12 @@ # milestone's release issue (the `issue` input), its branch is named for that # issue, and it is labeled `v2`. The link is only a cross-reference on # `v2/main`; the release issue stays open until the Release is published. +# +# It pushes the branch with the workflow token, and a push made with that +# token starts no workflow, so the PR would get no TypeScript or Python CI +# (#5110); a close and reopen fires only `pull_request`, which those workflows +# do not run on. So it dispatches both on the branch once the PR is open: +# `workflow_dispatch` is the one event the workflow token can start. name: Prepare Python Release @@ -32,6 +38,8 @@ jobs: permissions: contents: write pull-requests: write + # To dispatch the TypeScript and Python workflows on the PR's branch. + actions: write steps: # The ref this was dispatched on becomes the PR's base, and every PR # targets `v2/main`. A run from `main` would open a version PR straight @@ -100,7 +108,7 @@ jobs: echo "Generated by the **Prepare Python Release** workflow. Nothing is published until a maintainer publishes a GitHub Release." echo echo "> [!NOTE]" - echo "> A PR opened with the workflow token does not trigger CI. Close and reopen this PR to run it." + echo "> A push made with the workflow token starts no CI, so the workflow dispatched the TypeScript and Python workflows on this branch. Their checks report on the head commit. To run them again: \`gh workflow run python.yml --ref $BRANCH\` (and \`typescript.yml\`)." } > "$RUNNER_TEMP/pr-body.md" # A second run on the same day force-updates the branch, and may # stamp a different set of packages; the open PR's body is rewritten @@ -117,3 +125,9 @@ jobs: --title "chore: stamp Python CalVer versions for $DATE" \ --body-file "$RUNNER_TEMP/pr-body.md" fi + # The push above starts no workflow (see the header), so CI is + # dispatched on the branch explicitly, after a first run and a rerun + # alike. + for workflow in typescript.yml python.yml; do + gh workflow run "$workflow" --ref "$BRANCH" + done diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index d6180243a5..57697d6de4 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -5,6 +5,12 @@ on: # PR branch ran each workflow twice (once per event). PR checks still report # from the push run of the head commit. push: + # A push made with the workflow token starts no workflow, so the version PRs + # that automation opens (Version Packages, Prepare Python Release) get no + # push run. Their workflows dispatch this one on the PR's branch instead + # (#5110): `workflow_dispatch` is the one event that token can start, and its + # run reports its checks on the branch's head commit, which the PR shows. + workflow_dispatch: # Superseded runs on the same ref are cancelled (e.g. rapid amend-pushes), # except on main, where every merge keeps its own CI run diff --git a/.github/workflows/typescript.yml b/.github/workflows/typescript.yml index 2a909fa4bb..d5d3907f51 100644 --- a/.github/workflows/typescript.yml +++ b/.github/workflows/typescript.yml @@ -5,6 +5,12 @@ on: # PR branch ran each workflow twice (once per event). PR checks still report # from the push run of the head commit. push: + # A push made with the workflow token starts no workflow, so the version PRs + # that automation opens (Version Packages, Prepare Python Release) get no + # push run. Their workflows dispatch this one on the PR's branch instead + # (#5110): `workflow_dispatch` is the one event that token can start, and its + # run reports its checks on the branch's head commit, which the PR shows. + workflow_dispatch: # Superseded runs on the same ref are cancelled (e.g. rapid amend-pushes), # except on main, where every merge keeps its own CI run diff --git a/.github/workflows/version-packages.yml b/.github/workflows/version-packages.yml index 15c19b1f22..004e7a9f0e 100644 --- a/.github/workflows/version-packages.yml +++ b/.github/workflows/version-packages.yml @@ -14,6 +14,13 @@ # it as an exception and says how it is tied to its release issue instead. The # `v2` label does survive an update, and this workflow applies it. # +# The action pushes the PR's branch with the workflow token, and a push made +# with that token starts no workflow, so the PR would get no TypeScript or +# Python CI (#5110). Closing and reopening it does not help either: that fires +# `pull_request`, and those workflows run on `push`. So after each update this +# workflow dispatches both on the PR's branch; `workflow_dispatch` is the one +# event the workflow token can start. +# # This workflow only ever opens a PR. It gives the action no publish script, so # it never publishes, tags or creates a Release; that is `release.yml`, on a # maintainer-published GitHub Release. @@ -34,6 +41,8 @@ jobs: permissions: contents: write pull-requests: write + # To dispatch the TypeScript and Python workflows on the PR's branch. + actions: write steps: - uses: actions/checkout@v6 @@ -68,3 +77,18 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR: ${{ steps.changesets.outputs.pr-number }} run: gh pr edit "$PR" --add-label v2 + + # The action pushed the branch with the workflow token, which starts no + # workflow, so CI is dispatched on it explicitly (see the header). A + # dispatch on every update matches a push run on every push: the CI + # workflows' concurrency groups cancel the superseded run. + - name: Run CI on the Version Packages PR + if: steps.changesets.outputs.pr-number != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR: ${{ steps.changesets.outputs.pr-number }} + run: | + BRANCH=$(gh pr view "$PR" --json headRefName --jq .headRefName) + for workflow in typescript.yml python.yml; do + gh workflow run "$workflow" --ref "$BRANCH" + done diff --git a/RELEASING.md b/RELEASING.md index fa33776762..e84aaf64d4 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -39,7 +39,7 @@ node scripts/prepare-python-release.mjs # prints one "name: old -> new" line ### PRs opened by these workflows > [!NOTE] -> Both workflows open their PR with the workflow token, and GitHub does not start CI for a PR opened that way. Close and reopen the PR to run CI before merging. The Version Packages PR's body is rewritten on every push to `v2/main`, so it is tied to the release issue by a comment (`Part of #N`) instead of a body line. The repository setting **Settings → Actions → General → Allow GitHub Actions to create and approve pull requests** must be on, or the workflows fail at the step that opens the PR. +> Both workflows push their PR's branch with the workflow token, and a push made with that token starts no workflow, so each one then dispatches the TypeScript and Python workflows on that branch (`workflow_dispatch` is the one event the token can start). Their checks report on the PR's head commit; wait for them to pass before merging. Closing and reopening the PR does not start them: that fires `pull_request`, and those workflows run on `push`. To run them again by hand: `gh workflow run typescript.yml --ref ` (and `python.yml`). The Version Packages PR's body is rewritten on every push to `v2/main`, so it is tied to the release issue by a comment (`Part of #N`) instead of a body line. The repository setting **Settings → Actions → General → Allow GitHub Actions to create and approve pull requests** must be on, or the workflows fail at the step that opens the PR. ## How publishing works From 7071ed7a149ca55071a3c879f0616365ca6c84f4 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 13 Oct 2026 01:20:34 -0400 Subject: [PATCH 100/117] ci: validate the TypeScript servers on Node 22, 24 and 26 in parallel typescript.yml's validate job gains a node axis (22, 24, 26), so each package's format, lint, typecheck, build and tests run on every supported Node line as parallel matrix legs rather than serial steps. The other single-version jobs stay on Node 22, the oldest supported. The Dockerfiles move to node:24-alpine, and AGENTS.md, quality-gate.md and the local-dev skill name Node 24 as the development version and 22 as the oldest supported. No engines field is added or changed. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .claude/skills/local-dev/SKILL.md | 3 ++- .github/workflows/typescript.yml | 11 +++++++++-- AGENTS.md | 5 +++-- docs/quality-gate.md | 7 +++++-- src/everything/Dockerfile | 4 ++-- src/filesystem/Dockerfile | 4 ++-- src/memory/Dockerfile | 4 ++-- src/sequentialthinking/Dockerfile | 4 ++-- 8 files changed, 27 insertions(+), 15 deletions(-) diff --git a/.claude/skills/local-dev/SKILL.md b/.claude/skills/local-dev/SKILL.md index 45e66435c9..0f93424feb 100644 --- a/.claude/skills/local-dev/SKILL.md +++ b/.claude/skills/local-dev/SKILL.md @@ -14,7 +14,8 @@ Driving a running server with a client is `/client-smoke`; tests are ## Install and build -Node 22 for the TypeScript servers, and `uv` for the Python ones. +Node 24 (the development version; 22 is the oldest supported) for the +TypeScript servers, and `uv` for the Python ones. ```sh npm install # at the repo root: all four TypeScript workspaces diff --git a/.github/workflows/typescript.yml b/.github/workflows/typescript.yml index 2a909fa4bb..f082afe9d1 100644 --- a/.github/workflows/typescript.yml +++ b/.github/workflows/typescript.yml @@ -70,6 +70,12 @@ jobs: # `validate` (format:check -> lint -> typecheck -> build -> test). What no # leg covers — the root tooling and the cross-workspace guards — is the # root-guards job above; together they are the root `npm run validate`. + # + # Each package is also validated on every Node line it supports (#5068): + # 22, the oldest supported, through 26. The `node` axis multiplies legs, + # not steps, so the versions run side by side and the job takes no longer + # than its slowest leg. The other jobs here stay on one Node, 22: the oldest + # supported, so an API only newer Node has cannot slip in unnoticed. validate: timeout-minutes: 15 needs: [detect-packages] @@ -77,14 +83,15 @@ jobs: fail-fast: false matrix: package: ${{ fromJson(needs.detect-packages.outputs.packages) }} - name: Validate ${{ matrix.package }} + node: [22, 24, 26] + name: Validate ${{ matrix.package }} (Node ${{ matrix.node }}) runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v7 with: - node-version: 22 + node-version: ${{ matrix.node }} cache: npm # This package plus the root: run from a workspace directory, `npm ci` diff --git a/AGENTS.md b/AGENTS.md index 4a0736f1f0..0271029766 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,8 +96,9 @@ opening paragraph instead, as this one does. A format with no comment syntax ## Development setup -Node **22** for the TypeScript servers. Python **>= 3.10** for the Python -servers, at the version each server's `.python-version` pins. Python +Node **24** for the TypeScript servers, the development version; Node **22** +is the oldest supported, and CI validates each TypeScript server on 22, 24 and +26 in parallel legs. Python **>= 3.10** for the Python servers, at the version each server's `.python-version` pins. Python dependencies are managed with **`uv`, never `pip`**. ```sh diff --git a/docs/quality-gate.md b/docs/quality-gate.md index aa952d2f24..75434577dc 100644 --- a/docs/quality-gate.md +++ b/docs/quality-gate.md @@ -19,8 +19,11 @@ out; everything else points here. into parallel jobs on a fresh install; the gate runs them in sequence on your checkout. So a green gate means every check CI applies has already passed on your machine. It is the strongest predictor of a green CI there is here, though -not a proof: CI runs on Linux, on a fresh `npm ci`, with Node 22 and each -Python server's pinned interpreter. +not a proof: CI runs on Linux, on a fresh `npm ci`, with Node 22 (the oldest +supported) and each Python server's pinned interpreter, and `typescript.yml`'s +`validate` job runs each TypeScript server on Node 22, 24 and 26, one parallel +leg per version. The gate runs on whichever Node you have; use Node 24, the +development version. **There are two exceptions.** The first is the Windows legs (#1149): `test-windows` in both workflows runs each server's test suite (`npm test`, `pytest`) on a diff --git a/src/everything/Dockerfile b/src/everything/Dockerfile index 6729298b06..3a29550add 100644 --- a/src/everything/Dockerfile +++ b/src/everything/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22.12-alpine AS builder +FROM node:24-alpine AS builder COPY src/everything /app COPY tsconfig.json /tsconfig.json @@ -7,7 +7,7 @@ WORKDIR /app RUN --mount=type=cache,target=/root/.npm npm install -FROM node:22-alpine AS release +FROM node:24-alpine AS release WORKDIR /app diff --git a/src/filesystem/Dockerfile b/src/filesystem/Dockerfile index 418b140056..13733acbcb 100644 --- a/src/filesystem/Dockerfile +++ b/src/filesystem/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22.12-alpine AS builder +FROM node:24-alpine AS builder WORKDIR /app @@ -10,7 +10,7 @@ RUN --mount=type=cache,target=/root/.npm npm install RUN --mount=type=cache,target=/root/.npm-production npm ci --ignore-scripts --omit-dev -FROM node:22-alpine AS release +FROM node:24-alpine AS release WORKDIR /app diff --git a/src/memory/Dockerfile b/src/memory/Dockerfile index 2f85d0cfa2..1220923700 100644 --- a/src/memory/Dockerfile +++ b/src/memory/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22.12-alpine AS builder +FROM node:24-alpine AS builder COPY src/memory /app COPY tsconfig.json /tsconfig.json @@ -9,7 +9,7 @@ RUN --mount=type=cache,target=/root/.npm npm install RUN --mount=type=cache,target=/root/.npm-production npm ci --ignore-scripts --omit-dev -FROM node:22-alpine AS release +FROM node:24-alpine AS release COPY --from=builder /app/dist /app/dist COPY --from=builder /app/package.json /app/package.json diff --git a/src/sequentialthinking/Dockerfile b/src/sequentialthinking/Dockerfile index f1a88195bc..5d3548d5cb 100644 --- a/src/sequentialthinking/Dockerfile +++ b/src/sequentialthinking/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22.12-alpine AS builder +FROM node:24-alpine AS builder COPY src/sequentialthinking /app COPY tsconfig.json /tsconfig.json @@ -9,7 +9,7 @@ RUN --mount=type=cache,target=/root/.npm npm install RUN --mount=type=cache,target=/root/.npm-production npm ci --ignore-scripts --omit-dev -FROM node:22-alpine AS release +FROM node:24-alpine AS release COPY --from=builder /app/dist /app/dist COPY --from=builder /app/package.json /app/package.json From 0b58b3332615a17e3a3711dfd09d0df3bffde00f Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 13 Oct 2026 01:23:54 -0400 Subject: [PATCH 101/117] ci: report dispatched CI runs on the PR as commit statuses A workflow_dispatch run's checks attach to the head commit but are left out of its status rollup, which is what a pull request reads: a probe commit with 29 successful dispatched check runs had a rollup of null. So a version PR would have shown no CI at all. On a dispatch, each CI workflow now posts a pending commit status at the start and the run's result at the end, which the rollup does include. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .claude/skills/release/SKILL.md | 11 ++-- .github/workflows/prepare-python-release.yml | 2 +- .github/workflows/python.yml | 63 +++++++++++++++++++- .github/workflows/typescript.yml | 63 +++++++++++++++++++- RELEASING.md | 2 +- 5 files changed, 132 insertions(+), 9 deletions(-) diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 01c3e7b6b5..f56c8bfba9 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -179,10 +179,11 @@ Then, on the PR: would not survive; this is the exception `AGENTS.md` names. 2. **Wait for its CI to pass.** The workflow token's push starts no workflow, so the Version Packages workflow dispatches the TypeScript and Python - workflows on `changeset-release/v2/main` after each update; their checks - report on the PR's head commit. Closing and reopening the PR does not start - them (that fires `pull_request`; they run on `push`). If none appear, run - them by hand: + workflows on `changeset-release/v2/main` after each update. A PR does not + list dispatched checks, so the result shows as two commit statuses, + `TypeScript (dispatched)` and `Python (dispatched)`. Closing and reopening + the PR does not start them (that fires `pull_request`; they run on `push`). + If neither status appears, run them by hand: `gh workflow run typescript.yml --ref changeset-release/v2/main` (and `python.yml`). 3. **Read it**: each bump matches the semver policy in `RELEASING.md`, each @@ -203,7 +204,7 @@ gh workflow run prepare-python-release.yml --repo modelcontextprotocol/servers \ It opens a PR that starts with `Closes #N`, on a branch named `v2/chore/-python-calver-`, labeled `v2`, and dispatches the TypeScript and Python workflows on that branch, as the Version Packages -workflow does. Wait for that CI to pass, check that each stamped package really +workflow does. Wait for its two `(dispatched)` statuses to pass, check that each stamped package really changed, and a maintainer merges it. No PR means no Python server has a shipped change since its last stamp. diff --git a/.github/workflows/prepare-python-release.yml b/.github/workflows/prepare-python-release.yml index d6cc064783..bfb6c216f9 100644 --- a/.github/workflows/prepare-python-release.yml +++ b/.github/workflows/prepare-python-release.yml @@ -108,7 +108,7 @@ jobs: echo "Generated by the **Prepare Python Release** workflow. Nothing is published until a maintainer publishes a GitHub Release." echo echo "> [!NOTE]" - echo "> A push made with the workflow token starts no CI, so the workflow dispatched the TypeScript and Python workflows on this branch. Their checks report on the head commit. To run them again: \`gh workflow run python.yml --ref $BRANCH\` (and \`typescript.yml\`)." + echo "> A push made with the workflow token starts no CI, so the workflow dispatched the TypeScript and Python workflows on this branch. Their results show on this PR as the \`TypeScript (dispatched)\` and \`Python (dispatched)\` statuses. To run them again: \`gh workflow run python.yml --ref $BRANCH\` (and \`typescript.yml\`)." } > "$RUNNER_TEMP/pr-body.md" # A second run on the same day force-updates the branch, and may # stamp a different set of packages; the open PR's body is rewritten diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 57697d6de4..49f8f056f5 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -9,7 +9,9 @@ on: # that automation opens (Version Packages, Prepare Python Release) get no # push run. Their workflows dispatch this one on the PR's branch instead # (#5110): `workflow_dispatch` is the one event that token can start, and its - # run reports its checks on the branch's head commit, which the PR shows. + # run's checks attach to the branch's head commit. The rollup a PR reads + # leaves dispatched checks out, so the `dispatch-status` jobs below report + # the run as a commit status instead. workflow_dispatch: # Superseded runs on the same ref are cancelled (e.g. rapid amend-pushes), @@ -180,3 +182,62 @@ jobs: with: name: dist-${{ matrix.package }} path: src/${{ matrix.package }}/dist/ + + # A dispatched run's checks attach to the branch's head commit but are left + # out of its status rollup, which is what a pull request reads (verified on + # #5110: 29 successful dispatched check runs, a rollup of null). So on a + # dispatch, which is how a version PR opened by automation gets CI, these two + # jobs mirror the run onto the commit as a status, which the rollup does + # include: pending at the start, then the result of every job above. A push + # run reports through its own checks and skips both. + dispatch-status-pending: + name: Report the dispatched run as pending + if: github.event_name == 'workflow_dispatch' + timeout-minutes: 5 + runs-on: ubuntu-latest + permissions: + statuses: write + steps: + - name: Set the commit status to pending + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh api "repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" \ + -f state=pending -f context="Python (dispatched)" \ + -f description="Dispatched run in progress" \ + -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + + # `!cancelled()`: a run superseded by a newer dispatch posts nothing, and the + # newer run reports instead. A job skipped by its own `if:` counts as passed. + dispatch-status: + name: Report the dispatched run's result + if: github.event_name == 'workflow_dispatch' && !cancelled() + needs: + [ + dispatch-status-pending, + detect-packages, + script-tests, + test, + coverage, + test-windows, + build, + ] + timeout-minutes: 5 + runs-on: ubuntu-latest + permissions: + statuses: write + steps: + - name: Set the commit status to the run's result + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NEEDS: ${{ toJSON(needs) }} + run: | + if jq -e 'all(.[]; .result == "success" or .result == "skipped")' <<<"$NEEDS" >/dev/null; then + STATE=success DESCRIPTION="Every job passed" + else + STATE=failure DESCRIPTION="A job failed; open the run for details" + fi + gh api "repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" \ + -f state="$STATE" -f context="Python (dispatched)" \ + -f description="$DESCRIPTION" \ + -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" diff --git a/.github/workflows/typescript.yml b/.github/workflows/typescript.yml index d5d3907f51..be8cecd386 100644 --- a/.github/workflows/typescript.yml +++ b/.github/workflows/typescript.yml @@ -9,7 +9,9 @@ on: # that automation opens (Version Packages, Prepare Python Release) get no # push run. Their workflows dispatch this one on the PR's branch instead # (#5110): `workflow_dispatch` is the one event that token can start, and its - # run reports its checks on the branch's head commit, which the PR shows. + # run's checks attach to the branch's head commit. The rollup a PR reads + # leaves dispatched checks out, so the `dispatch-status` jobs below report + # the run as a commit status instead. workflow_dispatch: # Superseded runs on the same ref are cancelled (e.g. rapid amend-pushes), @@ -206,3 +208,62 @@ jobs: - name: Boot every server run: npm run smoke + + # A dispatched run's checks attach to the branch's head commit but are left + # out of its status rollup, which is what a pull request reads (verified on + # #5110: 29 successful dispatched check runs, a rollup of null). So on a + # dispatch, which is how a version PR opened by automation gets CI, these two + # jobs mirror the run onto the commit as a status, which the rollup does + # include: pending at the start, then the result of every job above. A push + # run reports through its own checks and skips both. + dispatch-status-pending: + name: Report the dispatched run as pending + if: github.event_name == 'workflow_dispatch' + timeout-minutes: 5 + runs-on: ubuntu-latest + permissions: + statuses: write + steps: + - name: Set the commit status to pending + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh api "repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" \ + -f state=pending -f context="TypeScript (dispatched)" \ + -f description="Dispatched run in progress" \ + -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + + # `!cancelled()`: a run superseded by a newer dispatch posts nothing, and the + # newer run reports instead. A job skipped by its own `if:` counts as passed. + dispatch-status: + name: Report the dispatched run's result + if: github.event_name == 'workflow_dispatch' && !cancelled() + needs: + [ + dispatch-status-pending, + root-guards, + detect-packages, + validate, + coverage, + test-windows, + smoke, + ] + timeout-minutes: 5 + runs-on: ubuntu-latest + permissions: + statuses: write + steps: + - name: Set the commit status to the run's result + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NEEDS: ${{ toJSON(needs) }} + run: | + if jq -e 'all(.[]; .result == "success" or .result == "skipped")' <<<"$NEEDS" >/dev/null; then + STATE=success DESCRIPTION="Every job passed" + else + STATE=failure DESCRIPTION="A job failed; open the run for details" + fi + gh api "repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" \ + -f state="$STATE" -f context="TypeScript (dispatched)" \ + -f description="$DESCRIPTION" \ + -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" diff --git a/RELEASING.md b/RELEASING.md index e84aaf64d4..9164b4cd2e 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -39,7 +39,7 @@ node scripts/prepare-python-release.mjs # prints one "name: old -> new" line ### PRs opened by these workflows > [!NOTE] -> Both workflows push their PR's branch with the workflow token, and a push made with that token starts no workflow, so each one then dispatches the TypeScript and Python workflows on that branch (`workflow_dispatch` is the one event the token can start). Their checks report on the PR's head commit; wait for them to pass before merging. Closing and reopening the PR does not start them: that fires `pull_request`, and those workflows run on `push`. To run them again by hand: `gh workflow run typescript.yml --ref ` (and `python.yml`). The Version Packages PR's body is rewritten on every push to `v2/main`, so it is tied to the release issue by a comment (`Part of #N`) instead of a body line. The repository setting **Settings → Actions → General → Allow GitHub Actions to create and approve pull requests** must be on, or the workflows fail at the step that opens the PR. +> Both workflows push their PR's branch with the workflow token, and a push made with that token starts no workflow, so each one then dispatches the TypeScript and Python workflows on that branch (`workflow_dispatch` is the one event the token can start). A PR does not list dispatched checks, so each dispatched run also reports itself on the PR's head commit as a status, `TypeScript (dispatched)` and `Python (dispatched)`; wait for both to pass before merging. Closing and reopening the PR does not start them: that fires `pull_request`, and those workflows run on `push`. To run them again by hand: `gh workflow run typescript.yml --ref ` (and `python.yml`). The Version Packages PR's body is rewritten on every push to `v2/main`, so it is tied to the release issue by a comment (`Part of #N`) instead of a body line. The repository setting **Settings → Actions → General → Allow GitHub Actions to create and approve pull requests** must be on, or the workflows fail at the step that opens the PR. ## How publishing works From 0f1ac4ff5eb4d6c454b0385d139af79d2b932fa4 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 13 Oct 2026 01:26:54 -0400 Subject: [PATCH 102/117] docs(quality-gate): list the Validate legs per Node in the stage table Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- docs/quality-gate.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/quality-gate.md b/docs/quality-gate.md index 75434577dc..d220d68621 100644 --- a/docs/quality-gate.md +++ b/docs/quality-gate.md @@ -51,7 +51,7 @@ in this order, and the first failure stops the run. | 1 | `verify:install-fresh` | Every package in `node_modules` is at the version `package-lock.json` records, and every `package.json` declares what the lockfile says it does | None needed: CI installs with `npm ci` | | 2 | `verify:dco` | Every commit in `origin/v2/main..HEAD` carries a `Signed-off-by:` trailer matching its author or committer (merge and bot commits exempt) | `dco.yml` → **DCO signoff**, over the pull request's `base.sha..head.sha` | | 3 | `validate` → `validate:guards` | The root guards, listed [below](#the-root-guards) | `typescript.yml` → **Root guards** | -| 4 | `validate` → each workspace's `validate` | Per TypeScript server: `format:check`, `lint` (`--max-warnings 0`), `typecheck`, `build`, `test` | `typescript.yml` → **Validate \** (one leg each) | +| 4 | `validate` → each workspace's `validate` | Per TypeScript server: `format:check`, `lint` (`--max-warnings 0`), `typecheck`, `build`, `test` | `typescript.yml` → **Validate \ (Node \)** (one leg per server per Node: 22, 24 and 26, in parallel) | | 5 | `coverage` | Per TypeScript server: `vitest run --coverage`, failing when any file is below 90% on lines, statements, functions or branches | `typescript.yml` → **Coverage \** (one leg each) | | 6 | `validate:py` | Per Python server: `uv sync --locked`, `ruff check`, `ruff format --check`, `pyright`, `pytest`, `uv build` | `python.yml` → **Test \** (one leg each) | | 7 | `coverage:py` | Per Python server: `uv sync --locked`, then `uv run --frozen pytest --cov --cov-report=term-missing --cov-report=json`; then **every file** in `coverage.json` must reach **90% on lines and 90% on branches** | `python.yml` → **Coverage \** (one leg each) | From 521ddb24e318e55be50a3f087d2e56498449af9c Mon Sep 17 00:00:00 2001 From: cliffhall Date: Tue, 13 Oct 2026 01:34:51 -0400 Subject: [PATCH 103/117] ci: finalize a cancelled dispatched run's status, and pin the path A dispatched run cancelled for any reason other than supersession left its "(dispatched)" status pending forever (Copilot). The result job now runs under always(): it stays silent only when a newer dispatched run of the same workflow exists on the commit, and otherwise reports the cancellation as an error. scripts/version-pr-ci.test.mjs pins each link of the version-PR CI path, since nothing exercises it before a release. Co-Authored-By: Claude Opus 5.5 Signed-off-by: cliffhall --- .github/workflows/python.yml | 24 +++++++-- .github/workflows/typescript.yml | 24 +++++++-- scripts/version-pr-ci.test.mjs | 86 ++++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 8 deletions(-) create mode 100644 scripts/version-pr-ci.test.mjs diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 49f8f056f5..5fb8894779 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -207,11 +207,13 @@ jobs: -f description="Dispatched run in progress" \ -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" - # `!cancelled()`: a run superseded by a newer dispatch posts nothing, and the - # newer run reports instead. A job skipped by its own `if:` counts as passed. + # `always()`, so a cancelled run cannot leave the status pending. A run that + # was cancelled because a newer dispatch on the same commit superseded it + # posts nothing, and the newer run reports instead; any other cancellation + # is reported as an error. A job skipped by its own `if:` counts as passed. dispatch-status: name: Report the dispatched run's result - if: github.event_name == 'workflow_dispatch' && !cancelled() + if: github.event_name == 'workflow_dispatch' && always() needs: [ dispatch-status-pending, @@ -226,16 +228,30 @@ jobs: runs-on: ubuntu-latest permissions: statuses: write + # To look for a newer dispatched run of this workflow on the commit. + actions: read steps: - name: Set the commit status to the run's result env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} NEEDS: ${{ toJSON(needs) }} run: | if jq -e 'all(.[]; .result == "success" or .result == "skipped")' <<<"$NEEDS" >/dev/null; then STATE=success DESCRIPTION="Every job passed" - else + elif jq -e 'any(.[]; .result == "failure")' <<<"$NEEDS" >/dev/null; then STATE=failure DESCRIPTION="A job failed; open the run for details" + else + NEWER=$(gh run list --workflow python.yml --event workflow_dispatch \ + --commit "$GITHUB_SHA" --json databaseId \ + --jq "[.[] | select(.databaseId > $GITHUB_RUN_ID)] | length") || NEWER=0 + # A failed lookup counts as no newer run: an error status is + # better than one left pending. + if [ "${NEWER:-0}" -gt 0 ]; then + echo "Superseded by a newer dispatched run, which reports instead" + exit 0 + fi + STATE=error DESCRIPTION="The run was cancelled; dispatch it again" fi gh api "repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" \ -f state="$STATE" -f context="Python (dispatched)" \ diff --git a/.github/workflows/typescript.yml b/.github/workflows/typescript.yml index be8cecd386..5bd7bd7911 100644 --- a/.github/workflows/typescript.yml +++ b/.github/workflows/typescript.yml @@ -233,11 +233,13 @@ jobs: -f description="Dispatched run in progress" \ -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" - # `!cancelled()`: a run superseded by a newer dispatch posts nothing, and the - # newer run reports instead. A job skipped by its own `if:` counts as passed. + # `always()`, so a cancelled run cannot leave the status pending. A run that + # was cancelled because a newer dispatch on the same commit superseded it + # posts nothing, and the newer run reports instead; any other cancellation + # is reported as an error. A job skipped by its own `if:` counts as passed. dispatch-status: name: Report the dispatched run's result - if: github.event_name == 'workflow_dispatch' && !cancelled() + if: github.event_name == 'workflow_dispatch' && always() needs: [ dispatch-status-pending, @@ -252,16 +254,30 @@ jobs: runs-on: ubuntu-latest permissions: statuses: write + # To look for a newer dispatched run of this workflow on the commit. + actions: read steps: - name: Set the commit status to the run's result env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} NEEDS: ${{ toJSON(needs) }} run: | if jq -e 'all(.[]; .result == "success" or .result == "skipped")' <<<"$NEEDS" >/dev/null; then STATE=success DESCRIPTION="Every job passed" - else + elif jq -e 'any(.[]; .result == "failure")' <<<"$NEEDS" >/dev/null; then STATE=failure DESCRIPTION="A job failed; open the run for details" + else + NEWER=$(gh run list --workflow typescript.yml --event workflow_dispatch \ + --commit "$GITHUB_SHA" --json databaseId \ + --jq "[.[] | select(.databaseId > $GITHUB_RUN_ID)] | length") || NEWER=0 + # A failed lookup counts as no newer run: an error status is + # better than one left pending. + if [ "${NEWER:-0}" -gt 0 ]; then + echo "Superseded by a newer dispatched run, which reports instead" + exit 0 + fi + STATE=error DESCRIPTION="The run was cancelled; dispatch it again" fi gh api "repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" \ -f state="$STATE" -f context="TypeScript (dispatched)" \ diff --git a/scripts/version-pr-ci.test.mjs b/scripts/version-pr-ci.test.mjs new file mode 100644 index 0000000000..227d7781f4 --- /dev/null +++ b/scripts/version-pr-ci.test.mjs @@ -0,0 +1,86 @@ +// Pins the path by which a version PR opened by automation gets CI (#5110). +// +// The Version Packages and Prepare Python Release workflows push their PR +// branches with the workflow token, which starts no workflow, so they +// dispatch `typescript.yml` and `python.yml` on the branch instead. A +// dispatched run's checks are left out of the status rollup a pull request +// reads, so each CI workflow also mirrors a dispatched run onto the commit as +// a status. Both version workflows run only from `v2/main`, so nothing +// exercises this path before a release needs it; if any link here breaks, +// version PRs silently go back to having no visible CI. Each test names the +// link it guards. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { parse } from "yaml"; + +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); +const read = (rel) => readFileSync(path.join(repoRoot, rel), "utf8"); +const workflow = (name) => parse(read(`.github/workflows/${name}`)); + +const CI = [ + { file: "typescript.yml", context: "TypeScript (dispatched)" }, + { file: "python.yml", context: "Python (dispatched)" }, +]; +const VERSION_WORKFLOWS = [ + { file: "version-packages.yml", job: "version" }, + { file: "prepare-python-release.yml", job: "prepare" }, +]; + +for (const { file, context } of CI) { + const wf = workflow(file); + const jobs = wf.jobs; + const pending = jobs["dispatch-status-pending"]; + const result = jobs["dispatch-status"]; + + test(`${file} runs on push and can be dispatched`, () => { + assert.ok("push" in wf.on, "push trigger"); + assert.ok("workflow_dispatch" in wf.on, "workflow_dispatch trigger"); + }); + + test(`${file} reports a dispatched run as pending, then its result`, () => { + for (const job of [pending, result]) { + assert.ok(job, "both status jobs exist"); + assert.match(job.if, /github\.event_name == 'workflow_dispatch'/); + assert.equal(job.permissions.statuses, "write"); + const run = job.steps.map((s) => s.run ?? "").join("\n"); + assert.match(run, /statuses\/\$GITHUB_SHA/); + assert.ok(run.includes(`context="${context}"`), `context ${context}`); + } + }); + + test(`${file}'s result job waits on every other job, even after a cancel`, () => { + const others = Object.keys(jobs).filter((id) => id !== "dispatch-status"); + assert.deepEqual([...result.needs].sort(), others.sort()); + // `always()`: a cancelled run must not leave the status pending. + assert.match(result.if, /always\(\)/); + assert.equal(result.permissions.actions, "read"); + const run = result.steps.map((s) => s.run ?? "").join("\n"); + assert.ok(run.includes(`gh run list --workflow ${file}`)); + assert.match(run, /STATE=error/); + }); +} + +for (const { file, job } of VERSION_WORKFLOWS) { + const steps = workflow(file).jobs[job]; + + test(`${file} dispatches both CI workflows on its PR's branch`, () => { + assert.equal(steps.permissions.actions, "write"); + const run = steps.steps.map((s) => s.run ?? "").join("\n"); + assert.match(run, /gh workflow run "\$workflow" --ref "\$BRANCH"/); + assert.match(run, /for workflow in typescript\.yml python\.yml; do/); + }); +} + +test("RELEASING.md names the statuses a version PR is merged on", () => { + const releasing = read("RELEASING.md"); + for (const { context } of CI) + assert.ok(releasing.includes(`\`${context}\``), context); + assert.doesNotMatch(releasing, /Close and reopen the PR to run CI/); +}); From 29746ba7aec03b1951985b47020bcf9180cfed49 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 18 Oct 2026 18:51:37 -0400 Subject: [PATCH 104/117] chore: drop "Thanks" from the changesets attribution, keep the author changelog-github wrote each line as "Thanks [@user]!"; nearly every changeset here is a maintainer's, and community credit lives in the release notes' Thanks section. A template keeps the PR, commit and author links. template is experimental upstream, so changelog-github is pinned exactly. Closes #5161 Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- .changeset/config.json | 5 ++++- package-lock.json | 2 +- package.json | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.changeset/config.json b/.changeset/config.json index ce1f9a041b..83eaed43f8 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -2,7 +2,10 @@ "$schema": "https://unpkg.com/@changesets/config@4.0.1/schema.json", "changelog": [ "@changesets/changelog-github", - { "repo": "modelcontextprotocol/servers" } + { + "repo": "modelcontextprotocol/servers", + "template": "\n\n- {pull} {commit} {authors} - {summary}" + } ], "commit": false, "fixed": [], diff --git a/package-lock.json b/package-lock.json index 20e5533cdc..98274dc3ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,7 @@ "@modelcontextprotocol/server-sequential-thinking": "*" }, "devDependencies": { - "@changesets/changelog-github": "^1.0.1", + "@changesets/changelog-github": "1.0.1", "@changesets/cli": "^3.0.3", "@eslint/js": "^10.0.1", "@modelcontextprotocol/client": "^2.3.1", diff --git a/package.json b/package.json index 2a456a6b55..49a04ec3f0 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "@modelcontextprotocol/server-sequential-thinking": "*" }, "devDependencies": { - "@changesets/changelog-github": "^1.0.1", + "@changesets/changelog-github": "1.0.1", "@changesets/cli": "^3.0.3", "@eslint/js": "^10.0.1", "@modelcontextprotocol/client": "^2.3.1", From 41752e32baa5fafd5a378cbb4724468b0b8586e8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:01:24 +0000 Subject: [PATCH 105/117] chore: version packages --- .changeset/everything-modern-era.md | 5 --- .changeset/everything-sdk-v2.md | 5 --- .changeset/filesystem-modern-era.md | 5 --- .changeset/filesystem-sdk-v2.md | 5 --- .changeset/memory-modern-era.md | 5 --- .changeset/memory-sdk-v2.md | 5 --- .../sequentialthinking-header-padding.md | 5 --- .changeset/sequentialthinking-modern-era.md | 5 --- .changeset/sequentialthinking-sdk-v2.md | 5 --- package-lock.json | 38 ++----------------- src/everything/CHANGELOG.md | 10 +++++ src/everything/package.json | 2 +- src/filesystem/CHANGELOG.md | 10 +++++ src/filesystem/package.json | 2 +- src/memory/CHANGELOG.md | 10 +++++ src/memory/package.json | 2 +- src/sequentialthinking/CHANGELOG.md | 14 +++++++ src/sequentialthinking/package.json | 2 +- 18 files changed, 52 insertions(+), 83 deletions(-) delete mode 100644 .changeset/everything-modern-era.md delete mode 100644 .changeset/everything-sdk-v2.md delete mode 100644 .changeset/filesystem-modern-era.md delete mode 100644 .changeset/filesystem-sdk-v2.md delete mode 100644 .changeset/memory-modern-era.md delete mode 100644 .changeset/memory-sdk-v2.md delete mode 100644 .changeset/sequentialthinking-header-padding.md delete mode 100644 .changeset/sequentialthinking-modern-era.md delete mode 100644 .changeset/sequentialthinking-sdk-v2.md diff --git a/.changeset/everything-modern-era.md b/.changeset/everything-modern-era.md deleted file mode 100644 index 6315ed7008..0000000000 --- a/.changeset/everything-modern-era.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-everything": minor ---- - -Serve the 2026-07-28 protocol revision alongside the 2025 era (#4852), over stdio (`serveStdio`) and Streamable HTTP (one `/mcp` endpoint: `createMcpHandler` for 2026-07-28 requests, the existing sessionful transport for 2025-era clients). New for 2026-07-28 clients: `server/discover`, multi-round-trip requests (`input_required`) for the elicitation, sampling and roots tools, `subscriptions/listen` change notifications, cache hints (`ttlMs` / `cacheScope`), and the tasks extension (`io.modelcontextprotocol/tasks`, advertised under `capabilities.extensions`), on which `simulate-research-query`, `trigger-sampling-request-async` and `trigger-elicitation-request-async` return. New tools on both eras: `trigger-multi-step-elicitation` (`requestState` across rounds), `trigger-log-messages` (per-request log levels), `trigger-list-changed`, `trigger-resource-update`, and `get-header-params` (`x-mcp-header` and JSON Schema 2020-12 schemas). `toggle-simulated-logging` and `toggle-subscriber-updates` are offered to 2025-era clients only, labelled deprecated with the roots, sampling and logging tools. For 2025-era clients: `get-roots-list` asks the client for its roots on every call; a failed sampling, elicitation or roots request is reported as `Fulfilling input required by 'tools/call' failed: `; a read of a dynamic resource with an invalid id is answered `-32602` with the URI in `data`; and the toggle tools' messages and the subscription acknowledgements no longer name a session id. `gzip-file-as-resource` limits its `name` argument to 255 characters. The HTTP+SSE transport prints a deprecation warning on start. diff --git a/.changeset/everything-sdk-v2.md b/.changeset/everything-sdk-v2.md deleted file mode 100644 index 07fe4f38ae..0000000000 --- a/.changeset/everything-sdk-v2.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-everything": major ---- - -Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`, `@modelcontextprotocol/node`, and `@modelcontextprotocol/server-legacy` for the deprecated HTTP+SSE transport) from `@modelcontextprotocol/sdk` 1.x (#4856). The task tools `simulate-research-query`, `trigger-sampling-request-async` and `trigger-elicitation-request-async` are removed, and the server no longer advertises the `tasks` capability, because SDK v2 dropped the experimental tasks layer they were built on; they return on the tasks extension in #4852. Breaking for clients: the server now requires Node.js 20 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); tool schemas in `tools/list` declare JSON Schema 2020-12 instead of draft-07; and tools no longer advertise `execution.taskSupport`. The optional `state` argument of `args-prompt` now carries its description. diff --git a/.changeset/filesystem-modern-era.md b/.changeset/filesystem-modern-era.md deleted file mode 100644 index f4c18faff4..0000000000 --- a/.changeset/filesystem-modern-era.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-filesystem": minor ---- - -Serve the 2026-07-28 protocol revision alongside the 2025 era on stdio, through the SDK's `serveStdio` entry (#4852). A 2026-07-28 connection has no `initialize` and no server-to-client `roots/list`, and Roots is deprecated as of that revision, so the command-line directories are its whole allow-list; a server started with none answers each tool call on such a connection with an error saying so. 2025-era clients keep the Roots behavior they had. The README now recommends command-line directories over Roots (#4844). diff --git a/.changeset/filesystem-sdk-v2.md b/.changeset/filesystem-sdk-v2.md deleted file mode 100644 index 523a805d57..0000000000 --- a/.changeset/filesystem-sdk-v2.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-filesystem": major ---- - -Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`) from `@modelcontextprotocol/sdk` 1.x (#4856). Breaking for clients: the server now requires Node.js 20 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); and tools no longer advertise `execution.taskSupport`. diff --git a/.changeset/memory-modern-era.md b/.changeset/memory-modern-era.md deleted file mode 100644 index bf0591cf71..0000000000 --- a/.changeset/memory-modern-era.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-memory": minor ---- - -Serve the 2026-07-28 protocol revision alongside the 2025 era on stdio, through the SDK's `serveStdio` entry (#4852). A 2026-07-28 client gets `notifications/resources/updated` for `memory://knowledge-graph` on a `subscriptions/listen` stream whose `resourceSubscriptions` name it; 2025-era clients still subscribe with `resources/subscribe`. diff --git a/.changeset/memory-sdk-v2.md b/.changeset/memory-sdk-v2.md deleted file mode 100644 index e1ab5b57df..0000000000 --- a/.changeset/memory-sdk-v2.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-memory": major ---- - -Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`) from `@modelcontextprotocol/sdk` 1.x (#4856). Breaking for clients: the server now requires Node.js 20 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); tool schemas in `tools/list` declare JSON Schema 2020-12 instead of draft-07; and tools no longer advertise `execution.taskSupport`. diff --git a/.changeset/sequentialthinking-header-padding.md b/.changeset/sequentialthinking-header-padding.md deleted file mode 100644 index abfbfc207a..0000000000 --- a/.changeset/sequentialthinking-header-padding.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-sequential-thinking": patch ---- - -Pad the header row of the thought box logged to stderr to the border's width, so its right edge lines up with the rest of the box (#5108). diff --git a/.changeset/sequentialthinking-modern-era.md b/.changeset/sequentialthinking-modern-era.md deleted file mode 100644 index 2b0ed4a37f..0000000000 --- a/.changeset/sequentialthinking-modern-era.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-sequential-thinking": minor ---- - -Serve the 2026-07-28 protocol revision alongside the 2025 era on stdio, through the SDK's `serveStdio` entry (#4852). The thought history lasts as long as the client's connection, on either era. diff --git a/.changeset/sequentialthinking-sdk-v2.md b/.changeset/sequentialthinking-sdk-v2.md deleted file mode 100644 index 9e76a69e93..0000000000 --- a/.changeset/sequentialthinking-sdk-v2.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@modelcontextprotocol/server-sequential-thinking": major ---- - -Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`) from `@modelcontextprotocol/sdk` 1.x (#4856). Breaking for clients: the server now requires Node.js 20 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); tool schemas in `tools/list` declare JSON Schema 2020-12 instead of draft-07; and tools no longer advertise `execution.taskSupport`. diff --git a/package-lock.json b/package-lock.json index 98274dc3ed..fda4a11575 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1088,9 +1088,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1108,9 +1105,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1128,9 +1122,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1148,9 +1139,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1168,9 +1156,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1188,9 +1173,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3732,9 +3714,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3756,9 +3735,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3780,9 +3756,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3804,9 +3777,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5623,7 +5593,7 @@ }, "src/everything": { "name": "@modelcontextprotocol/server-everything", - "version": "1.0.0", + "version": "2.0.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@modelcontextprotocol/node": "^2.1.1", @@ -5653,7 +5623,7 @@ }, "src/filesystem": { "name": "@modelcontextprotocol/server-filesystem", - "version": "1.0.0", + "version": "2.0.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@modelcontextprotocol/server": "^2.3.1", @@ -5681,7 +5651,7 @@ }, "src/memory": { "name": "@modelcontextprotocol/server-memory", - "version": "1.0.0", + "version": "2.0.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@modelcontextprotocol/server": "^2.3.1", @@ -5704,7 +5674,7 @@ }, "src/sequentialthinking": { "name": "@modelcontextprotocol/server-sequential-thinking", - "version": "1.0.0", + "version": "2.0.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@modelcontextprotocol/server": "^2.3.1", diff --git a/src/everything/CHANGELOG.md b/src/everything/CHANGELOG.md index 8a9fddd37c..476d2d2dec 100644 --- a/src/everything/CHANGELOG.md +++ b/src/everything/CHANGELOG.md @@ -1,5 +1,15 @@ # @modelcontextprotocol/server-everything +## 2.0.0 + +### Major Changes + +- [#5118](https://github.com/modelcontextprotocol/servers/pull/5118) [`5b400a6`](https://github.com/modelcontextprotocol/servers/commit/5b400a6ec05bd883522665ebeae110bf1a2744ff) [@cliffhall](https://github.com/cliffhall) - Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`, `@modelcontextprotocol/node`, and `@modelcontextprotocol/server-legacy` for the deprecated HTTP+SSE transport) from `@modelcontextprotocol/sdk` 1.x ([#4856](https://github.com/modelcontextprotocol/servers/issues/4856)). The task tools `simulate-research-query`, `trigger-sampling-request-async` and `trigger-elicitation-request-async` are removed, and the server no longer advertises the `tasks` capability, because SDK v2 dropped the experimental tasks layer they were built on; they return on the tasks extension in [#4852](https://github.com/modelcontextprotocol/servers/issues/4852). Breaking for clients: the server now requires Node.js 20 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); tool schemas in `tools/list` declare JSON Schema 2020-12 instead of draft-07; and tools no longer advertise `execution.taskSupport`. The optional `state` argument of `args-prompt` now carries its description. + +### Minor Changes + +- [#5131](https://github.com/modelcontextprotocol/servers/pull/5131) [`419200b`](https://github.com/modelcontextprotocol/servers/commit/419200b8a4d1e66c294575930ac7be29d4b37330) [@cliffhall](https://github.com/cliffhall) - Serve the 2026-07-28 protocol revision alongside the 2025 era ([#4852](https://github.com/modelcontextprotocol/servers/issues/4852)), over stdio (`serveStdio`) and Streamable HTTP (one `/mcp` endpoint: `createMcpHandler` for 2026-07-28 requests, the existing sessionful transport for 2025-era clients). New for 2026-07-28 clients: `server/discover`, multi-round-trip requests (`input_required`) for the elicitation, sampling and roots tools, `subscriptions/listen` change notifications, cache hints (`ttlMs` / `cacheScope`), and the tasks extension (`io.modelcontextprotocol/tasks`, advertised under `capabilities.extensions`), on which `simulate-research-query`, `trigger-sampling-request-async` and `trigger-elicitation-request-async` return. New tools on both eras: `trigger-multi-step-elicitation` (`requestState` across rounds), `trigger-log-messages` (per-request log levels), `trigger-list-changed`, `trigger-resource-update`, and `get-header-params` (`x-mcp-header` and JSON Schema 2020-12 schemas). `toggle-simulated-logging` and `toggle-subscriber-updates` are offered to 2025-era clients only, labelled deprecated with the roots, sampling and logging tools. For 2025-era clients: `get-roots-list` asks the client for its roots on every call; a failed sampling, elicitation or roots request is reported as `Fulfilling input required by 'tools/call' failed: `; a read of a dynamic resource with an invalid id is answered `-32602` with the URI in `data`; and the toggle tools' messages and the subscription acknowledgements no longer name a session id. `gzip-file-as-resource` limits its `name` argument to 255 characters. The HTTP+SSE transport prints a deprecation warning on start. + ## 1.0.0 The first semantic version, resuming semver after the date-stamped releases (last `2026.8.31`). It describes everything since `2026.8.31`, and is the last release of the legacy-era server, on the 1.x MCP SDK. diff --git a/src/everything/package.json b/src/everything/package.json index ecdb17ce3f..c23f8b91b1 100644 --- a/src/everything/package.json +++ b/src/everything/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/server-everything", - "version": "1.0.0", + "version": "2.0.0", "description": "MCP server that exercises all the features of the MCP protocol", "license": "SEE LICENSE IN LICENSE", "mcpName": "io.github.modelcontextprotocol/server-everything", diff --git a/src/filesystem/CHANGELOG.md b/src/filesystem/CHANGELOG.md index 4e0ffccaf5..8bccfe7b33 100644 --- a/src/filesystem/CHANGELOG.md +++ b/src/filesystem/CHANGELOG.md @@ -1,5 +1,15 @@ # @modelcontextprotocol/server-filesystem +## 2.0.0 + +### Major Changes + +- [#5118](https://github.com/modelcontextprotocol/servers/pull/5118) [`5b400a6`](https://github.com/modelcontextprotocol/servers/commit/5b400a6ec05bd883522665ebeae110bf1a2744ff) [@cliffhall](https://github.com/cliffhall) - Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`) from `@modelcontextprotocol/sdk` 1.x ([#4856](https://github.com/modelcontextprotocol/servers/issues/4856)). Breaking for clients: the server now requires Node.js 20 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); and tools no longer advertise `execution.taskSupport`. + +### Minor Changes + +- [#5131](https://github.com/modelcontextprotocol/servers/pull/5131) [`419200b`](https://github.com/modelcontextprotocol/servers/commit/419200b8a4d1e66c294575930ac7be29d4b37330) [@cliffhall](https://github.com/cliffhall) - Serve the 2026-07-28 protocol revision alongside the 2025 era on stdio, through the SDK's `serveStdio` entry ([#4852](https://github.com/modelcontextprotocol/servers/issues/4852)). A 2026-07-28 connection has no `initialize` and no server-to-client `roots/list`, and Roots is deprecated as of that revision, so the command-line directories are its whole allow-list; a server started with none answers each tool call on such a connection with an error saying so. 2025-era clients keep the Roots behavior they had. The README now recommends command-line directories over Roots ([#4844](https://github.com/modelcontextprotocol/servers/issues/4844)). + ## 1.0.0 The first semantic version, resuming semver after the date-stamped releases (last `2026.8.31`). It describes everything since `2026.8.31`, and is the last release of the legacy-era server, on the 1.x MCP SDK. diff --git a/src/filesystem/package.json b/src/filesystem/package.json index 8adbd53b22..6ec45b24d2 100644 --- a/src/filesystem/package.json +++ b/src/filesystem/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/server-filesystem", - "version": "1.0.0", + "version": "2.0.0", "description": "MCP server for filesystem access", "license": "SEE LICENSE IN LICENSE", "mcpName": "io.github.modelcontextprotocol/server-filesystem", diff --git a/src/memory/CHANGELOG.md b/src/memory/CHANGELOG.md index a654f2c93f..f0aaf9dc63 100644 --- a/src/memory/CHANGELOG.md +++ b/src/memory/CHANGELOG.md @@ -1,5 +1,15 @@ # @modelcontextprotocol/server-memory +## 2.0.0 + +### Major Changes + +- [#5118](https://github.com/modelcontextprotocol/servers/pull/5118) [`5b400a6`](https://github.com/modelcontextprotocol/servers/commit/5b400a6ec05bd883522665ebeae110bf1a2744ff) [@cliffhall](https://github.com/cliffhall) - Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`) from `@modelcontextprotocol/sdk` 1.x ([#4856](https://github.com/modelcontextprotocol/servers/issues/4856)). Breaking for clients: the server now requires Node.js 20 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); tool schemas in `tools/list` declare JSON Schema 2020-12 instead of draft-07; and tools no longer advertise `execution.taskSupport`. + +### Minor Changes + +- [#5131](https://github.com/modelcontextprotocol/servers/pull/5131) [`419200b`](https://github.com/modelcontextprotocol/servers/commit/419200b8a4d1e66c294575930ac7be29d4b37330) [@cliffhall](https://github.com/cliffhall) - Serve the 2026-07-28 protocol revision alongside the 2025 era on stdio, through the SDK's `serveStdio` entry ([#4852](https://github.com/modelcontextprotocol/servers/issues/4852)). A 2026-07-28 client gets `notifications/resources/updated` for `memory://knowledge-graph` on a `subscriptions/listen` stream whose `resourceSubscriptions` name it; 2025-era clients still subscribe with `resources/subscribe`. + ## 1.0.0 The first semantic version, resuming semver after the date-stamped releases (last `2026.8.31`). It describes everything since `2026.8.31`, and is the last release of the legacy-era server, on the 1.x MCP SDK. diff --git a/src/memory/package.json b/src/memory/package.json index e48e521b9d..b908b3b95b 100644 --- a/src/memory/package.json +++ b/src/memory/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/server-memory", - "version": "1.0.0", + "version": "2.0.0", "description": "MCP server for enabling memory for Claude through a knowledge graph", "license": "SEE LICENSE IN LICENSE", "mcpName": "io.github.modelcontextprotocol/server-memory", diff --git a/src/sequentialthinking/CHANGELOG.md b/src/sequentialthinking/CHANGELOG.md index 09e81ad025..7086cf6b5b 100644 --- a/src/sequentialthinking/CHANGELOG.md +++ b/src/sequentialthinking/CHANGELOG.md @@ -1,5 +1,19 @@ # @modelcontextprotocol/server-sequential-thinking +## 2.0.0 + +### Major Changes + +- [#5118](https://github.com/modelcontextprotocol/servers/pull/5118) [`5b400a6`](https://github.com/modelcontextprotocol/servers/commit/5b400a6ec05bd883522665ebeae110bf1a2744ff) [@cliffhall](https://github.com/cliffhall) - Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`) from `@modelcontextprotocol/sdk` 1.x ([#4856](https://github.com/modelcontextprotocol/servers/issues/4856)). Breaking for clients: the server now requires Node.js 20 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); tool schemas in `tools/list` declare JSON Schema 2020-12 instead of draft-07; and tools no longer advertise `execution.taskSupport`. + +### Minor Changes + +- [#5131](https://github.com/modelcontextprotocol/servers/pull/5131) [`419200b`](https://github.com/modelcontextprotocol/servers/commit/419200b8a4d1e66c294575930ac7be29d4b37330) [@cliffhall](https://github.com/cliffhall) - Serve the 2026-07-28 protocol revision alongside the 2025 era on stdio, through the SDK's `serveStdio` entry ([#4852](https://github.com/modelcontextprotocol/servers/issues/4852)). The thought history lasts as long as the client's connection, on either era. + +### Patch Changes + +- [#5144](https://github.com/modelcontextprotocol/servers/pull/5144) [`d6fd666`](https://github.com/modelcontextprotocol/servers/commit/d6fd666467c4615828067f3bc2ab4e6d08c3e4a3) [@cliffhall](https://github.com/cliffhall) - Pad the header row of the thought box logged to stderr to the border's width, so its right edge lines up with the rest of the box ([#5108](https://github.com/modelcontextprotocol/servers/issues/5108)). + ## 1.0.0 The first semantic version, resuming semver after the date-stamped releases (last `2026.8.31`). It describes everything since `2026.8.31`, and is the last release of the legacy-era server, on the 1.x MCP SDK. diff --git a/src/sequentialthinking/package.json b/src/sequentialthinking/package.json index ae41494e13..fc2259457e 100644 --- a/src/sequentialthinking/package.json +++ b/src/sequentialthinking/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/server-sequential-thinking", - "version": "1.0.0", + "version": "2.0.0", "description": "MCP server for sequential thinking and problem solving", "license": "SEE LICENSE IN LICENSE", "mcpName": "io.github.modelcontextprotocol/server-sequential-thinking", From 38fdee2402c6f30592aefe700f276814c86b2eeb Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 18 Oct 2026 19:19:47 -0400 Subject: [PATCH 106/117] chore: stamp Python CalVer versions for 2026.10.18 mcp-server-fetch, mcp-server-git and mcp-server-time 2026.10.10 -> 2026.10.18, for the v2.0.0 release. Each shipped source changes since its last stamp (the 2026-07-28 era, the fetch annotations, #5107 in git). Part of #5159 Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- src/fetch/pyproject.toml | 2 +- src/fetch/uv.lock | 2 +- src/git/pyproject.toml | 2 +- src/git/uv.lock | 2 +- src/time/pyproject.toml | 2 +- src/time/uv.lock | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/fetch/pyproject.toml b/src/fetch/pyproject.toml index c629084d5a..2154af678b 100644 --- a/src/fetch/pyproject.toml +++ b/src/fetch/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcp-server-fetch" -version = "2026.10.10" +version = "2026.10.18" description = "A Model Context Protocol server providing tools to fetch and convert web content for usage by LLMs" readme = "README.md" requires-python = ">=3.10" diff --git a/src/fetch/uv.lock b/src/fetch/uv.lock index 771fcb39f4..10645c76bb 100644 --- a/src/fetch/uv.lock +++ b/src/fetch/uv.lock @@ -745,7 +745,7 @@ wheels = [ [[package]] name = "mcp-server-fetch" -version = "2026.10.10" +version = "2026.10.18" source = { editable = "." } dependencies = [ { name = "httpx", extra = ["socks"] }, diff --git a/src/git/pyproject.toml b/src/git/pyproject.toml index 7036291e80..915e47a906 100644 --- a/src/git/pyproject.toml +++ b/src/git/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcp-server-git" -version = "2026.10.10" +version = "2026.10.18" description = "A Model Context Protocol server providing tools to read, search, and manipulate Git repositories programmatically via LLMs" readme = "README.md" requires-python = ">=3.10" diff --git a/src/git/uv.lock b/src/git/uv.lock index 0ed114edd3..0ce4812c91 100644 --- a/src/git/uv.lock +++ b/src/git/uv.lock @@ -509,7 +509,7 @@ wheels = [ [[package]] name = "mcp-server-git" -version = "2026.10.10" +version = "2026.10.18" source = { editable = "." } dependencies = [ { name = "click" }, diff --git a/src/time/pyproject.toml b/src/time/pyproject.toml index d37e27724e..c43bc5d815 100644 --- a/src/time/pyproject.toml +++ b/src/time/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcp-server-time" -version = "2026.10.10" +version = "2026.10.18" description = "A Model Context Protocol server providing tools for time queries and timezone conversions for LLMs" readme = "README.md" requires-python = ">=3.10" diff --git a/src/time/uv.lock b/src/time/uv.lock index 16584d44cd..da9478e342 100644 --- a/src/time/uv.lock +++ b/src/time/uv.lock @@ -497,7 +497,7 @@ wheels = [ [[package]] name = "mcp-server-time" -version = "2026.10.10" +version = "2026.10.18" source = { editable = "." } dependencies = [ { name = "jsonschema" }, From 1ad67f50a3eccfe7b975e136740bf143a6c46c33 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 18 Oct 2026 23:02:13 -0400 Subject: [PATCH 107/117] fix: raise the TypeScript servers' Node floor to 24 The four packages declared engines >=20 and said "Node.js 20 or later" in their READMEs and 2.0.0 CHANGELOG entries, while CI never ran Node 20 and the docs named 22 as the floor. Node 20 is end of life and 22 reaches it in April 2027, so the floor moves to 24: engines, @types/node, the README and CHANGELOG lines, the CI matrix (24 and 26) and every workflow's node-version, and the setup docs. 2.0.0 is not yet on npm, so its CHANGELOG entries are corrected in place rather than superseded by a new changeset. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- .claude/skills/local-dev/SKILL.md | 2 +- .github/workflows/dco.yml | 2 +- .github/workflows/dependabot-alerts.yml | 2 +- .github/workflows/dependency-refresh.yml | 2 +- .github/workflows/everything-mcp-diff.yml | 2 +- .github/workflows/main-source.yml | 2 +- .github/workflows/prepare-python-release.yml | 2 +- .github/workflows/python.yml | 6 ++--- .github/workflows/release.yml | 10 +++---- .github/workflows/sdk-watch.yml | 2 +- .github/workflows/typescript.yml | 18 ++++++------- .github/workflows/version-packages.yml | 2 +- AGENTS.md | 6 ++--- docs/quality-gate.md | 10 +++---- package-lock.json | 28 ++++++++++---------- src/everything/CHANGELOG.md | 2 +- src/everything/README.md | 2 +- src/everything/package.json | 2 +- src/filesystem/CHANGELOG.md | 2 +- src/filesystem/README.md | 2 +- src/filesystem/package.json | 4 +-- src/memory/CHANGELOG.md | 2 +- src/memory/README.md | 2 +- src/memory/package.json | 4 +-- src/sequentialthinking/CHANGELOG.md | 2 +- src/sequentialthinking/README.md | 2 +- src/sequentialthinking/package.json | 4 +-- 27 files changed, 63 insertions(+), 63 deletions(-) diff --git a/.claude/skills/local-dev/SKILL.md b/.claude/skills/local-dev/SKILL.md index 0f93424feb..6b7c4a2f80 100644 --- a/.claude/skills/local-dev/SKILL.md +++ b/.claude/skills/local-dev/SKILL.md @@ -14,7 +14,7 @@ Driving a running server with a client is `/client-smoke`; tests are ## Install and build -Node 24 (the development version; 22 is the oldest supported) for the +Node 24 (the development version, and the oldest supported) for the TypeScript servers, and `uv` for the Python ones. ```sh diff --git a/.github/workflows/dco.yml b/.github/workflows/dco.yml index 295bce8346..578e595777 100644 --- a/.github/workflows/dco.yml +++ b/.github/workflows/dco.yml @@ -42,7 +42,7 @@ jobs: - uses: actions/setup-node@v7 with: - node-version: 22 + node-version: 24 # Node built-ins and git only, so no `npm ci`. - name: Check every commit is signed off diff --git a/.github/workflows/dependabot-alerts.yml b/.github/workflows/dependabot-alerts.yml index 19eacea041..4f1371765d 100644 --- a/.github/workflows/dependabot-alerts.yml +++ b/.github/workflows/dependabot-alerts.yml @@ -67,7 +67,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v7 with: - node-version: 22 + node-version: 24 cache: npm # The script reads every lockfile as text; its one dependency is diff --git a/.github/workflows/dependency-refresh.yml b/.github/workflows/dependency-refresh.yml index 1582074ad2..84f1f9cdad 100644 --- a/.github/workflows/dependency-refresh.yml +++ b/.github/workflows/dependency-refresh.yml @@ -50,7 +50,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v7 with: - node-version: 22 + node-version: 24 cache: npm # Each Python server's `.python-version` is fetched by uv as needed. diff --git a/.github/workflows/everything-mcp-diff.yml b/.github/workflows/everything-mcp-diff.yml index 16fe49c17d..01acb746b4 100644 --- a/.github/workflows/everything-mcp-diff.yml +++ b/.github/workflows/everything-mcp-diff.yml @@ -99,7 +99,7 @@ jobs: - uses: actions/setup-node@v7 with: - node-version: 22 + node-version: 24 cache: npm # A root `npm ci` runs every workspace's `prepare`, which builds it, so diff --git a/.github/workflows/main-source.yml b/.github/workflows/main-source.yml index 7a2ee9e5e6..d1da81dd27 100644 --- a/.github/workflows/main-source.yml +++ b/.github/workflows/main-source.yml @@ -50,7 +50,7 @@ jobs: - uses: actions/setup-node@v7 with: - node-version: 22 + node-version: 24 # Node built-ins and git only, so no `npm ci`. - name: Check the head is already on v2/main diff --git a/.github/workflows/prepare-python-release.yml b/.github/workflows/prepare-python-release.yml index bfb6c216f9..ed5318bb0a 100644 --- a/.github/workflows/prepare-python-release.yml +++ b/.github/workflows/prepare-python-release.yml @@ -59,7 +59,7 @@ jobs: - uses: actions/setup-node@v7 with: - node-version: 22 + node-version: 24 # The script runs `uv lock` in each package it stamps. - name: Install uv diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 5fb8894779..271c1167b1 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -52,7 +52,7 @@ jobs: - uses: actions/setup-node@v7 with: - node-version: 22 + node-version: 24 - name: Run node --test run: node --test scripts/validate-py.test.mjs scripts/coverage-py.test.mjs scripts/lib/py-coverage.test.mjs @@ -78,7 +78,7 @@ jobs: - uses: actions/setup-node@v7 with: - node-version: 22 + node-version: 24 # The same per-server chain `npm run validate:py` runs locally: locked # sync, ruff check, ruff format --check, pyright, pytest, uv build. @@ -111,7 +111,7 @@ jobs: - uses: actions/setup-node@v7 with: - node-version: 22 + node-version: 24 - name: Coverage run: npm run coverage:py -- ${{ matrix.package }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f47a698260..72965fc962 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -141,7 +141,7 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 22 + node-version: 24 cache: npm # The registry-diff guard: skip (not fail) a version that is already @@ -213,13 +213,13 @@ jobs: steps: # No `cache: npm`: there is no checkout, so no lockfile to key it on, and # nothing here installs dependencies to cache. - # OIDC trusted publishing requires npm >= 11.5.1. Node 22 bundles npm - # 10.x, so this job runs Node 24, whose bundled npm meets the floor, and + # OIDC trusted publishing requires npm >= 11.5.1. Not every Node 24 bundles + # it, so this job pins a Node 24 release whose bundled npm meets the floor, and # installs nothing: no package is fetched from the registry and run # next to `id-token: write` (#5104). Pinned EXACTLY, not to a major: the # runtime is the one thing this job trusts besides the pinned actions, # and a range would let whatever 24.x is current at release time in. - # The build jobs keep Node 22; this job only reads a manifest and runs + # The build jobs take the current 24.x; this job only reads a manifest and runs # `npm publish` on the verified tarball. - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -390,7 +390,7 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 22 + node-version: 24 cache: npm # Defense in depth: the same per-server chain CI and the gate run diff --git a/.github/workflows/sdk-watch.yml b/.github/workflows/sdk-watch.yml index e7813bc161..f0497ea490 100644 --- a/.github/workflows/sdk-watch.yml +++ b/.github/workflows/sdk-watch.yml @@ -66,7 +66,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: 22 + node-version: 24 cache: npm # The script reads the manifests and lockfiles as text; its one diff --git a/.github/workflows/typescript.yml b/.github/workflows/typescript.yml index 89f6a4edf2..40be37b425 100644 --- a/.github/workflows/typescript.yml +++ b/.github/workflows/typescript.yml @@ -46,7 +46,7 @@ jobs: - uses: actions/setup-node@v7 with: - node-version: 22 + node-version: 24 cache: npm - name: Install dependencies @@ -80,10 +80,10 @@ jobs: # root-guards job above; together they are the root `npm run validate`. # # Each package is also validated on every Node line it supports (#5068): - # 22, the oldest supported, through 26. The `node` axis multiplies legs, - # not steps, so the versions run side by side and the job takes no longer - # than its slowest leg. The other jobs here stay on one Node, 22: the oldest - # supported, so an API only newer Node has cannot slip in unnoticed. + # 24, the oldest supported (#5168), through 26. The `node` axis multiplies + # legs, not steps, so the versions run side by side and the job takes no + # longer than its slowest leg. The other jobs here stay on one Node, 24: the + # oldest supported, so an API only newer Node has cannot slip in unnoticed. validate: timeout-minutes: 15 needs: [detect-packages] @@ -91,7 +91,7 @@ jobs: fail-fast: false matrix: package: ${{ fromJson(needs.detect-packages.outputs.packages) }} - node: [22, 24, 26] + node: [24, 26] name: Validate ${{ matrix.package }} (Node ${{ matrix.node }}) runs-on: ubuntu-latest steps: @@ -134,7 +134,7 @@ jobs: - uses: actions/setup-node@v7 with: - node-version: 22 + node-version: 24 cache: npm # The same install as the `validate` leg: this package plus the root. Its @@ -166,7 +166,7 @@ jobs: - uses: actions/setup-node@v7 with: - node-version: 22 + node-version: 24 cache: npm # The same install as the `validate` leg; its `prepare` builds `dist/`. @@ -193,7 +193,7 @@ jobs: - uses: actions/setup-node@v7 with: - node-version: 22 + node-version: 24 cache: npm - name: Install uv diff --git a/.github/workflows/version-packages.yml b/.github/workflows/version-packages.yml index 004e7a9f0e..2a9a3f9785 100644 --- a/.github/workflows/version-packages.yml +++ b/.github/workflows/version-packages.yml @@ -48,7 +48,7 @@ jobs: - uses: actions/setup-node@v7 with: - node-version: 22 + node-version: 24 cache: npm # Versioning needs the changesets CLI, not a build, so the workspaces' diff --git a/AGENTS.md b/AGENTS.md index 0271029766..dae8526ba5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -96,9 +96,9 @@ opening paragraph instead, as this one does. A format with no comment syntax ## Development setup -Node **24** for the TypeScript servers, the development version; Node **22** -is the oldest supported, and CI validates each TypeScript server on 22, 24 and -26 in parallel legs. Python **>= 3.10** for the Python servers, at the version each server's `.python-version` pins. Python +Node **24** for the TypeScript servers: the development version and the oldest +supported (`engines` is `>=24`), and CI validates each TypeScript server on 24 +and 26 in parallel legs. Python **>= 3.10** for the Python servers, at the version each server's `.python-version` pins. Python dependencies are managed with **`uv`, never `pip`**. ```sh diff --git a/docs/quality-gate.md b/docs/quality-gate.md index d220d68621..1464eecc1b 100644 --- a/docs/quality-gate.md +++ b/docs/quality-gate.md @@ -19,11 +19,11 @@ out; everything else points here. into parallel jobs on a fresh install; the gate runs them in sequence on your checkout. So a green gate means every check CI applies has already passed on your machine. It is the strongest predictor of a green CI there is here, though -not a proof: CI runs on Linux, on a fresh `npm ci`, with Node 22 (the oldest +not a proof: CI runs on Linux, on a fresh `npm ci`, with Node 24 (the oldest supported) and each Python server's pinned interpreter, and `typescript.yml`'s -`validate` job runs each TypeScript server on Node 22, 24 and 26, one parallel -leg per version. The gate runs on whichever Node you have; use Node 24, the -development version. +`validate` job runs each TypeScript server on Node 24 and 26, one parallel leg +per version. The gate runs on whichever Node you have; use Node 24, the +development version and the floor. **There are two exceptions.** The first is the Windows legs (#1149): `test-windows` in both workflows runs each server's test suite (`npm test`, `pytest`) on a @@ -51,7 +51,7 @@ in this order, and the first failure stops the run. | 1 | `verify:install-fresh` | Every package in `node_modules` is at the version `package-lock.json` records, and every `package.json` declares what the lockfile says it does | None needed: CI installs with `npm ci` | | 2 | `verify:dco` | Every commit in `origin/v2/main..HEAD` carries a `Signed-off-by:` trailer matching its author or committer (merge and bot commits exempt) | `dco.yml` → **DCO signoff**, over the pull request's `base.sha..head.sha` | | 3 | `validate` → `validate:guards` | The root guards, listed [below](#the-root-guards) | `typescript.yml` → **Root guards** | -| 4 | `validate` → each workspace's `validate` | Per TypeScript server: `format:check`, `lint` (`--max-warnings 0`), `typecheck`, `build`, `test` | `typescript.yml` → **Validate \ (Node \)** (one leg per server per Node: 22, 24 and 26, in parallel) | +| 4 | `validate` → each workspace's `validate` | Per TypeScript server: `format:check`, `lint` (`--max-warnings 0`), `typecheck`, `build`, `test` | `typescript.yml` → **Validate \ (Node \)** (one leg per server per Node: 24 and 26, in parallel) | | 5 | `coverage` | Per TypeScript server: `vitest run --coverage`, failing when any file is below 90% on lines, statements, functions or branches | `typescript.yml` → **Coverage \** (one leg each) | | 6 | `validate:py` | Per Python server: `uv sync --locked`, `ruff check`, `ruff format --check`, `pyright`, `pytest`, `uv build` | `python.yml` → **Test \** (one leg each) | | 7 | `coverage:py` | Per Python server: `uv sync --locked`, then `uv run --frozen pytest --cov --cov-report=term-missing --cov-report=json`; then **every file** in `coverage.json` must reach **90% on lines and 90% on branches** | `python.yml` → **Coverage \** (one leg each) | diff --git a/package-lock.json b/package-lock.json index fda4a11575..30a4d0d6ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1364,13 +1364,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.19.21", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.21.tgz", - "integrity": "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==", + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/@types/qs": { @@ -5209,9 +5209,9 @@ } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "dev": true, "license": "MIT" }, @@ -5618,7 +5618,7 @@ "vitest": "^4.1.11" }, "engines": { - "node": ">=20" + "node": ">=24" } }, "src/filesystem": { @@ -5639,14 +5639,14 @@ "@modelcontextprotocol/client": "^2.3.1", "@types/diff": "^5.0.9", "@types/minimatch": "^5.1.2", - "@types/node": "^22", + "@types/node": "^24", "@vitest/coverage-v8": "^4.1.11", "shx": "^0.4.0", "typescript": "^5.8.2", "vitest": "^4.1.11" }, "engines": { - "node": ">=20" + "node": ">=24" } }, "src/memory": { @@ -5662,14 +5662,14 @@ }, "devDependencies": { "@modelcontextprotocol/client": "^2.3.1", - "@types/node": "^22", + "@types/node": "^24", "@vitest/coverage-v8": "^4.1.11", "shx": "^0.4.0", "typescript": "^5.8.2", "vitest": "^4.1.11" }, "engines": { - "node": ">=20" + "node": ">=24" } }, "src/sequentialthinking": { @@ -5687,7 +5687,7 @@ }, "devDependencies": { "@modelcontextprotocol/client": "^2.3.1", - "@types/node": "^22", + "@types/node": "^24", "@types/yargs": "^17.0.32", "@vitest/coverage-v8": "^4.1.11", "shx": "^0.4.0", @@ -5695,7 +5695,7 @@ "vitest": "^4.1.11" }, "engines": { - "node": ">=20" + "node": ">=24" } } } diff --git a/src/everything/CHANGELOG.md b/src/everything/CHANGELOG.md index 476d2d2dec..8f60e09327 100644 --- a/src/everything/CHANGELOG.md +++ b/src/everything/CHANGELOG.md @@ -4,7 +4,7 @@ ### Major Changes -- [#5118](https://github.com/modelcontextprotocol/servers/pull/5118) [`5b400a6`](https://github.com/modelcontextprotocol/servers/commit/5b400a6ec05bd883522665ebeae110bf1a2744ff) [@cliffhall](https://github.com/cliffhall) - Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`, `@modelcontextprotocol/node`, and `@modelcontextprotocol/server-legacy` for the deprecated HTTP+SSE transport) from `@modelcontextprotocol/sdk` 1.x ([#4856](https://github.com/modelcontextprotocol/servers/issues/4856)). The task tools `simulate-research-query`, `trigger-sampling-request-async` and `trigger-elicitation-request-async` are removed, and the server no longer advertises the `tasks` capability, because SDK v2 dropped the experimental tasks layer they were built on; they return on the tasks extension in [#4852](https://github.com/modelcontextprotocol/servers/issues/4852). Breaking for clients: the server now requires Node.js 20 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); tool schemas in `tools/list` declare JSON Schema 2020-12 instead of draft-07; and tools no longer advertise `execution.taskSupport`. The optional `state` argument of `args-prompt` now carries its description. +- [#5118](https://github.com/modelcontextprotocol/servers/pull/5118) [`5b400a6`](https://github.com/modelcontextprotocol/servers/commit/5b400a6ec05bd883522665ebeae110bf1a2744ff) [@cliffhall](https://github.com/cliffhall) - Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`, `@modelcontextprotocol/node`, and `@modelcontextprotocol/server-legacy` for the deprecated HTTP+SSE transport) from `@modelcontextprotocol/sdk` 1.x ([#4856](https://github.com/modelcontextprotocol/servers/issues/4856)). The task tools `simulate-research-query`, `trigger-sampling-request-async` and `trigger-elicitation-request-async` are removed, and the server no longer advertises the `tasks` capability, because SDK v2 dropped the experimental tasks layer they were built on; they return on the tasks extension in [#4852](https://github.com/modelcontextprotocol/servers/issues/4852). Breaking for clients: the server now requires Node.js 24 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); tool schemas in `tools/list` declare JSON Schema 2020-12 instead of draft-07; and tools no longer advertise `execution.taskSupport`. The optional `state` argument of `args-prompt` now carries its description. ### Minor Changes diff --git a/src/everything/README.md b/src/everything/README.md index e226a1fda6..f8a85ee876 100644 --- a/src/everything/README.md +++ b/src/everything/README.md @@ -24,7 +24,7 @@ Roots, sampling and logging are deprecated as of 2026-07-28 and stay available, ## Requirements -Node.js 20 or later, when the server runs through `npx` or from source. +Node.js 24 or later, when the server runs through `npx` or from source. ## Usage with Claude Desktop (uses [stdio Transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#stdio)) diff --git a/src/everything/package.json b/src/everything/package.json index c23f8b91b1..e663d65758 100644 --- a/src/everything/package.json +++ b/src/everything/package.json @@ -13,7 +13,7 @@ }, "type": "module", "engines": { - "node": ">=20" + "node": ">=24" }, "bin": { "mcp-server-everything": "dist/index.js" diff --git a/src/filesystem/CHANGELOG.md b/src/filesystem/CHANGELOG.md index 8bccfe7b33..081abe0d3c 100644 --- a/src/filesystem/CHANGELOG.md +++ b/src/filesystem/CHANGELOG.md @@ -4,7 +4,7 @@ ### Major Changes -- [#5118](https://github.com/modelcontextprotocol/servers/pull/5118) [`5b400a6`](https://github.com/modelcontextprotocol/servers/commit/5b400a6ec05bd883522665ebeae110bf1a2744ff) [@cliffhall](https://github.com/cliffhall) - Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`) from `@modelcontextprotocol/sdk` 1.x ([#4856](https://github.com/modelcontextprotocol/servers/issues/4856)). Breaking for clients: the server now requires Node.js 20 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); and tools no longer advertise `execution.taskSupport`. +- [#5118](https://github.com/modelcontextprotocol/servers/pull/5118) [`5b400a6`](https://github.com/modelcontextprotocol/servers/commit/5b400a6ec05bd883522665ebeae110bf1a2744ff) [@cliffhall](https://github.com/cliffhall) - Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`) from `@modelcontextprotocol/sdk` 1.x ([#4856](https://github.com/modelcontextprotocol/servers/issues/4856)). Breaking for clients: the server now requires Node.js 24 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); and tools no longer advertise `execution.taskSupport`. ### Minor Changes diff --git a/src/filesystem/README.md b/src/filesystem/README.md index 35cbb2d567..aa0e27c6e4 100644 --- a/src/filesystem/README.md +++ b/src/filesystem/README.md @@ -224,7 +224,7 @@ The mapping for filesystem tools is: ## Requirements -Node.js 20 or later, when the server runs through `npx` or from source. +Node.js 24 or later, when the server runs through `npx` or from source. ## Usage with Claude Desktop Add this to your `claude_desktop_config.json`: diff --git a/src/filesystem/package.json b/src/filesystem/package.json index 6ec45b24d2..cd7fd7c91e 100644 --- a/src/filesystem/package.json +++ b/src/filesystem/package.json @@ -13,7 +13,7 @@ }, "type": "module", "engines": { - "node": ">=20" + "node": ">=24" }, "bin": { "mcp-server-filesystem": "dist/index.js" @@ -44,7 +44,7 @@ "@modelcontextprotocol/client": "^2.3.1", "@types/diff": "^5.0.9", "@types/minimatch": "^5.1.2", - "@types/node": "^22", + "@types/node": "^24", "@vitest/coverage-v8": "^4.1.11", "shx": "^0.4.0", "typescript": "^5.8.2", diff --git a/src/memory/CHANGELOG.md b/src/memory/CHANGELOG.md index f0aaf9dc63..599f0da6d2 100644 --- a/src/memory/CHANGELOG.md +++ b/src/memory/CHANGELOG.md @@ -4,7 +4,7 @@ ### Major Changes -- [#5118](https://github.com/modelcontextprotocol/servers/pull/5118) [`5b400a6`](https://github.com/modelcontextprotocol/servers/commit/5b400a6ec05bd883522665ebeae110bf1a2744ff) [@cliffhall](https://github.com/cliffhall) - Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`) from `@modelcontextprotocol/sdk` 1.x ([#4856](https://github.com/modelcontextprotocol/servers/issues/4856)). Breaking for clients: the server now requires Node.js 20 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); tool schemas in `tools/list` declare JSON Schema 2020-12 instead of draft-07; and tools no longer advertise `execution.taskSupport`. +- [#5118](https://github.com/modelcontextprotocol/servers/pull/5118) [`5b400a6`](https://github.com/modelcontextprotocol/servers/commit/5b400a6ec05bd883522665ebeae110bf1a2744ff) [@cliffhall](https://github.com/cliffhall) - Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`) from `@modelcontextprotocol/sdk` 1.x ([#4856](https://github.com/modelcontextprotocol/servers/issues/4856)). Breaking for clients: the server now requires Node.js 24 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); tool schemas in `tools/list` declare JSON Schema 2020-12 instead of draft-07; and tools no longer advertise `execution.taskSupport`. ### Minor Changes diff --git a/src/memory/README.md b/src/memory/README.md index 60db63fdbd..50e1a5057f 100644 --- a/src/memory/README.md +++ b/src/memory/README.md @@ -144,7 +144,7 @@ The server serves both protocol eras on stdio from the same process. The client' ## Requirements -Node.js 20 or later, when the server runs through `npx` or from source. +Node.js 24 or later, when the server runs through `npx` or from source. # Usage with Claude Desktop diff --git a/src/memory/package.json b/src/memory/package.json index b908b3b95b..b45118be72 100644 --- a/src/memory/package.json +++ b/src/memory/package.json @@ -13,7 +13,7 @@ }, "type": "module", "engines": { - "node": ">=20" + "node": ">=24" }, "bin": { "mcp-server-memory": "dist/index.js" @@ -39,7 +39,7 @@ }, "devDependencies": { "@modelcontextprotocol/client": "^2.3.1", - "@types/node": "^22", + "@types/node": "^24", "@vitest/coverage-v8": "^4.1.11", "shx": "^0.4.0", "typescript": "^5.8.2", diff --git a/src/sequentialthinking/CHANGELOG.md b/src/sequentialthinking/CHANGELOG.md index 7086cf6b5b..7a6ecaa755 100644 --- a/src/sequentialthinking/CHANGELOG.md +++ b/src/sequentialthinking/CHANGELOG.md @@ -4,7 +4,7 @@ ### Major Changes -- [#5118](https://github.com/modelcontextprotocol/servers/pull/5118) [`5b400a6`](https://github.com/modelcontextprotocol/servers/commit/5b400a6ec05bd883522665ebeae110bf1a2744ff) [@cliffhall](https://github.com/cliffhall) - Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`) from `@modelcontextprotocol/sdk` 1.x ([#4856](https://github.com/modelcontextprotocol/servers/issues/4856)). Breaking for clients: the server now requires Node.js 20 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); tool schemas in `tools/list` declare JSON Schema 2020-12 instead of draft-07; and tools no longer advertise `execution.taskSupport`. +- [#5118](https://github.com/modelcontextprotocol/servers/pull/5118) [`5b400a6`](https://github.com/modelcontextprotocol/servers/commit/5b400a6ec05bd883522665ebeae110bf1a2744ff) [@cliffhall](https://github.com/cliffhall) - Move to the TypeScript SDK v2 packages (`@modelcontextprotocol/server`) from `@modelcontextprotocol/sdk` 1.x ([#4856](https://github.com/modelcontextprotocol/servers/issues/4856)). Breaking for clients: the server now requires Node.js 24 or later; over stdio, a single incoming JSON-RPC message larger than 10 MiB now closes the connection (the SDK v2 default read-buffer cap; SDK 1.x buffered without a limit); an unknown tool name is rejected with JSON-RPC error `-32602` instead of returning an `isError` result; error messages no longer carry the `MCP error : ` prefix, and input-validation messages name the field first (`field: message`); tool schemas in `tools/list` declare JSON Schema 2020-12 instead of draft-07; and tools no longer advertise `execution.taskSupport`. ### Minor Changes diff --git a/src/sequentialthinking/README.md b/src/sequentialthinking/README.md index 6d40f1e66d..390d09646d 100644 --- a/src/sequentialthinking/README.md +++ b/src/sequentialthinking/README.md @@ -72,7 +72,7 @@ After installing the server in your MCP host: ## Requirements -Node.js 20 or later, when the server runs through `npx` or from source. +Node.js 24 or later, when the server runs through `npx` or from source. ## Configuration diff --git a/src/sequentialthinking/package.json b/src/sequentialthinking/package.json index fc2259457e..dd746892b9 100644 --- a/src/sequentialthinking/package.json +++ b/src/sequentialthinking/package.json @@ -13,7 +13,7 @@ }, "type": "module", "engines": { - "node": ">=20" + "node": ">=24" }, "bin": { "mcp-server-sequential-thinking": "dist/index.js" @@ -41,7 +41,7 @@ }, "devDependencies": { "@modelcontextprotocol/client": "^2.3.1", - "@types/node": "^22", + "@types/node": "^24", "@types/yargs": "^17.0.32", "@vitest/coverage-v8": "^4.1.11", "shx": "^0.4.0", From 306f5a40e848abd09475fe18184662413b8003a7 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 19 Oct 2026 00:26:39 -0400 Subject: [PATCH 108/117] ci: run the main-source guard on Node 24 again (#5168) Re-applies #5169's change to main-source.yml on top of the merge with main, so it reaches main as an ordinary change to a shared file. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- .github/workflows/main-source.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main-source.yml b/.github/workflows/main-source.yml index 7a2ee9e5e6..d1da81dd27 100644 --- a/.github/workflows/main-source.yml +++ b/.github/workflows/main-source.yml @@ -50,7 +50,7 @@ jobs: - uses: actions/setup-node@v7 with: - node-version: 22 + node-version: 24 # Node built-ins and git only, so no `npm ci`. - name: Check the head is already on v2/main From 400777f651985b75df32c6121cffbee3805887ad Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 19 Oct 2026 01:30:42 -0400 Subject: [PATCH 109/117] feat(time): build on MCPServer; an unknown tool is JSON-RPC -32602 The time server moves from the SDK's low-level Server, wrapped to keep SDK v1's behavior, to the high-level MCPServer (#5174): - Each tool is a typed function. MCPServer derives the input schema, validates arguments, and returns the result model as structuredContent with an outputSchema, plus the same object as indented JSON text. - Anticipated failures (invalid or empty timezone, bad HH:MM, a time skipped by a clock change) raise ToolError: an isError result prefixed "Error executing tool : ", in place of v1's "Error processing mcp-server-time query: " prefix and its jsonschema messages. - An unknown tool is a JSON-RPC -32602 error ("Unknown tool: ") in both eras, as both spec revisions require. MCPServer still answers it with isError (python-sdk#3659), so a middleware rejects the name first. - subscriptions=False: the tools never change, so neither era claims listChanged. MCPServer advertises empty prompts and resources. - The jsonschema dependency and the v1 wrappers are gone. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- src/time/README.md | 21 +- src/time/pyproject.toml | 1 - src/time/src/mcp_server_time/server.py | 280 ++++++++++------------ src/time/tests/test_eras.py | 58 +++-- src/time/tests/test_protocol.py | 311 +++++++++++++++---------- src/time/tests/test_server.py | 8 +- src/time/uv.lock | 2 - 7 files changed, 365 insertions(+), 316 deletions(-) diff --git a/src/time/README.md b/src/time/README.md index 7103cafc82..53b37b3022 100644 --- a/src/time/README.md +++ b/src/time/README.md @@ -26,6 +26,14 @@ A timezone name, here and in `--local-timezone`, must be an exact IANA key, case The server speaks both eras of the Model Context Protocol over stdio, with nothing to configure: a **2026-07-28** client (discovered with `server/discover`, no session) and a **2025-11-25** client (the `initialize` handshake). The client's first request decides which. Tools behave the same in both. Methods the 2026-07-28 specification removes, such as `ping`, are answered only on a 2025-11-25 connection. +### Results and errors + +The server is built on the SDK's high-level `MCPServer`. Each tool declares an `outputSchema` and returns its result twice: as `structuredContent`, and as the same object in indented JSON in a text block, for a client that does not read structured output. + +- **A tool failure is an `isError` result** whose text the model can read and act on, prefixed `Error executing tool : `. That covers an unknown or non-exact timezone (`Invalid timezone: ...`), an empty timezone (`Missing required argument: `), a time not in `HH:MM` 24-hour form, a wall-clock time skipped by a clock change, and an argument missing or of the wrong type (the SDK's validation message). +- **An unknown tool name is a JSON-RPC `-32602` error** (`Unknown tool: `), not an `isError` result, as both protocol versions specify. +- The server registers no prompts or resources. Their list methods answer with empty lists. + ## Installation ### Using uv (recommended) @@ -221,11 +229,12 @@ Example: } } ``` -Response: +Response (`structuredContent`, and the same JSON as text): ```json { "timezone": "Europe/Warsaw", "datetime": "2024-01-01T13:00:00+01:00", + "day_of_week": "Monday", "is_dst": false } ``` @@ -241,20 +250,22 @@ Response: } } ``` -Response: +Response (`structuredContent`, and the same JSON as text): ```json { "source": { "timezone": "America/New_York", - "datetime": "2024-01-01T12:30:00-05:00", + "datetime": "2024-01-01T16:30:00-05:00", + "day_of_week": "Monday", "is_dst": false }, "target": { "timezone": "Asia/Tokyo", - "datetime": "2024-01-01T12:30:00+09:00", + "datetime": "2024-01-02T06:30:00+09:00", + "day_of_week": "Tuesday", "is_dst": false }, - "time_difference": "+13.0h", + "time_difference": "+14.0h" } ``` diff --git a/src/time/pyproject.toml b/src/time/pyproject.toml index c43bc5d815..a15f193ceb 100644 --- a/src/time/pyproject.toml +++ b/src/time/pyproject.toml @@ -17,7 +17,6 @@ classifiers = [ "Programming Language :: Python :: 3.10", ] dependencies = [ - "jsonschema>=4.20.0", "mcp>=2.2,<3", "pydantic>=2.0.0", "tzdata>=2024.2", diff --git a/src/time/src/mcp_server_time/server.py b/src/time/src/mcp_server_time/server.py index 4a2cd187ae..e0b15c23c2 100644 --- a/src/time/src/mcp_server_time/server.py +++ b/src/time/src/mcp_server_time/server.py @@ -2,42 +2,34 @@ # timezones, with the local timezone detected (or set with --local-timezone) # and named in the tool descriptions so a model can default to it. # -# Built on the MCP Python SDK v2's low-level `Server` (#4851). That SDK no -# longer validates tool arguments or folds tool exceptions into `isError` -# results, so `call_tool` does both with SDK v1's messages, keeping tool -# results as they were on v1. `serve()` serves both protocol eras (#4853): a -# 2025-11-25 client through the initialize handshake, and a 2026-07-28 client -# sessionlessly. No handler reads the session or calls back to the client -# (roots, sampling, elicitation, logging), so nothing differs between them. +# Built on the MCP Python SDK's high-level `MCPServer` (#5174), which these +# reference servers use to show the SDK's recommended API. Each tool is a +# typed function: `MCPServer` derives its input schema from the parameters, +# validates arguments, and returns the pydantic result model as both +# `structuredContent` (with an `outputSchema`) and a JSON text block. A +# failure the tool anticipates raises `ToolError`, which the model reads as an +# `isError` result. An unknown tool name is a JSON-RPC `-32602` protocol error, +# as both spec revisions require (see `reject_unknown_tools`). `serve()` serves +# both protocol eras (#4853): a 2025-11-25 client through the initialize +# handshake, and a 2026-07-28 client sessionlessly. No tool reads the session +# or calls back to the client, so nothing differs between them. from datetime import datetime, timedelta from functools import cache from importlib.metadata import version from enum import Enum -import json import sys -from typing import Any +from typing import Annotated, Any from zoneinfo import ZoneInfo, ZoneInfoNotFoundError, available_timezones from tzlocal import get_localzone_name # ← returns "Europe/Paris", etc. -import jsonschema -from mcp.server import Server, ServerRequestContext -from mcp.server.stdio import stdio_server -from mcp.types import ( - CallToolRequestParams, - CallToolResult, - ContentBlock, - ListToolsResult, - PaginatedRequestParams, - Tool, - ToolAnnotations, - TextContent, - INVALID_PARAMS, -) +from mcp.server.context import CallNext, HandlerResult, ServerRequestContext +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.exceptions import ToolError from mcp.shared.exceptions import MCPError - -from pydantic import BaseModel +from mcp.types import INVALID_PARAMS, ToolAnnotations +from pydantic import BaseModel, Field # The version this server reports in serverInfo, read from the installed # distribution's metadata (pyproject.toml) so it cannot drift from the @@ -103,10 +95,11 @@ def load_zoneinfo(timezone_name: str) -> ZoneInfo: def get_zoneinfo(timezone_name: str) -> ZoneInfo: + """`load_zoneinfo`, failing with a `ToolError` the model can read.""" try: return load_zoneinfo(timezone_name) except Exception as e: - raise MCPError(code=INVALID_PARAMS, message=f"Invalid timezone: {str(e)}") + raise ToolError(f"Invalid timezone: {str(e)}") from e class TimeServer: @@ -132,7 +125,7 @@ def convert_time( try: parsed_time = datetime.strptime(time_str, "%H:%M").time() except ValueError: - raise ValueError("Invalid time format. Expected HH:MM [24-hour format]") + raise ToolError("Invalid time format. Expected HH:MM [24-hour format]") now = datetime.now(source_timezone) source_time = datetime( @@ -148,7 +141,7 @@ def convert_time( # spring-forward day) does not survive a round trip through UTC. round_trip = datetime.fromtimestamp(source_time.timestamp(), source_timezone) if round_trip.replace(tzinfo=None) != source_time.replace(tzinfo=None): - raise ValueError( + raise ToolError( f"Invalid time: {time_str} does not exist in {source_tz} on " f"{source_time.date().isoformat()} (skipped by a clock change)" ) @@ -181,15 +174,99 @@ def convert_time( ) -def tool_error(message: str) -> CallToolResult: - """A tool call that failed, as an `isError` result the model can read.""" - return CallToolResult( - content=[TextContent(type="text", text=message)], is_error=True +READ_ONLY = ToolAnnotations( + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=False, +) + + +def require(name: str, value: str) -> None: + """Report an empty timezone as missing, rather than as zoneinfo's own error.""" + if not value: + raise ToolError(f"Missing required argument: {name}") + + +def reject_unknown_tools(server: MCPServer) -> None: + """Answer a call to an unknown tool with JSON-RPC `-32602`. + + Both spec revisions (2025-11-25 and 2026-07-28, server/tools "Error + Handling") make an unknown tool a protocol error, but `MCPServer` still + answers it with an `isError` result (python-sdk#3659). This middleware + rejects the name before `MCPServer` looks it up. Remove it once + `MCPServer` does this itself. + """ + + async def middleware( + ctx: ServerRequestContext[Any, Any], call_next: CallNext + ) -> HandlerResult: + if ctx.method == "tools/call" and ctx.params is not None: + name = ctx.params.get("name") + if isinstance(name, str) and name not in { + tool.name for tool in await server.list_tools() + }: + raise MCPError(code=INVALID_PARAMS, message=f"Unknown tool: {name}") + return await call_next(ctx) + + server.middleware.append(middleware) + + +def create_server(local_tz: str) -> MCPServer: + """The time server, with `local_tz` named in its tools' argument descriptions.""" + time_server = TimeServer() + # The tools are fixed at startup, so there is no list change to listen + # for: `subscriptions=False` leaves 2026-07-28's `subscriptions/listen` + # unserved, and the server does not claim `listChanged` in either era. + server = MCPServer("mcp-time", version=SERVER_VERSION, subscriptions=False) + reject_unknown_tools(server) + + @server.tool( + name=TimeTools.GET_CURRENT_TIME.value, + description="Get current time in a specific timezone", + annotations=READ_ONLY, ) + def get_current_time( + timezone: Annotated[ + str, + Field( + description=f"IANA timezone name (e.g., 'America/New_York', 'Europe/London'). Use '{local_tz}' as local timezone if no timezone provided by the user." + ), + ], + ) -> TimeResult: + require("timezone", timezone) + return time_server.get_current_time(timezone) + + @server.tool( + name=TimeTools.CONVERT_TIME.value, + description="Convert time between timezones", + annotations=READ_ONLY, + ) + def convert_time( + source_timezone: Annotated[ + str, + Field( + description=f"Source IANA timezone name (e.g., 'America/New_York', 'Europe/London'). Use '{local_tz}' as local timezone if no source timezone provided by the user." + ), + ], + time: Annotated[ + str, Field(description="Time to convert in 24-hour format (HH:MM)") + ], + target_timezone: Annotated[ + str, + Field( + description=f"Target IANA timezone name (e.g., 'Asia/Tokyo', 'America/San_Francisco'). Use '{local_tz}' as local timezone if no target timezone provided by the user." + ), + ], + ) -> TimeConversionResult: + require("source_timezone", source_timezone) + require("target_timezone", target_timezone) + return time_server.convert_time(source_timezone, time, target_timezone) + + return server async def serve(local_timezone: str | None = None) -> None: - time_server = TimeServer() if local_timezone: # Fail before the transport opens, with one line naming the bad value # rather than a traceback. zoneinfo raises a different type per kind of @@ -202,133 +279,10 @@ async def serve(local_timezone: str | None = None) -> None: f"Error: invalid --local-timezone {local_timezone!r}: " "not a known IANA timezone name" ) - local_tz = str(get_local_tz(local_timezone)) - - tools = [ - Tool( - name=TimeTools.GET_CURRENT_TIME.value, - description="Get current time in a specific timezone", - input_schema={ - "type": "object", - "properties": { - "timezone": { - "type": "string", - "description": f"IANA timezone name (e.g., 'America/New_York', 'Europe/London'). Use '{local_tz}' as local timezone if no timezone provided by the user.", - } - }, - "required": ["timezone"], - }, - annotations=ToolAnnotations( - read_only_hint=True, - destructive_hint=False, - idempotent_hint=True, - open_world_hint=False, - ), - ), - Tool( - name=TimeTools.CONVERT_TIME.value, - description="Convert time between timezones", - input_schema={ - "type": "object", - "properties": { - "source_timezone": { - "type": "string", - "description": f"Source IANA timezone name (e.g., 'America/New_York', 'Europe/London'). Use '{local_tz}' as local timezone if no source timezone provided by the user.", - }, - "time": { - "type": "string", - "description": "Time to convert in 24-hour format (HH:MM)", - }, - "target_timezone": { - "type": "string", - "description": f"Target IANA timezone name (e.g., 'Asia/Tokyo', 'America/San_Francisco'). Use '{local_tz}' as local timezone if no target timezone provided by the user.", - }, - }, - "required": ["source_timezone", "time", "target_timezone"], - }, - annotations=ToolAnnotations( - read_only_hint=True, - destructive_hint=False, - idempotent_hint=True, - open_world_hint=False, - ), - ), - ] - schemas = {tool.name: tool.input_schema for tool in tools} - - async def list_tools( - ctx: ServerRequestContext, params: PaginatedRequestParams | None - ) -> ListToolsResult: - """List available time tools.""" - return ListToolsResult(tools=tools) - - def run_tool(name: str, arguments: dict[str, Any]) -> list[ContentBlock]: - """Handle tool calls for time queries.""" - try: - match name: - case TimeTools.GET_CURRENT_TIME.value: - timezone = arguments.get("timezone") - if not timezone: - raise ValueError("Missing required argument: timezone") - - result = time_server.get_current_time(timezone) - - case TimeTools.CONVERT_TIME.value: - if not all( - k in arguments - for k in ["source_timezone", "time", "target_timezone"] - ): - raise ValueError( # pragma: no cover # unreachable: call_tool rejects a missing key against inputSchema first - "Missing required arguments" - ) - for key in ["source_timezone", "target_timezone"]: - if not arguments[key]: - raise ValueError(f"Missing required argument: {key}") - - result = time_server.convert_time( - arguments["source_timezone"], - arguments["time"], - arguments["target_timezone"], - ) - case _: - raise ValueError(f"Unknown tool: {name}") - - return [ - TextContent(type="text", text=json.dumps(result.model_dump(), indent=2)) - ] - - except Exception as e: - raise ValueError(f"Error processing mcp-server-time query: {str(e)}") - - async def call_tool( - ctx: ServerRequestContext, params: CallToolRequestParams - ) -> CallToolResult: - # SDK v2's low-level server neither validates arguments nor turns a - # handler exception into an isError result, both of which SDK v1 did. - # Both are done here, with v1's messages, so tool results are unchanged. - arguments = params.arguments or {} - schema = schemas.get(params.name) - if schema is not None: - try: - jsonschema.validate(instance=arguments, schema=schema) - except jsonschema.ValidationError as e: - return tool_error(f"Input validation error: {e.message}") - try: - return CallToolResult(content=run_tool(params.name, arguments)) - except Exception as e: - return tool_error(str(e)) - - server = Server( - "mcp-time", - version=SERVER_VERSION, - on_list_tools=list_tools, - on_call_tool=call_tool, - ) - options = server.create_initialization_options() - async with stdio_server() as (read_stream, write_stream): - # Both eras (#4853): Server.run()'s dual-era loop lets the client's - # first request decide. `initialize` gets the 2025-11-25 handshake; - # `server/discover` or a request carrying a 2026-07-28 envelope gets - # the modern, sessionless era, with its required result fields - # (resultType, ttlMs, cacheScope) filled in by the SDK. - await server.run(read_stream, write_stream, options) + server = create_server(str(get_local_tz(local_timezone))) + # Both eras (#4853): the SDK's dual-era loop lets the client's first + # request decide. `initialize` gets the 2025-11-25 handshake; + # `server/discover` or a request carrying a 2026-07-28 envelope gets the + # modern, sessionless era, with its required result fields (resultType, + # ttlMs, cacheScope) filled in by the SDK. + await server.run_stdio_async() diff --git a/src/time/tests/test_eras.py b/src/time/tests/test_eras.py index 5f2e35b45a..d619fab639 100644 --- a/src/time/tests/test_eras.py +++ b/src/time/tests/test_eras.py @@ -8,9 +8,8 @@ # JSON-RPC path stdio takes, rather than the SDK's in-process shortcut, which # would bypass serve() and its era dispatch. # -# The legacy-era characterization tests in test_protocol.py stay as the -# backward-compatibility net; this file adds what differs by era and shows -# that the rest does not. +# The detailed protocol tests in test_protocol.py run in the legacy era; this +# file adds what differs by era and shows that the rest does not. import json from collections.abc import AsyncIterator @@ -24,7 +23,13 @@ from mcp.client._transport import TransportStreams from mcp.shared.exceptions import MCPError from mcp.shared.memory import create_client_server_memory_streams -from mcp.types import METHOD_NOT_FOUND, CallToolResult, TextContent +from mcp.types import ( + INVALID_PARAMS, + METHOD_NOT_FOUND, + CallToolResult, + PromptReference, + TextContent, +) from mcp_server_time.server import serve @@ -55,7 +60,7 @@ async def fake_stdio_server() -> AsyncIterator[Any]: async def transport() -> AsyncIterator[TransportStreams]: yield client_streams - with patch("mcp_server_time.server.stdio_server", fake_stdio_server): + with patch("mcp.server.mcpserver.server.stdio_server", fake_stdio_server): async with anyio.create_task_group() as tg: tg.start_soon(serve, local_timezone) async with Client(transport(), mode=mode) as client: @@ -116,6 +121,8 @@ async def test_tool_call_succeeds_in_each_era(mode: str, negotiated: str) -> Non data = json.loads(text_of(result)) assert data["target"]["datetime"].endswith("T17:00:00+05:00") assert data["time_difference"] == "+5.0h" + # The same object, structured, in either era. + assert result.structured_content == data @pytest.mark.parametrize(("mode", "negotiated"), ERAS) @@ -126,27 +133,52 @@ async def test_tool_failure_is_an_is_error_result_in_each_era( bad_zone = await client.call_tool( "get_current_time", {"timezone": "Nope/Nowhere"} ) - unknown = await client.call_tool("no_such_tool", {}) assert bad_zone.is_error is True - assert "Invalid timezone" in text_of(bad_zone) - assert unknown.is_error is True - assert text_of(unknown) == ( - "Error processing mcp-server-time query: Unknown tool: no_such_tool" + assert text_of(bad_zone) == ( + "Error executing tool get_current_time: " + "Invalid timezone: 'No time zone found with key Nope/Nowhere'" ) +@pytest.mark.parametrize(("mode", "negotiated"), ERAS) +async def test_unknown_tool_is_a_json_rpc_error_in_each_era( + mode: str, negotiated: str +) -> None: + # Both spec revisions make an unknown tool a protocol error, -32602, not + # an isError result (#5174). + async with connect(mode) as client: + with pytest.raises(MCPError) as excinfo: + await client.call_tool("no_such_tool", {}) + assert excinfo.value.error.code == INVALID_PARAMS + assert excinfo.value.error.message == "Unknown tool: no_such_tool" + + @pytest.mark.parametrize(("mode", "negotiated"), ERAS) async def test_unsupported_method_is_a_json_rpc_error_in_each_era( mode: str, negotiated: str ) -> None: - # The server declares no resources, so resources/list is a protocol error - # (an MCPError on the client), not an isError result. + # The server offers no completions, so completion/complete is a protocol + # error (an MCPError on the client), not an isError result. async with connect(mode) as client: with pytest.raises(MCPError) as excinfo: - await client.list_resources() + await client.complete( + PromptReference(type="ref/prompt", name="none"), + {"name": "a", "value": "b"}, + ) assert excinfo.value.error.code == METHOD_NOT_FOUND +@pytest.mark.parametrize(("mode", "negotiated"), ERAS) +async def test_prompts_and_resources_are_empty_in_each_era( + mode: str, negotiated: str +) -> None: + # MCPServer serves prompts/list and resources/list for every server; this + # one registers neither. + async with connect(mode) as client: + assert (await client.list_prompts()).prompts == [] + assert (await client.list_resources()).resources == [] + + async def test_ping_is_served_only_in_the_legacy_era() -> None: # ping is removed in 2026-07-28; the legacy era keeps answering it. async with connect("legacy") as client: diff --git a/src/time/tests/test_protocol.py b/src/time/tests/test_protocol.py index e4daee6418..ff17d34895 100644 --- a/src/time/tests/test_protocol.py +++ b/src/time/tests/test_protocol.py @@ -1,19 +1,16 @@ -# Characterization tests for mcp-server-time, driven through the MCP protocol -# in-process (#4855). +# Protocol-level tests for mcp-server-time, driven through the MCP protocol +# in-process (#4855, #5174). # -# Each test runs the real `serve()` coroutine with `stdio_server` swapped for -# an in-memory stream pair, and talks to it with a real `ClientSession`. That -# exercises what a client actually sees: the server's input validation, its -# folding of handler exceptions into `isError` results, the advertised tool -# schemas and the initialize handshake. The pure helpers in `server.py` are -# covered directly in `test_server.py`. +# Each test runs the real `serve()` coroutine with the SDK's `stdio_server` +# swapped for an in-memory stream pair, and talks to it with a real +# `ClientSession`. That exercises what a client actually sees: `MCPServer`'s +# argument validation, the `isError` results a tool's `ToolError` becomes, +# the JSON-RPC error for an unknown tool, the advertised tool schemas and the +# initialize handshake. The pure helpers in `server.py` are covered directly +# in `test_server.py`. # -# These tests pin CURRENT behavior on `mcp` 1.x, including behavior open issues -# want changed, so that the SDK v2 port (#4851) has a regression net and any -# intended change shows up as a failing assertion. Assertions compare wire JSON -# (`model_dump(by_alias=True, mode="json")`) rather than SDK attribute names -# wherever practical, so the snake_case renames in SDK v2 do not force a -# rewrite. +# Assertions compare wire JSON (`model_dump(by_alias=True, mode="json")`) +# rather than SDK attribute names wherever practical. # # Time is frozen with `frozen_at()`, which swaps the server module's # `datetime` for a subclass whose `now()` returns a fixed instant. freezegun is @@ -32,9 +29,10 @@ import anyio import pytest from mcp import ClientSession +from mcp.shared.exceptions import MCPError from mcp.shared.memory import create_client_server_memory_streams from mcp.shared.message import SessionMessage -from mcp.types import JSONRPCRequest +from mcp.types import INVALID_PARAMS, JSONRPCRequest from pydantic import BaseModel from mcp_server_time.server import serve @@ -78,7 +76,7 @@ async def serving( async def fake_stdio_server(): yield server_streams - with patch("mcp_server_time.server.stdio_server", fake_stdio_server): + with patch("mcp.server.mcpserver.server.stdio_server", fake_stdio_server): async with anyio.create_task_group() as tg: tg.start_soon(serve, local_timezone) async with ClientSession(*client_streams) as session: @@ -116,9 +114,25 @@ def error_result(text: str) -> dict[str, Any]: return {"content": [{"type": "text", "text": text}], "isError": True} -def handler_error(message: str) -> dict[str, Any]: - """An exception raised in the server's handler, as the client receives it.""" - return error_result(f"Error processing mcp-server-time query: {message}") +def tool_error(tool: str, message: str) -> dict[str, Any]: + """A `ToolError` the tool raised, as the client receives it.""" + return error_result(f"Error executing tool {tool}: {message}") + + +def assert_validation_error(result: dict[str, Any], tool: str, *needles: str) -> None: + """An argument `MCPServer` rejected against the tool's signature. + + The text is pydantic's, so only its stable parts are pinned: the tool, the + argument and the reason. + """ + assert result["isError"] is True, result + assert len(result["content"]) == 1 + text = result["content"][0]["text"] + assert text.startswith( + f"Error executing tool {tool}: 1 validation error for {tool}Arguments\n" + ), text + for needle in needles: + assert needle in text, (needle, text) # --------------------------------------------------------------------------- @@ -137,10 +151,14 @@ async def test_initialize_reports_server_info_and_capabilities() -> None: "version": version("mcp-server-time"), } assert init["serverInfo"]["version"] != version("mcp") - # Only tools are advertised; no resources, prompts or logging. - # Behavior change in the SDK v2 port (#4851): v2 omits the empty - # `experimental: {}` object v1 always sent. It advertised nothing. - assert init["capabilities"] == {"tools": {"listChanged": False}} + # MCPServer serves prompts/list and resources/list for every server, so + # both are advertised even though this one registers neither (both lists + # are empty). No logging. Nothing claims listChanged: the tools are fixed. + assert init["capabilities"] == { + "prompts": {"listChanged": False}, + "resources": {"subscribe": False, "listChanged": False}, + "tools": {"listChanged": False}, + } assert "instructions" not in init @@ -156,18 +174,35 @@ async def test_initialize_reports_server_info_and_capabilities() -> None: } +TIME_RESULT_SCHEMA = { + "type": "object", + "title": "TimeResult", + "properties": { + "timezone": {"title": "Timezone", "type": "string"}, + "datetime": {"title": "Datetime", "type": "string"}, + "day_of_week": {"title": "Day Of Week", "type": "string"}, + "is_dst": {"title": "Is Dst", "type": "boolean"}, + }, + "required": ["timezone", "datetime", "day_of_week", "is_dst"], +} + + def expected_tools(local_tz: str) -> list[dict[str, Any]]: - # Names and descriptions are pinned as they are today. #3201 and #2853 - # request changes to them; a fix will change this fixture. + # Names, descriptions, argument names, required sets and annotations are + # the published interface and do not change with the move to MCPServer + # (#5174). MCPServer adds a `title` to each input schema and property, and + # an `outputSchema` derived from the result model. return [ { "name": "get_current_time", "description": "Get current time in a specific timezone", "inputSchema": { "type": "object", + "title": "get_current_timeArguments", "properties": { "timezone": { "type": "string", + "title": "Timezone", "description": ( "IANA timezone name (e.g., 'America/New_York', " f"'Europe/London'). Use '{local_tz}' as local timezone " @@ -177,6 +212,7 @@ def expected_tools(local_tz: str) -> list[dict[str, Any]]: }, "required": ["timezone"], }, + "outputSchema": TIME_RESULT_SCHEMA, "annotations": READ_ONLY_ANNOTATIONS, }, { @@ -184,9 +220,11 @@ def expected_tools(local_tz: str) -> list[dict[str, Any]]: "description": "Convert time between timezones", "inputSchema": { "type": "object", + "title": "convert_timeArguments", "properties": { "source_timezone": { "type": "string", + "title": "Source Timezone", "description": ( "Source IANA timezone name (e.g., 'America/New_York', " f"'Europe/London'). Use '{local_tz}' as local timezone " @@ -195,10 +233,12 @@ def expected_tools(local_tz: str) -> list[dict[str, Any]]: }, "time": { "type": "string", + "title": "Time", "description": "Time to convert in 24-hour format (HH:MM)", }, "target_timezone": { "type": "string", + "title": "Target Timezone", "description": ( "Target IANA timezone name (e.g., 'Asia/Tokyo', " f"'America/San_Francisco'). Use '{local_tz}' as local " @@ -208,6 +248,17 @@ def expected_tools(local_tz: str) -> list[dict[str, Any]]: }, "required": ["source_timezone", "time", "target_timezone"], }, + "outputSchema": { + "type": "object", + "title": "TimeConversionResult", + "properties": { + "source": {"$ref": "#/$defs/TimeResult"}, + "target": {"$ref": "#/$defs/TimeResult"}, + "time_difference": {"title": "Time Difference", "type": "string"}, + }, + "required": ["source", "target", "time_difference"], + "$defs": {"TimeResult": TIME_RESULT_SCHEMA}, + }, "annotations": READ_ONLY_ANNOTATIONS, }, ] @@ -216,7 +267,7 @@ def expected_tools(local_tz: str) -> list[dict[str, Any]]: async def test_list_tools_with_local_timezone_override() -> None: async with connected("Europe/Warsaw") as session: result = wire(await session.list_tools()) - # No title, outputSchema, icons or pagination cursor. + # No tool title, icons or pagination cursor. assert result == {"tools": expected_tools("Europe/Warsaw")} @@ -263,7 +314,7 @@ async def test_invalid_local_timezone_fails_before_the_transport_opens( def unexpected_stdio_server(): raise AssertionError("stdio_server must not be reached") - with patch("mcp_server_time.server.stdio_server", unexpected_stdio_server): + with patch("mcp.server.mcpserver.server.stdio_server", unexpected_stdio_server): with pytest.raises(SystemExit) as exit_info: await serve(local_timezone) assert exit_info.value.code == ( @@ -281,22 +332,17 @@ async def test_get_current_time_wire_result() -> None: async with connected() as session: with frozen_at("2024-01-01 12:00:00+00:00"): result = await call(session, "get_current_time", {"timezone": "UTC"}) - # Exactly one text block holding indented JSON; no structuredContent. + # The result model as structuredContent, and the same object as indented + # JSON in one text block for a client that does not read structured output. + expected = { + "timezone": "UTC", + "datetime": "2024-01-01T12:00:00+00:00", + "day_of_week": "Monday", + "is_dst": False, + } assert result == { - "content": [ - { - "type": "text", - "text": json.dumps( - { - "timezone": "UTC", - "datetime": "2024-01-01T12:00:00+00:00", - "day_of_week": "Monday", - "is_dst": False, - }, - indent=2, - ), - } - ], + "content": [{"type": "text", "text": json.dumps(expected, indent=2)}], + "structuredContent": expected, "isError": False, } @@ -471,57 +517,55 @@ async def test_get_current_time_does_not_default_to_the_local_timezone() -> None # never substitutes it for an empty argument. async with connected("Europe/Warsaw") as session: result = await call(session, "get_current_time", {"timezone": ""}) - assert result == handler_error("Missing required argument: timezone") + assert result == tool_error( + "get_current_time", "Missing required argument: timezone" + ) @pytest.mark.parametrize( - "arguments,expected", + "arguments,needles", [ - # Rejected by call_tool's input validation against inputSchema, before - # the handler runs. - ({}, error_result("Input validation error: 'timezone' is a required property")), - ( - {"timezone": 5}, - error_result("Input validation error: 5 is not of type 'string'"), - ), - ( - {"timezone": None}, - error_result("Input validation error: None is not of type 'string'"), - ), - # Raised by the handler and folded into an error result by call_tool. - ({"timezone": ""}, handler_error("Missing required argument: timezone")), + # Rejected by MCPServer against the tool's signature, before it runs. + ({}, ["timezone\n Field required [type=missing"]), + ({"timezone": 5}, ["timezone\n Input should be a valid string"]), + ({"timezone": None}, ["timezone\n Input should be a valid string"]), + ], +) +async def test_get_current_time_argument_validation( + arguments: dict[str, Any], needles: list[str] +) -> None: + async with connected() as session: + result = await call(session, "get_current_time", arguments) + assert_validation_error(result, "get_current_time", *needles) + + +@pytest.mark.parametrize( + "arguments,message", + [ + # Raised by the tool as a ToolError. + ({"timezone": ""}, "Missing required argument: timezone"), ( {"timezone": "Invalid/Timezone"}, - handler_error( - "Invalid timezone: 'No time zone found with key Invalid/Timezone'" - ), + "Invalid timezone: 'No time zone found with key Invalid/Timezone'", ), ( {"timezone": "europe/warsaw"}, - handler_error( - "Invalid timezone: 'No time zone found with key europe/warsaw'" - ), + "Invalid timezone: 'No time zone found with key europe/warsaw'", ), ( {"timezone": "../etc/passwd"}, - handler_error( - "Invalid timezone: ZoneInfo keys must refer to subdirectories of TZPATH, got: ../etc/passwd" - ), + "Invalid timezone: ZoneInfo keys must refer to subdirectories of TZPATH, got: ../etc/passwd", ), ( {"timezone": "/etc/localtime"}, - handler_error( - "Invalid timezone: ZoneInfo keys may not be absolute paths, got: /etc/localtime" - ), + "Invalid timezone: ZoneInfo keys may not be absolute paths, got: /etc/localtime", ), ], ) -async def test_get_current_time_errors( - arguments: dict[str, Any], expected: dict[str, Any] -) -> None: +async def test_get_current_time_errors(arguments: dict[str, Any], message: str) -> None: async with connected() as session: result = await call(session, "get_current_time", arguments) - assert result == expected + assert result == tool_error("get_current_time", message) # --------------------------------------------------------------------------- @@ -541,30 +585,24 @@ async def test_convert_time_wire_result() -> None: "target_timezone": "Asia/Tokyo", }, ) + expected = { + "source": { + "timezone": "Europe/Warsaw", + "datetime": "2024-01-01T12:00:00+01:00", + "day_of_week": "Monday", + "is_dst": False, + }, + "target": { + "timezone": "Asia/Tokyo", + "datetime": "2024-01-01T20:00:00+09:00", + "day_of_week": "Monday", + "is_dst": False, + }, + "time_difference": "+8.0h", + } assert result == { - "content": [ - { - "type": "text", - "text": json.dumps( - { - "source": { - "timezone": "Europe/Warsaw", - "datetime": "2024-01-01T12:00:00+01:00", - "day_of_week": "Monday", - "is_dst": False, - }, - "target": { - "timezone": "Asia/Tokyo", - "datetime": "2024-01-01T20:00:00+09:00", - "day_of_week": "Monday", - "is_dst": False, - }, - "time_difference": "+8.0h", - }, - indent=2, - ), - } - ], + "content": [{"type": "text", "text": json.dumps(expected, indent=2)}], + "structuredContent": expected, "isError": False, } @@ -749,9 +787,10 @@ async def test_convert_time_into_a_nonexistent_local_time() -> None: "target_timezone": "UTC", }, ) - assert result == handler_error( + assert result == tool_error( + "convert_time", "Invalid time: 02:30 does not exist in America/New_York on 2024-03-10 " - "(skipped by a clock change)" + "(skipped by a clock change)", ) @@ -825,54 +864,61 @@ async def test_convert_time_ignores_extra_arguments() -> None: @pytest.mark.parametrize( - "arguments,expected", + "arguments,needles", [ - # Rejected by call_tool's input validation, before the handler runs. A - # missing key never reaches the handler's own "Missing required - # arguments" check, which is why that branch is marked unreachable. + # Rejected by MCPServer against the tool's signature, before it runs. ( {"source_timezone": "UTC", "time": "12:00"}, - error_result( - "Input validation error: 'target_timezone' is a required property" - ), + ["target_timezone\n Field required [type=missing"], ), ( {"time": "12:00", "target_timezone": "UTC"}, - error_result( - "Input validation error: 'source_timezone' is a required property" - ), + ["source_timezone\n Field required [type=missing"], ), ( {"source_timezone": "UTC", "time": 1200, "target_timezone": "UTC"}, - error_result("Input validation error: 1200 is not of type 'string'"), + ["time\n Input should be a valid string"], ), - # Raised by the handler. + ], +) +async def test_convert_time_argument_validation( + arguments: dict[str, Any], needles: list[str] +) -> None: + async with connected() as session: + result = await call(session, "convert_time", arguments) + assert_validation_error(result, "convert_time", *needles) + + +@pytest.mark.parametrize( + "arguments,message", + [ + # Raised by the tool as a ToolError. ( {"source_timezone": "Bad/Zone", "time": "12:00", "target_timezone": "UTC"}, - handler_error("Invalid timezone: 'No time zone found with key Bad/Zone'"), + "Invalid timezone: 'No time zone found with key Bad/Zone'", ), ( {"source_timezone": "UTC", "time": "12:00", "target_timezone": "Bad/Zone"}, - handler_error("Invalid timezone: 'No time zone found with key Bad/Zone'"), + "Invalid timezone: 'No time zone found with key Bad/Zone'", ), # Like get_current_time, an empty timezone is reported as a missing # argument rather than as zoneinfo's own message. ( {"source_timezone": "", "time": "12:00", "target_timezone": "UTC"}, - handler_error("Missing required argument: source_timezone"), + "Missing required argument: source_timezone", ), ( {"source_timezone": "UTC", "time": "12:00", "target_timezone": ""}, - handler_error("Missing required argument: target_timezone"), + "Missing required argument: target_timezone", ), ], ) async def test_convert_time_argument_errors( - arguments: dict[str, Any], expected: dict[str, Any] + arguments: dict[str, Any], message: str ) -> None: async with connected() as session: result = await call(session, "convert_time", arguments) - assert result == expected + assert result == tool_error("convert_time", message) @pytest.mark.parametrize( @@ -886,8 +932,8 @@ async def test_convert_time_rejects_bad_time_strings(time: str) -> None: "convert_time", {"source_timezone": "UTC", "time": time, "target_timezone": "UTC"}, ) - assert result == handler_error( - "Invalid time format. Expected HH:MM [24-hour format]" + assert result == tool_error( + "convert_time", "Invalid time format. Expected HH:MM [24-hour format]" ) @@ -898,8 +944,8 @@ async def test_convert_time_checks_timezones_before_the_time_string() -> None: "convert_time", {"source_timezone": "UTC", "time": "nope", "target_timezone": "Bad/Zone"}, ) - assert result == handler_error( - "Invalid timezone: 'No time zone found with key Bad/Zone'" + assert result == tool_error( + "convert_time", "Invalid timezone: 'No time zone found with key Bad/Zone'" ) @@ -908,18 +954,21 @@ async def test_convert_time_checks_timezones_before_the_time_string() -> None: # --------------------------------------------------------------------------- -async def test_unknown_tool_is_an_error_result_not_a_protocol_error() -> None: - # call_tool skips validation for an unlisted tool, then the handler's - # fallthrough raises, which call_tool folds into `isError`. +async def test_unknown_tool_is_a_protocol_error() -> None: + # Both spec revisions make an unknown tool a JSON-RPC -32602 error, not an + # isError result (#5174). Era by era coverage is in test_eras.py. async with connected() as session: - result = await call(session, "no_such_tool", {"timezone": "UTC"}) - assert result == handler_error("Unknown tool: no_such_tool") + with pytest.raises(MCPError) as excinfo: + await session.call_tool("no_such_tool", {"timezone": "UTC"}) + assert excinfo.value.error.code == INVALID_PARAMS + assert excinfo.value.error.message == "Unknown tool: no_such_tool" async def test_session_survives_errors() -> None: # A failed call does not take the server down. async with connected() as session: - await call(session, "no_such_tool", {}) + with pytest.raises(MCPError): + await session.call_tool("no_such_tool", {}) await call(session, "get_current_time", {"timezone": "Bad/Zone"}) result = await call(session, "get_current_time", {"timezone": "UTC"}) assert result["isError"] is False @@ -962,7 +1011,7 @@ async def test_server_discover_answers_the_2026_07_28_era() -> None: async def fake_stdio_server() -> AsyncIterator[Any]: yield server_streams - with patch("mcp_server_time.server.stdio_server", fake_stdio_server): + with patch("mcp.server.mcpserver.server.stdio_server", fake_stdio_server): async with anyio.create_task_group() as tg: tg.start_soon(serve, "UTC") await client_write.send(SessionMessage(discover)) @@ -975,7 +1024,13 @@ async def fake_stdio_server() -> AsyncIterator[Any]: "jsonrpc": "2.0", "id": 1, "result": { - "capabilities": {"tools": {"listChanged": False}}, + # The same capabilities as the 2025-11-25 handshake; with + # subscriptions/listen unserved, none claims listChanged. + "capabilities": { + "prompts": {"listChanged": False}, + "resources": {"subscribe": False, "listChanged": False}, + "tools": {"listChanged": False}, + }, "supportedVersions": ["2026-07-28"], "resultType": "complete", "ttlMs": 0, diff --git a/src/time/tests/test_server.py b/src/time/tests/test_server.py index a64ecc863f..5fda472a05 100644 --- a/src/time/tests/test_server.py +++ b/src/time/tests/test_server.py @@ -1,5 +1,5 @@ from freezegun import freeze_time -from mcp.shared.exceptions import MCPError +from mcp.server.mcpserver.exceptions import ToolError import pytest from unittest.mock import patch from zoneinfo import ZoneInfo @@ -89,7 +89,7 @@ def test_get_current_time(test_time, timezone, expected): def test_get_current_time_with_invalid_timezone(): time_server = TimeServer() with pytest.raises( - MCPError, + ToolError, match=r"Invalid timezone: 'No time zone found with key Invalid/Timezone'", ): time_server.get_current_time("Invalid/Timezone") @@ -120,7 +120,7 @@ def test_get_current_time_with_invalid_timezone(): ) def test_convert_time_errors(source_tz, time_str, target_tz, expected_error): time_server = TimeServer() - with pytest.raises((MCPError, ValueError), match=expected_error): + with pytest.raises(ToolError, match=expected_error): time_server.convert_time(source_tz, time_str, target_tz) @@ -539,7 +539,7 @@ def test_get_zoneinfo_rejects_a_key_the_tz_database_does_not_list(): without_warsaw = known_timezones() - {"Europe/Warsaw"} with patch("mcp_server_time.server.known_timezones", return_value=without_warsaw): with pytest.raises( - MCPError, + ToolError, match="Invalid timezone: 'No time zone found with key Europe/Warsaw'", ): get_zoneinfo("Europe/Warsaw") diff --git a/src/time/uv.lock b/src/time/uv.lock index da9478e342..7afc3c2923 100644 --- a/src/time/uv.lock +++ b/src/time/uv.lock @@ -500,7 +500,6 @@ name = "mcp-server-time" version = "2026.10.18" source = { editable = "." } dependencies = [ - { name = "jsonschema" }, { name = "mcp" }, { name = "pydantic" }, { name = "tzdata" }, @@ -519,7 +518,6 @@ dev = [ [package.metadata] requires-dist = [ - { name = "jsonschema", specifier = ">=4.20.0" }, { name = "mcp", specifier = ">=2.2,<3" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "tzdata", specifier = ">=2024.2" }, From 50c68234b09c95cfa3789ea9cf4da36d369bc9b8 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 19 Oct 2026 01:29:38 -0400 Subject: [PATCH 110/117] feat(fetch): build on MCPServer; an unknown tool is a -32602 error (#5174) The fetch server moves from the SDK's low-level Server, with its SDK v1 compatibility wrappers, to the high-level MCPServer, so this reference server shows the API new servers should use: - The `fetch` tool and prompt are decorated functions. Their type hints and Field descriptions are the input schema and the prompt's arguments, and MCPServer validates calls against them. The non-URL parameters are strict, so a string is not coerced to the integer or boolean the schema declares. - Anticipated failures (robots.txt refusal, connection failure, HTTP error status, refused private address, unusable proxy) raise ToolError, which MCPServer returns as an isError result prefixed "Error executing tool fetch: ". - An unknown tool is now a JSON-RPC -32602 protocol error, as both spec revisions require. MCPServer still answers it with isError (python-sdk#3659), so a middleware rejects the name first; it also keeps -32602 for an unknown prompt and a prompt call without `url`, which MCPServer would answer with -32603. - subscriptions=False: the lists never change, so the 2026-07-28 era advertises listChanged: false. - The hand-rolled jsonschema validation and isError folding are gone, and so is the direct jsonschema dependency. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- src/fetch/README.md | 8 + src/fetch/pyproject.toml | 1 - src/fetch/src/mcp_server_fetch/server.py | 377 ++++++++++------------- src/fetch/tests/harness.py | 8 +- src/fetch/tests/test_eras.py | 37 ++- src/fetch/tests/test_protocol.py | 133 +++++--- src/fetch/uv.lock | 2 - 7 files changed, 288 insertions(+), 278 deletions(-) diff --git a/src/fetch/README.md b/src/fetch/README.md index 7d83961207..3b1a3894c2 100644 --- a/src/fetch/README.md +++ b/src/fetch/README.md @@ -31,6 +31,14 @@ The fetch tool will truncate the response, but by using the `start_index` argume - Arguments: - `url` (string, required): URL to fetch +### Errors + +- A failed fetch (a robots.txt refusal, an unreachable page, an HTTP error status, a refused private address, an unusable proxy setting) and arguments that do not match the tool's schema are returned as a tool result with `isError: true`, whose text starts `Error executing tool fetch:` and says what went wrong, so the model can read it and decide what to do. +- Calling a tool the server does not have is a JSON-RPC protocol error, `-32602` (`Unknown tool: `), as the specification requires. So is getting a prompt it does not have (`Unknown prompt: `), or the `fetch` prompt without its `url` (`URL is required`). +- When the `fetch` prompt's fetch fails, the prompt still returns: its message holds the reason the fetch failed. + +The server is built on the MCP Python SDK's high-level `MCPServer`: the tool and the prompt are decorated functions, and their type hints and field descriptions are the input schema and the prompt's arguments. + ### Protocol versions The server speaks both eras of the Model Context Protocol over stdio, with nothing to configure: a **2026-07-28** client (discovered with `server/discover`, no session) and a **2025-11-25** client (the `initialize` handshake). The client's first request decides which. Tools and prompts behave the same in both. Methods the 2026-07-28 specification removes, such as `ping`, are answered only on a 2025-11-25 connection. diff --git a/src/fetch/pyproject.toml b/src/fetch/pyproject.toml index 2154af678b..765b74fa5b 100644 --- a/src/fetch/pyproject.toml +++ b/src/fetch/pyproject.toml @@ -18,7 +18,6 @@ classifiers = [ dependencies = [ "httpx[socks]>=0.27", "markdownify>=0.13.1", - "jsonschema>=4.20.0", "mcp>=2.2,<3", "protego>=0.6.2", "pydantic>=2.0.0", diff --git a/src/fetch/src/mcp_server_fetch/server.py b/src/fetch/src/mcp_server_fetch/server.py index 4447f81e88..bb058a9954 100644 --- a/src/fetch/src/mcp_server_fetch/server.py +++ b/src/fetch/src/mcp_server_fetch/server.py @@ -4,49 +4,40 @@ # check) must go through it: the private-address guard on each request and # redirect hop, robots.txt for autonomous fetches, and proxy normalization. # -# Built on the MCP Python SDK v2's low-level `Server` (#4851). That SDK no -# longer validates tool arguments or folds tool exceptions into `isError` -# results, so `call_tool` does both with SDK v1's messages, keeping tool -# results as they were on v1. `serve()` serves both protocol eras (#4853): a -# 2025-11-25 client through the initialize handshake, and a 2026-07-28 client -# sessionlessly. No handler reads the session or calls back to the client -# (roots, sampling, elicitation, logging), so nothing differs between them. +# Built on the MCP Python SDK's high-level `MCPServer` (#5174), as the example +# to follow: the tool and the prompt are plain decorated functions, whose type +# hints and `Field` descriptions become the input schema and the prompt's +# arguments, and which the SDK validates before calling. A failure the server +# anticipated (robots.txt refusal, an unreachable or erroring URL, a private +# destination, a bad proxy setting) is raised as `ToolError`, which the SDK +# returns to the model as an `isError` result. A request naming a tool or a +# prompt the server does not have, or a prompt call without its `url`, is a +# malformed request, answered with a JSON-RPC `-32602` protocol error by +# `reject_unknown_names` before `MCPServer` sees it (see there for why). +# `serve()` serves both protocol eras (#4853): a 2025-11-25 client through the +# initialize handshake, and a 2026-07-28 client sessionlessly. No handler reads +# the session or calls back to the client (roots, sampling, elicitation, +# logging), so nothing differs between them. import asyncio import ipaddress import os import socket +from collections.abc import Mapping from typing import Annotated, Any, Tuple from importlib.metadata import version from urllib.parse import urlparse, urlunparse import httpx -import jsonschema import markdownify import readabilipy.simple_json +from mcp.server import MCPServer, ServerRequestContext +from mcp.server.context import CallNext, HandlerResult, ServerMiddleware +from mcp.server.mcpserver.exceptions import ToolError from mcp.shared.exceptions import MCPError -from mcp.server import Server, ServerRequestContext -from mcp.server.stdio import stdio_server -from mcp.types import ( - CallToolRequestParams, - CallToolResult, - ContentBlock, - GetPromptRequestParams, - GetPromptResult, - ListPromptsResult, - ListToolsResult, - PaginatedRequestParams, - Prompt, - PromptArgument, - PromptMessage, - TextContent, - Tool, - ToolAnnotations, - INVALID_PARAMS, - INTERNAL_ERROR, -) +from mcp.types import INVALID_PARAMS, ToolAnnotations from protego import Protego -from pydantic import BaseModel, Field, AnyUrl +from pydantic import AnyUrl, Field # The version this server reports in serverInfo, read from the installed # distribution's metadata (pyproject.toml) so it cannot drift from the @@ -154,11 +145,10 @@ async def _refuse_private_destination(request: httpx.Request) -> None: return for address in addresses: if not _is_public_address(address): - raise MCPError( - code=INVALID_PARAMS, - message=f"Refused to fetch {request.url}: {host} resolves to {address}, " + raise ToolError( + f"Refused to fetch {request.url}: {host} resolves to {address}, " "which is not a public address. Start the server with " - "--allow-private-ips to allow private, loopback and link-local addresses.", + "--allow-private-ips to allow private, loopback and link-local addresses." ) @@ -238,7 +228,7 @@ def normalize_proxy_env() -> None: def make_client(proxy_url: str | None) -> httpx.AsyncClient: - """Build the httpx client, turning a bad proxy setting into a tool error. + """Build the httpx client, turning a bad proxy setting into a ``ToolError``. httpx validates the proxy (``--proxy-url`` or the proxy environment variables) when the client is constructed, before any request, so this @@ -247,11 +237,10 @@ def make_client(proxy_url: str | None) -> httpx.AsyncClient: try: return httpx.AsyncClient(proxy=proxy_url) except (ValueError, ImportError) as e: - raise MCPError( - code=INTERNAL_ERROR, - message=f"Failed to set up the HTTP client, check the proxy " + raise ToolError( + f"Failed to set up the HTTP client, check the proxy " f"configuration (--proxy-url or the HTTP_PROXY, HTTPS_PROXY and " - f"ALL_PROXY environment variables): {e}", + f"ALL_PROXY environment variables): {e}" ) @@ -263,7 +252,7 @@ async def check_may_autonomously_fetch_url( ) -> None: """ Check if the URL can be fetched by the user agent according to the robots.txt file. - Raises an MCPError if not. + Raises a ToolError if not. """ from httpx import HTTPError @@ -279,14 +268,12 @@ async def check_may_autonomously_fetch_url( headers={"User-Agent": user_agent}, ) except HTTPError: - raise MCPError( - code=INTERNAL_ERROR, - message=f"Failed to fetch robots.txt {robot_txt_url} due to a connection issue", + raise ToolError( + f"Failed to fetch robots.txt {robot_txt_url} due to a connection issue" ) if response.status_code in (401, 403): - raise MCPError( - code=INTERNAL_ERROR, - message=f"When fetching robots.txt ({robot_txt_url}), received status {response.status_code} so assuming that autonomous fetching is not allowed, the user can try manually fetching by using the fetch prompt", + raise ToolError( + f"When fetching robots.txt ({robot_txt_url}), received status {response.status_code} so assuming that autonomous fetching is not allowed, the user can try manually fetching by using the fetch prompt" ) elif 400 <= response.status_code < 500: return @@ -296,14 +283,13 @@ async def check_may_autonomously_fetch_url( ) robot_parser = Protego.parse(processed_robot_txt) if not robot_parser.can_fetch(str(url), user_agent): - raise MCPError( - code=INTERNAL_ERROR, - message=f"The sites robots.txt ({robot_txt_url}), specifies that autonomous fetching of this page is not allowed, " + raise ToolError( + f"The sites robots.txt ({robot_txt_url}), specifies that autonomous fetching of this page is not allowed, " f"{user_agent}\n" f"{url}" f"\n{robot_txt}\n\n" f"The assistant must let the user know that it failed to view the page. The assistant may provide further guidance based on the above information.\n" - f"The assistant can tell the user that they can try manually fetching the page by using the fetch prompt within their UI.", + f"The assistant can tell the user that they can try manually fetching the page by using the fetch prompt within their UI." ) @@ -330,11 +316,10 @@ async def fetch_url( timeout=30, ) except HTTPError as e: - raise MCPError(code=INTERNAL_ERROR, message=f"Failed to fetch {url}: {e!r}") + raise ToolError(f"Failed to fetch {url}: {e!r}") if response.status_code >= 400: - raise MCPError( - code=INTERNAL_ERROR, - message=f"Failed to fetch {url} - status code {response.status_code}", + raise ToolError( + f"Failed to fetch {url} - status code {response.status_code}" ) page_raw = response.text @@ -353,60 +338,52 @@ async def fetch_url( ) -class Fetch(BaseModel): - """Parameters for fetching a URL.""" - - url: Annotated[AnyUrl, Field(description="URL to fetch")] - max_length: Annotated[ - int, - Field( - default=5000, - description="Maximum number of characters to return.", - # ge/le rather than gt/lt: the inclusive bounds emit minimum/maximum, - # which every client accepts, while exclusiveMinimum/exclusiveMaximum - # are rejected by some (Gemini, #1624). Same range for an int. - ge=1, - le=999999, - ), - ] - start_index: Annotated[ - int, - Field( - default=0, - description="On return output starting at this character index, useful if a previous fetch was truncated and more context is required.", - ge=0, - ), - ] - raw: Annotated[ - bool, - Field( - default=False, - description="Get the actual HTML content of the requested page, without simplification.", - ), - ] +FETCH_TOOL_DESCRIPTION = """Fetches a URL from the internet and optionally extracts its contents as markdown. +Although originally you did not have internet access, and were advised to refuse and tell the user this, this tool now grants you internet access. Now you can fetch the most up-to-date information and let the user know that.""" -def tool_error(message: str) -> CallToolResult: - """A tool call that failed, as an `isError` result the model can read.""" - return CallToolResult( - content=[TextContent(type="text", text=message)], is_error=True - ) +def reject_unknown_names(server: MCPServer) -> ServerMiddleware[Any]: + """Middleware answering a malformed tool or prompt request with `-32602`. -async def serve( + Both spec revisions (2025-11-25 and 2026-07-28, server/tools "Error + Handling") make an unknown tool a JSON-RPC protocol error, `-32602`. + `MCPServer` instead answers it with an `isError` result + (modelcontextprotocol/python-sdk#3659), and answers an unknown prompt or a + missing required prompt argument with `-32603`. This runs before the SDK + looks the name up, so each gets `-32602` with the message the server has + always sent. Remove the tool half once `MCPServer` rejects unknown tools + itself. + """ + + async def middleware( + ctx: ServerRequestContext[Any, Any], call_next: CallNext + ) -> HandlerResult: + params: Mapping[str, Any] = ctx.params or {} + name = params.get("name") + if ctx.method == "tools/call": + if name not in {tool.name for tool in await server.list_tools()}: + raise MCPError(code=INVALID_PARAMS, message=f"Unknown tool: {name}") + elif ctx.method == "prompts/get": + if name not in {prompt.name for prompt in await server.list_prompts()}: + raise MCPError(code=INVALID_PARAMS, message=f"Unknown prompt: {name}") + arguments = params.get("arguments") or {} + if "url" not in arguments: + raise MCPError(code=INVALID_PARAMS, message="URL is required") + return await call_next(ctx) + + return middleware + + +def create_server( custom_user_agent: str | None = None, ignore_robots_txt: bool = False, proxy_url: str | None = None, allow_private_ips: bool = False, -) -> None: - """Run the fetch MCP server. +) -> MCPServer: + """Build the fetch server: the `fetch` tool, the `fetch` prompt, and the guard. - Args: - custom_user_agent: Optional custom User-Agent string to use for requests - ignore_robots_txt: Whether to ignore robots.txt restrictions - proxy_url: Optional proxy URL to use for requests - allow_private_ips: Allow fetching private, loopback and link-local - addresses, which are refused by default + Args mirror `serve()`'s. """ if proxy_url: proxy_url = normalize_proxy_url(proxy_url) @@ -414,12 +391,15 @@ async def serve( user_agent_autonomous = custom_user_agent or DEFAULT_USER_AGENT_AUTONOMOUS user_agent_manual = custom_user_agent or DEFAULT_USER_AGENT_MANUAL - tool = Tool( - name="fetch", - description="""Fetches a URL from the internet and optionally extracts its contents as markdown. + # subscriptions=False: the tool and prompt lists never change and there are + # no resources to watch, so `subscriptions/listen` is not served and the + # 2026-07-28 capabilities say `listChanged: false`. + server = MCPServer("mcp-fetch", version=SERVER_VERSION, subscriptions=False) + server.middleware.append(reject_unknown_names(server)) -Although originally you did not have internet access, and were advised to refuse and tell the user this, this tool now grants you internet access. Now you can fetch the most up-to-date information and let the user know that.""", - input_schema=Fetch.model_json_schema(), + @server.tool( + name="fetch", + description=FETCH_TOOL_DESCRIPTION, # Reads only, changes nothing, and repeating a request has no further # side effect; it reaches arbitrary hosts on the internet (#3572). annotations=ToolAnnotations( @@ -428,99 +408,80 @@ async def serve( idempotent_hint=True, open_world_hint=True, ), + # The result is page text for the model, so no outputSchema. + structured_output=False, ) - - async def list_tools( - ctx: ServerRequestContext, params: PaginatedRequestParams | None - ) -> ListToolsResult: - return ListToolsResult(tools=[tool]) - - async def list_prompts( - ctx: ServerRequestContext, params: PaginatedRequestParams | None - ) -> ListPromptsResult: - return ListPromptsResult( - prompts=[ - Prompt( - name="fetch", - description="Fetch a URL and extract its contents as markdown", - arguments=[ - PromptArgument( - name="url", description="URL to fetch", required=True - ) - ], - ) - ] - ) - - async def run_tool(name: str, arguments: dict[str, Any]) -> list[ContentBlock]: - if name != "fetch": - raise ValueError(f"Unknown tool: {name}") - try: - args = Fetch(**arguments) - except ValueError as e: - raise MCPError(code=INVALID_PARAMS, message=str(e)) - - url = str(args.url) + # strict=True: pydantic would otherwise coerce "yes" to True or "10" to 10, + # accepting arguments the advertised inputSchema rejects. + async def fetch( + url: Annotated[AnyUrl, Field(description="URL to fetch")], + max_length: Annotated[ + int, + Field( + description="Maximum number of characters to return.", + # ge/le rather than gt/lt: the inclusive bounds emit + # minimum/maximum, which every client accepts, while + # exclusiveMinimum/exclusiveMaximum are rejected by some + # (Gemini, #1624). Same range for an int. + ge=1, + le=999999, + strict=True, + ), + ] = 5000, + start_index: Annotated[ + int, + Field( + description="On return output starting at this character index, useful if a previous fetch was truncated and more context is required.", + ge=0, + strict=True, + ), + ] = 0, + raw: Annotated[ + bool, + Field( + description="Get the actual HTML content of the requested page, without simplification.", + strict=True, + ), + ] = False, + ) -> str: + page_url = str(url) if not ignore_robots_txt: await check_may_autonomously_fetch_url( - url, user_agent_autonomous, proxy_url, allow_private_ips + page_url, user_agent_autonomous, proxy_url, allow_private_ips ) content, prefix = await fetch_url( - url, + page_url, user_agent_autonomous, - force_raw=args.raw, + force_raw=raw, proxy_url=proxy_url, allow_private_ips=allow_private_ips, ) original_length = len(content) - if args.start_index >= original_length: + if start_index >= original_length: content = "No more content available." else: # Never empty: start_index < original_length and max_length > 0. - truncated_content = content[ - args.start_index : args.start_index + args.max_length - ] + truncated_content = content[start_index : start_index + max_length] content = truncated_content actual_content_length = len(truncated_content) - remaining_content = original_length - ( - args.start_index + actual_content_length - ) + remaining_content = original_length - (start_index + actual_content_length) # Only add the prompt to continue fetching if there is still remaining content - if actual_content_length == args.max_length and remaining_content > 0: - next_start = args.start_index + actual_content_length + if actual_content_length == max_length and remaining_content > 0: + next_start = start_index + actual_content_length content += f"\n\nContent truncated. Call the fetch tool with a start_index of {next_start} to get more content." - return [TextContent(type="text", text=f"{prefix}Contents of {url}:\n{content}")] - - async def call_tool( - ctx: ServerRequestContext, params: CallToolRequestParams - ) -> CallToolResult: - # SDK v2's low-level server neither validates arguments nor turns a - # handler exception into an isError result, both of which SDK v1 did. - # Both are done here, with v1's messages, so tool results are unchanged. - arguments = params.arguments or {} - if params.name == tool.name: - try: - jsonschema.validate(instance=arguments, schema=tool.input_schema) - except jsonschema.ValidationError as e: - return tool_error(f"Input validation error: {e.message}") - try: - return CallToolResult(content=await run_tool(params.name, arguments)) - except Exception as e: - return tool_error(str(e)) - - async def get_prompt( - ctx: ServerRequestContext, params: GetPromptRequestParams - ) -> GetPromptResult: - name, arguments = params.name, params.arguments - if name != "fetch": - raise MCPError(code=INVALID_PARAMS, message=f"Unknown prompt: {name}") - if not arguments or "url" not in arguments: - raise MCPError(code=INVALID_PARAMS, message="URL is required") - - url = arguments["url"] + return f"{prefix}Contents of {page_url}:\n{content}" + @server.prompt( + name="fetch", + description="Fetch a URL and extract its contents as markdown", + ) + async def fetch_prompt( + url: Annotated[str, Field(description="URL to fetch")], + ) -> str: + # The user asked for this page, so robots.txt is not consulted, and a + # failure is shown to them as the prompt's text rather than raised. try: content, prefix = await fetch_url( url, @@ -528,40 +489,34 @@ async def get_prompt( proxy_url=proxy_url, allow_private_ips=allow_private_ips, ) - # TODO: after SDK bug is addressed, don't catch the exception - except MCPError as e: - return GetPromptResult( - description=f"Failed to fetch {url}", - messages=[ - PromptMessage( - role="user", - content=TextContent(type="text", text=str(e)), - ) - ], - ) - return GetPromptResult( - description=f"Contents of {url}", - messages=[ - PromptMessage( - role="user", content=TextContent(type="text", text=prefix + content) - ) - ], - ) + except ToolError as e: + return str(e) + return prefix + content + + return server - server = Server( - "mcp-fetch", - version=SERVER_VERSION, - on_list_tools=list_tools, - on_list_prompts=list_prompts, - on_call_tool=call_tool, - on_get_prompt=get_prompt, - ) - options = server.create_initialization_options() - async with stdio_server() as (read_stream, write_stream): - # Both eras (#4853): Server.run()'s dual-era loop lets the client's - # first request decide. `initialize` gets the 2025-11-25 handshake; - # `server/discover` or a request carrying a 2026-07-28 envelope gets - # the modern, sessionless era, with its required result fields - # (resultType, ttlMs, cacheScope) filled in by the SDK. - await server.run(read_stream, write_stream, options) +async def serve( + custom_user_agent: str | None = None, + ignore_robots_txt: bool = False, + proxy_url: str | None = None, + allow_private_ips: bool = False, +) -> None: + """Run the fetch MCP server over stdio. + + Args: + custom_user_agent: Optional custom User-Agent string to use for requests + ignore_robots_txt: Whether to ignore robots.txt restrictions + proxy_url: Optional proxy URL to use for requests + allow_private_ips: Allow fetching private, loopback and link-local + addresses, which are refused by default + """ + server = create_server( + custom_user_agent, ignore_robots_txt, proxy_url, allow_private_ips + ) + # Both eras (#4853): the SDK's dual-era loop lets the client's first + # request decide. `initialize` gets the 2025-11-25 handshake; + # `server/discover` or a request carrying a 2026-07-28 envelope gets the + # modern, sessionless era, with its required result fields (resultType, + # ttlMs, cacheScope) filled in by the SDK. + await server.run_stdio_async() diff --git a/src/fetch/tests/harness.py b/src/fetch/tests/harness.py index 650faa34d1..c477dc49d9 100644 --- a/src/fetch/tests/harness.py +++ b/src/fetch/tests/harness.py @@ -22,6 +22,7 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Any, AsyncIterator, Callable, Union +from unittest.mock import patch import anyio import httpx @@ -112,17 +113,14 @@ async def connect( async def fake_stdio_server() -> AsyncIterator[Any]: yield server_streams - original = server_module.stdio_server - server_module.stdio_server = fake_stdio_server # type: ignore[assignment] - try: + # MCPServer.run_stdio_async() opens the SDK's stdio_server. + with patch("mcp.server.mcpserver.server.stdio_server", fake_stdio_server): async with anyio.create_task_group() as tg: tg.start_soon(lambda: server_module.serve(**serve_kwargs)) async with ClientSession(*client_streams) as session: init = await session.initialize() yield session, init tg.cancel_scope.cancel() - finally: - server_module.stdio_server = original @pytest.fixture diff --git a/src/fetch/tests/test_eras.py b/src/fetch/tests/test_eras.py index 88169b75ac..463c00cb9a 100644 --- a/src/fetch/tests/test_eras.py +++ b/src/fetch/tests/test_eras.py @@ -63,7 +63,7 @@ async def fake_stdio_server() -> AsyncIterator[Any]: async def transport() -> AsyncIterator[TransportStreams]: yield client_streams - with patch("mcp_server_fetch.server.stdio_server", fake_stdio_server): + with patch("mcp.server.mcpserver.server.stdio_server", fake_stdio_server): async with anyio.create_task_group() as tg: tg.start_soon(lambda: serve(**serve_kwargs)) async with Client(transport(), mode=mode) as client: @@ -137,7 +137,7 @@ async def test_prompt_succeeds_in_each_era( web.add(PAGE, plain("prompted")) async with connect(mode) as client: result = await client.get_prompt("fetch", {"url": PAGE}) - assert result.description == f"Contents of {PAGE}" + assert result.description == "Fetch a URL and extract its contents as markdown" content = result.messages[0].content assert isinstance(content, TextContent) assert content.text.endswith("prompted") @@ -147,29 +147,44 @@ async def test_prompt_succeeds_in_each_era( async def test_tool_failure_is_an_is_error_result_in_each_era( web: FakeWeb, mode: str, negotiated: str ) -> None: + # Arguments that fail the tool's schema are the model's to correct, so + # MCPServer returns them as an isError result. async with connect(mode) as client: missing = await client.call_tool("fetch", {}) - unknown = await client.call_tool("no_such_tool", {}) assert missing.is_error is True - assert text_of(missing) == "Input validation error: 'url' is a required property" - assert unknown.is_error is True - assert text_of(unknown) == "Unknown tool: no_such_tool" + assert text_of(missing).startswith( + "Error executing tool fetch: 1 validation error for fetchArguments\nurl\n" + ) + + +@pytest.mark.parametrize(("mode", "negotiated"), ERAS) +async def test_unknown_tool_is_a_json_rpc_error_in_each_era( + web: FakeWeb, mode: str, negotiated: str +) -> None: + # Both spec revisions make an unknown tool a protocol error, -32602 + # (#5174), not an isError result. + async with connect(mode) as client: + with pytest.raises(MCPError) as unknown: + await client.call_tool("no_such_tool", {}) + assert unknown.value.error.code == INVALID_PARAMS + assert unknown.value.error.message == "Unknown tool: no_such_tool" @pytest.mark.parametrize(("mode", "negotiated"), ERAS) async def test_prompt_failure_is_a_json_rpc_error_in_each_era( web: FakeWeb, mode: str, negotiated: str ) -> None: - # get_prompt raises MCPError, which reaches the client as a JSON-RPC error - # rather than a result, and an undeclared method is one too. + # An unknown prompt, or a prompt call without its `url`, reaches the + # client as a JSON-RPC -32602 error rather than a result. async with connect(mode) as client: with pytest.raises(MCPError) as unknown_prompt: await client.get_prompt("no_such_prompt", {"url": PAGE}) - with pytest.raises(MCPError) as no_resources: - await client.list_resources() + with pytest.raises(MCPError) as no_url: + await client.get_prompt("fetch", {}) assert unknown_prompt.value.error.code == INVALID_PARAMS assert unknown_prompt.value.error.message == "Unknown prompt: no_such_prompt" - assert no_resources.value.error.code == METHOD_NOT_FOUND + assert no_url.value.error.code == INVALID_PARAMS + assert no_url.value.error.message == "URL is required" async def test_ping_is_served_only_in_the_legacy_era(web: FakeWeb) -> None: diff --git a/src/fetch/tests/test_protocol.py b/src/fetch/tests/test_protocol.py index 2c00dc2760..5e9f0b2c6b 100644 --- a/src/fetch/tests/test_protocol.py +++ b/src/fetch/tests/test_protocol.py @@ -90,6 +90,13 @@ async def call(session: ClientSession, arguments: dict[str, Any]) -> CallToolRes return await session.call_tool("fetch", arguments) +# MCPServer prefixes the message of a ToolError the tool raises (#5174). +TOOL_ERROR = "Error executing tool fetch: " +# MCPServer answers prompts/get with the prompt's own description, the same +# for every call, so it no longer names the URL or the failure (#5174). +PROMPT_DESCRIPTION = "Fetch a URL and extract its contents as markdown" + + # -------------------------------------------------------------------------- # Initialization # -------------------------------------------------------------------------- @@ -99,9 +106,19 @@ async def test_initialize_advertises_tools_and_prompts(web: FakeWeb) -> None: async with connect() as (_, init): data = wire(init) assert data["serverInfo"]["name"] == "mcp-fetch" - assert "tools" in data["capabilities"] - assert "prompts" in data["capabilities"] - assert "resources" not in data["capabilities"] + assert data["capabilities"]["tools"] == {"listChanged": False} + assert data["capabilities"]["prompts"] == {"listChanged": False} + # MCPServer always serves resources/list; fetch registers none (#5174). + assert data["capabilities"]["resources"] == { + "subscribe": False, + "listChanged": False, + } + + +async def test_resources_list_is_empty(web: FakeWeb) -> None: + async with connect() as (session, _): + result = await session.list_resources() + assert result.resources == [] async def test_server_version_is_the_package_version(web: FakeWeb) -> None: @@ -136,8 +153,9 @@ async def test_list_tools_wire_shape(web: FakeWeb) -> None: "idempotentHint": True, "openWorldHint": True, } + # MCPServer derives the schema from the tool function's signature (#5174), + # so the model is named after the tool and carries no description. assert tool["inputSchema"] == { - "description": "Parameters for fetching a URL.", "properties": { "url": { "description": "URL to fetch", @@ -171,7 +189,7 @@ async def test_list_tools_wire_shape(web: FakeWeb) -> None: }, }, "required": ["url"], - "title": "Fetch", + "title": "fetchArguments", "type": "object", } @@ -196,30 +214,36 @@ async def test_call_with_only_url_applies_defaults(web: FakeWeb) -> None: @pytest.mark.parametrize( - ("arguments", "offending"), + ("arguments", "field"), [ - ({}, "'url'"), - ({"url": ""}, "''"), - ({"url": 5}, "5"), + ({}, "url"), + ({"url": ""}, "url"), + ({"url": 5}, "url"), # #1624: the bounds are minimum 1 and maximum 999999, so the rejected # values are the same as under the old exclusive 0 and 1000000. - ({"url": PAGE, "max_length": 0}, "0"), - ({"url": PAGE, "max_length": 1000000}, "1000000"), - ({"url": PAGE, "start_index": -1}, "-1"), - ({"url": PAGE, "raw": "yes"}, "'yes'"), + ({"url": PAGE, "max_length": 0}, "max_length"), + ({"url": PAGE, "max_length": 1000000}, "max_length"), + ({"url": PAGE, "start_index": -1}, "start_index"), + ({"url": PAGE, "raw": "yes"}, "raw"), + # Strict: a string is not coerced to the integer or boolean the + # schema declares. + ({"url": PAGE, "max_length": "10"}, "max_length"), + ({"url": PAGE, "raw": "true"}, "raw"), ], ) async def test_schema_violations_are_rejected_by_call_tool( - web: FakeWeb, arguments: dict[str, Any], offending: str + web: FakeWeb, arguments: dict[str, Any], field: str ) -> None: - # call_tool validates against inputSchema before the tool runs. The rest - # of the message is jsonschema's wording, which varies by version. + # MCPServer validates the arguments against the tool's signature before + # the tool runs and returns pydantic's report as an isError result, naming + # the field. The rest is pydantic's wording, which varies by version. async with connect() as (session, _): result = await call(session, arguments) assert wire(result)["isError"] is True text = text_of(result) - assert text.startswith("Input validation error: ") - assert offending in text + assert text.startswith( + f"{TOOL_ERROR}1 validation error for fetchArguments\n{field}\n" + ) assert web.requests == [] @@ -237,26 +261,29 @@ async def test_max_length_bounds_are_inclusive(web: FakeWeb, max_length: int) -> @pytest.mark.parametrize("url", ["not a url", "example.com/page", "http://"]) async def test_invalid_url_is_rejected_by_pydantic(web: FakeWeb, url: str) -> None: - # jsonschema does not check `format: uri`, so these reach Fetch(**arguments) - # and its ValidationError (a ValueError) becomes an isError result. + # The `url` parameter is an AnyUrl, so pydantic rejects these before the + # tool runs, as an isError result. async with connect() as (session, _): result = await call(session, {"url": url}) assert wire(result)["isError"] is True - text = text_of(result) - assert "1 validation error for Fetch" in text - assert "url" in text + assert text_of(result).startswith( + f"{TOOL_ERROR}1 validation error for fetchArguments\nurl\n" + ) assert web.requests == [] -async def test_unknown_tool_name_is_rejected_without_fetching(web: FakeWeb) -> None: - # call_tool skips schema validation for a tool it has not listed, so the - # name check in run_tool is what stops an unknown name from fetching. +async def test_unknown_tool_name_is_a_jsonrpc_error(web: FakeWeb) -> None: + # Both spec revisions make an unknown tool a protocol error, -32602 + # (#5174); nothing is fetched. web.add(ROBOTS, plain("", status=404)) web.add(PAGE, plain("fetched anyway")) async with connect() as (session, _): - result = await session.call_tool("nope", {"url": PAGE}) - assert wire(result)["isError"] is True - assert text_of(result) == "Unknown tool: nope" + with pytest.raises(MCPError) as excinfo: + await session.call_tool("nope", {"url": PAGE}) + assert wire(excinfo.value.error) == { + "code": INVALID_PARAMS, + "message": "Unknown tool: nope", + } assert web.requests == [] @@ -265,9 +292,10 @@ async def test_unknown_tool_name_is_rejected_before_argument_validation( ) -> None: # An unknown name is reported as such, not as a missing `url` argument. async with connect() as (session, _): - result = await session.call_tool("nope", {}) - assert wire(result)["isError"] is True - assert text_of(result) == "Unknown tool: nope" + with pytest.raises(MCPError) as excinfo: + await session.call_tool("nope", {}) + assert excinfo.value.error.code == INVALID_PARAMS + assert excinfo.value.error.message == "Unknown tool: nope" assert web.requests == [] @@ -297,7 +325,7 @@ async def test_robots_401_403_blocks_fetch(web: FakeWeb, status: int) -> None: "content": [ { "type": "text", - "text": f"When fetching robots.txt ({ROBOTS}), received status {status} " + "text": f"{TOOL_ERROR}When fetching robots.txt ({ROBOTS}), received status {status} " "so assuming that autonomous fetching is not allowed, the user can " "try manually fetching by using the fetch prompt", } @@ -318,7 +346,7 @@ def refuse(request: httpx.Request) -> httpx.Response: "content": [ { "type": "text", - "text": f"Failed to fetch robots.txt {ROBOTS} due to a connection issue", + "text": f"{TOOL_ERROR}Failed to fetch robots.txt {ROBOTS} due to a connection issue", } ], "isError": True, @@ -333,7 +361,7 @@ async def test_robots_disallow_blocks_fetch(web: FakeWeb) -> None: result = await call(session, {"url": PAGE}) assert wire(result)["isError"] is True assert text_of(result) == ( - f"The sites robots.txt ({ROBOTS}), specifies that autonomous fetching of this page is not allowed, " + f"{TOOL_ERROR}The sites robots.txt ({ROBOTS}), specifies that autonomous fetching of this page is not allowed, " f"{DEFAULT_USER_AGENT_AUTONOMOUS}\n" f"{PAGE}" f"\n{robots}\n\n" @@ -531,7 +559,10 @@ async def test_http_error_status(web: FakeWeb, status: int) -> None: result = await call(session, {"url": PAGE}) assert wire(result) == { "content": [ - {"type": "text", "text": f"Failed to fetch {PAGE} - status code {status}"} + { + "type": "text", + "text": f"{TOOL_ERROR}Failed to fetch {PAGE} - status code {status}", + } ], "isError": True, } @@ -548,7 +579,7 @@ def refuse(request: httpx.Request) -> httpx.Response: "content": [ { "type": "text", - "text": f"Failed to fetch {PAGE}: ConnectError('connection refused')", + "text": f"{TOOL_ERROR}Failed to fetch {PAGE}: ConnectError('connection refused')", } ], "isError": True, @@ -670,7 +701,8 @@ async def test_redirect_to_private_address_is_refused(web: FakeWeb) -> None: "content": [ { "type": "text", - "text": refused(metadata, "169.254.169.254", "169.254.169.254"), + "text": TOOL_ERROR + + refused(metadata, "169.254.169.254", "169.254.169.254"), } ], "isError": True, @@ -715,7 +747,7 @@ async def test_private_address_is_refused_before_any_request( # httpx normalizes the host (::ffff:127.0.0.1 becomes ::ffff:7f00:1), so # only the shape of the message is pinned here. text = text_of(result) - assert text.startswith("Refused to fetch http://") + assert text.startswith(f"{TOOL_ERROR}Refused to fetch http://") assert "/robots.txt: " in text assert "which is not a public address" in text assert web.requests == [] @@ -746,7 +778,9 @@ async def test_hostname_resolving_to_a_private_address_is_refused( url = "https://intranet.example.com/" async with connect(ignore_robots_txt=True) as (session, _): result = await call(session, {"url": url}) - assert text_of(result) == refused(url, "intranet.example.com", "10.1.2.3") + assert text_of(result) == TOOL_ERROR + refused( + url, "intranet.example.com", "10.1.2.3" + ) assert web.requests == [] @@ -765,7 +799,7 @@ async def test_robots_redirect_to_private_address_is_refused(web: FakeWeb) -> No web.add(ROBOTS, httpx.Response(302, headers={"location": internal})) async with connect() as (session, _): result = await call(session, {"url": PAGE}) - assert text_of(result) == refused(internal, "127.0.0.1", "127.0.0.1") + assert text_of(result) == TOOL_ERROR + refused(internal, "127.0.0.1", "127.0.0.1") assert web.urls() == [ROBOTS] @@ -793,7 +827,7 @@ async def test_allow_private_ips_follows_redirect_to_private_address( prompt = await session.get_prompt("fetch", {"url": metadata}) assert wire(result)["isError"] is False assert text_of(result) == f"{RAW_PREFIX_PLAIN}Contents of {PAGE}:\ninstance-secret" - assert prompt.description == f"Contents of {metadata}" + assert prompt.description == PROMPT_DESCRIPTION # robots.txt is only consulted for the original host, never the target. assert web.urls() == [ROBOTS, PAGE, metadata, metadata] # With the guard off, nothing is resolved by the server. @@ -807,7 +841,9 @@ async def test_guard_uses_the_real_resolver() -> None: async with connect(ignore_robots_txt=True) as (session, _): result = await call(session, {"url": url}) assert wire(result)["isError"] is True - assert text_of(result).startswith(f"Refused to fetch {url}: localhost resolves to ") + assert text_of(result).startswith( + f"{TOOL_ERROR}Refused to fetch {url}: localhost resolves to " + ) async def test_timeouts_are_hard_coded(web: FakeWeb) -> None: @@ -876,7 +912,7 @@ async def test_bad_proxy_config_is_a_tool_error(ignore_robots_txt: bool) -> None "content": [ { "type": "text", - "text": "Failed to set up the HTTP client, check the proxy " + "text": f"{TOOL_ERROR}Failed to set up the HTTP client, check the proxy " "configuration (--proxy-url or the HTTP_PROXY, HTTPS_PROXY and " "ALL_PROXY environment variables): " "Unknown scheme for proxy URL URL('ftp://proxy.example.com')", @@ -915,7 +951,7 @@ async def test_get_prompt_fetches_with_manual_agent_and_no_robots( async with connect() as (session, _): result = await session.get_prompt("fetch", {"url": PAGE}) assert wire(result) == { - "description": f"Contents of {PAGE}", + "description": PROMPT_DESCRIPTION, "messages": [ { "role": "user", @@ -950,7 +986,7 @@ async def test_get_prompt_fetch_failure_is_a_prompt_message(web: FakeWeb) -> Non async with connect() as (session, _): result = await session.get_prompt("fetch", {"url": PAGE}) assert wire(result) == { - "description": f"Failed to fetch {PAGE}", + "description": PROMPT_DESCRIPTION, "messages": [ { "role": "user", @@ -1008,7 +1044,7 @@ async def test_get_prompt_does_not_validate_the_url( async with connect() as (session, _): result = await session.get_prompt("fetch", {"url": "not a url"}) data = wire(result) - assert data["description"] == "Failed to fetch not a url" + assert data["description"] == PROMPT_DESCRIPTION assert data["messages"][0]["content"]["text"].startswith( "Failed to fetch not a url: UnsupportedProtocol(" ) @@ -1051,7 +1087,7 @@ async def test_server_discover_answers_the_2026_07_28_era() -> None: async def fake_stdio_server() -> AsyncIterator[Any]: yield server_streams - with patch("mcp_server_fetch.server.stdio_server", fake_stdio_server): + with patch("mcp.server.mcpserver.server.stdio_server", fake_stdio_server): async with anyio.create_task_group() as tg: tg.start_soon(serve) await client_write.send(SessionMessage(discover)) @@ -1066,6 +1102,7 @@ async def fake_stdio_server() -> AsyncIterator[Any]: "result": { "capabilities": { "prompts": {"listChanged": False}, + "resources": {"listChanged": False, "subscribe": False}, "tools": {"listChanged": False}, }, "supportedVersions": ["2026-07-28"], diff --git a/src/fetch/uv.lock b/src/fetch/uv.lock index 10645c76bb..720939bade 100644 --- a/src/fetch/uv.lock +++ b/src/fetch/uv.lock @@ -749,7 +749,6 @@ version = "2026.10.18" source = { editable = "." } dependencies = [ { name = "httpx", extra = ["socks"] }, - { name = "jsonschema" }, { name = "markdownify" }, { name = "mcp" }, { name = "protego" }, @@ -770,7 +769,6 @@ dev = [ [package.metadata] requires-dist = [ { name = "httpx", extras = ["socks"], specifier = ">=0.27" }, - { name = "jsonschema", specifier = ">=4.20.0" }, { name = "markdownify", specifier = ">=0.13.1" }, { name = "mcp", specifier = ">=2.2,<3" }, { name = "protego", specifier = ">=0.6.2" }, From 916cdc9c0f753cc7940d546a9d2dac39e090acce Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 19 Oct 2026 01:31:03 -0400 Subject: [PATCH 111/117] feat(git): build the git server on MCPServer; unknown tool is -32602 The git server moves from the SDK's low-level Server, with its SDK v1 compatibility wrapper (hand-rolled jsonschema validation, every exception folded into an isError result), to the high-level MCPServer (#5174). Each tool is a typed function registered with @server.tool; its name, description, arguments, defaults, descriptions and annotations are unchanged. Only each input schema's top-level title differs, now derived from the function name. - An unknown tool is a JSON-RPC -32602 protocol error in both eras, as the spec requires. MCPServer still answers one with isError (python-sdk#3659), so a middleware rejects it before the lookup, which also keeps #4994's rule that the name is checked before repo_path. - Anticipated failures (the guards, GitError, gitdb's ODBError, and the builtins GitPython raises for failed lookups) are raised as ToolError, so the model still reads their message, now prefixed by MCPServer with "Error executing tool : ". Anything else is a crash, reported generically. - subscriptions=False: the tool list never changes, so the 2026-07-28 capabilities advertise no listChanged. MCPServer always serves empty prompt and resource lists, so those capabilities now appear. - Argument validation errors are pydantic's, from MCPServer. - jsonschema is no longer a direct dependency. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- src/git/README.md | 7 +- src/git/pyproject.toml | 1 - src/git/src/mcp_server_git/server.py | 680 +++++++++++------------- src/git/tests/conftest.py | 10 +- src/git/tests/snapshots/list_tools.json | 48 +- src/git/tests/test_entrypoints.py | 4 +- src/git/tests/test_eras.py | 47 +- src/git/tests/test_protocol.py | 196 ++++--- src/git/tests/test_server.py | 2 +- src/git/uv.lock | 2 - 10 files changed, 511 insertions(+), 486 deletions(-) diff --git a/src/git/README.md b/src/git/README.md index 43a9d132a2..11cdfe59a4 100644 --- a/src/git/README.md +++ b/src/git/README.md @@ -8,7 +8,7 @@ A Model Context Protocol server for Git repository interaction and automation. T Source: https://github.com/modelcontextprotocol/servers/tree/main/src/git -Requires MCP Python SDK 2.x (`mcp>=2.2,<3`). +Requires MCP Python SDK 2.x (`mcp>=2.2,<3`). The server is built on the SDK's high-level `MCPServer`: each tool is a typed Python function, and the SDK derives its input schema from the signature. Please note that mcp-server-git is currently in early development. The functionality and available tools are subject to change and expansion as we continue to develop and improve the server. @@ -106,6 +106,11 @@ The server speaks both eras of the Model Context Protocol over stdio, with nothi Roots is deprecated in 2026-07-28, and this server never asks a client for Roots in either era: repository access comes from `--repository` alone, or, with no `--repository`, from each tool call's `repo_path`. Methods the 2026-07-28 specification removes, such as `ping`, are answered only on a 2025-11-25 connection. +### Errors + +- **A tool that fails** returns a result with `isError: true` whose text is `Error executing tool : `, so the model can read it and correct the call. This covers git errors (an unknown revision, a failed command), a `repo_path` that is not a repository or is outside `--repository`, the flag-injection guards, and arguments that do not match the tool's input schema. +- **An unknown tool name** is a protocol error, JSON-RPC `-32602` (`Unknown tool: `), as the specification requires in both eras. Before 2.0.0 it was an `isError` result. + ## Installation ### Using uv (recommended) diff --git a/src/git/pyproject.toml b/src/git/pyproject.toml index 915e47a906..a88372750e 100644 --- a/src/git/pyproject.toml +++ b/src/git/pyproject.toml @@ -18,7 +18,6 @@ classifiers = [ dependencies = [ "click>=8.1.7", "gitpython>=3.1.60", - "jsonschema>=4.20.0", "mcp>=2.2,<3", "pydantic>=2.0.0", ] diff --git a/src/git/src/mcp_server_git/server.py b/src/git/src/mcp_server_git/server.py index 6d1ccfc780..8f6fc8a03d 100644 --- a/src/git/src/mcp_server_git/server.py +++ b/src/git/src/mcp_server_git/server.py @@ -3,35 +3,37 @@ # the flag-injection guards live beside the handlers because every tool call # must pass them before any git command runs. # -# Built on the MCP Python SDK v2's low-level `Server` (#4851). That SDK no -# longer validates tool arguments or folds tool exceptions into `isError` -# results, so `call_tool` does both with SDK v1's messages, keeping tool -# results as they were on v1. `serve()` serves both protocol eras (#4853): a -# 2025-11-25 client through the initialize handshake, and a 2026-07-28 client -# sessionlessly. No handler reads the session or calls back to the client -# (roots, sampling, elicitation, logging), so nothing differs between them. +# Built on the MCP Python SDK's high-level `MCPServer` (#5174), the API these +# reference servers are meant to teach: each tool is a typed function, and the +# SDK derives its input schema from the signature and validates arguments +# against it. A failure the model can act on (a git error, a path outside the +# repository, a guard's rejection) is raised as `ToolError`, which the SDK +# returns as an `isError` result carrying the message. An unknown tool name is +# a protocol error, JSON-RPC -32602, as both spec revisions require. +# +# `serve()` serves both protocol eras (#4853): a 2025-11-25 client through the +# initialize handshake, and a 2026-07-28 client sessionlessly. No tool reads +# the session or calls back to the client (roots, sampling, elicitation, +# logging), so nothing differs between them. import logging +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from enum import Enum +from functools import wraps from importlib.metadata import version from pathlib import Path -from typing import Any, Optional -import jsonschema -from mcp.server import Server, ServerRequestContext -from mcp.server.stdio import stdio_server -from mcp.types import ( - CallToolRequestParams, - CallToolResult, - ContentBlock, - ListToolsResult, - PaginatedRequestParams, - TextContent, - Tool, - ToolAnnotations, -) -from enum import Enum +from typing import Annotated, Any, Optional, ParamSpec + import git from git.exc import BadName -from pydantic import BaseModel, Field +from gitdb.exc import ODBError +from mcp.server.context import CallNext, HandlerResult, ServerRequestContext +from mcp.server.mcpserver import MCPServer +from mcp.server.mcpserver.exceptions import ToolError +from mcp.shared.exceptions import MCPError +from mcp.types import INVALID_PARAMS, ToolAnnotations +from pydantic import Field # The version this server reports in serverInfo, read from the installed # distribution's metadata (pyproject.toml) so it cannot drift from the @@ -54,88 +56,6 @@ def __str__(self) -> str: return str(self.args[0]) -class GitStatus(BaseModel): - repo_path: str - - -class GitDiffUnstaged(BaseModel): - repo_path: str - context_lines: int = DEFAULT_CONTEXT_LINES - - -class GitDiffStaged(BaseModel): - repo_path: str - context_lines: int = DEFAULT_CONTEXT_LINES - - -class GitDiff(BaseModel): - repo_path: str - target: str - context_lines: int = DEFAULT_CONTEXT_LINES - - -class GitCommit(BaseModel): - repo_path: str - message: str - - -class GitAdd(BaseModel): - repo_path: str - files: list[str] = Field(..., min_length=1) - - -class GitReset(BaseModel): - repo_path: str - - -class GitLog(BaseModel): - repo_path: str - max_count: int = 10 - start_timestamp: Optional[str] = Field( - None, - description="Start timestamp for filtering commits. Accepts: ISO 8601 format (e.g., '2024-01-15T14:30:25'), relative dates (e.g., '2 weeks ago', 'yesterday'), or absolute dates (e.g., '2024-01-15', 'Jan 15 2024')", - ) - end_timestamp: Optional[str] = Field( - None, - description="End timestamp for filtering commits. Accepts: ISO 8601 format (e.g., '2024-01-15T14:30:25'), relative dates (e.g., '2 weeks ago', 'yesterday'), or absolute dates (e.g., '2024-01-15', 'Jan 15 2024')", - ) - - -class GitCreateBranch(BaseModel): - repo_path: str - branch_name: str - base_branch: str | None = None - - -class GitCheckout(BaseModel): - repo_path: str - branch_name: str - - -class GitShow(BaseModel): - repo_path: str - revision: str - - -class GitBranch(BaseModel): - repo_path: str = Field( - ..., - description="The path to the Git repository.", - ) - branch_type: str = Field( - ..., - description="Whether to list local branches ('local'), remote branches ('remote') or all branches('all').", - ) - contains: Optional[str] = Field( - None, - description="The commit sha that branch should contain. Do not pass anything to this param if no commit sha is specified", - ) - not_contains: Optional[str] = Field( - None, - description="The commit sha that branch should NOT contain. Do not pass anything to this param if no commit sha is specified", - ) - - class GitTools(str, Enum): STATUS = "git_status" DIFF_UNSTAGED = "git_diff_unstaged" @@ -486,11 +406,280 @@ def git_branch( return branch_info -def tool_error(message: str) -> CallToolResult: - """A tool call that failed, as an `isError` result the model can read.""" - return CallToolResult( - content=[TextContent(type="text", text=message)], is_error=True +# The failures a tool reports to the model as a `ToolError`, each with the +# message it was raised with: the guards' `ValueError`s and +# `FlagInjectionError`s, git and GitPython errors (`GitError`: a failed +# command, a missing or invalid repository), gitdb's lookup errors (`ODBError`: +# `BadName` for an unknown revision, `BadObject`), and the builtins GitPython +# raises for a failed lookup: `KeyError` for a path not in a tree, `IndexError` +# for a missing ref in `repo.references[...]`, `TypeError` for +# `active_branch` on a detached HEAD, `OSError` for an unreadable path. +# Anything else escapes as a crash, which the SDK reports only as +# "Error executing tool ", logging the traceback. +ANTICIPATED_ERRORS = ( + ValueError, + KeyError, + IndexError, + TypeError, + OSError, + git.GitError, + ODBError, +) + + +@contextmanager +def reported_to_model() -> Iterator[None]: + """Turn an anticipated failure into a `ToolError` with the same message.""" + try: + yield + except ANTICIPATED_ERRORS as e: + raise ToolError(str(e)) from e + + +_P = ParamSpec("_P") + + +def reports_failures(fn: Callable[_P, str]) -> Callable[_P, str]: + """Run a tool function inside `reported_to_model()`. + + `wraps` keeps the signature, which is what `MCPServer` reads to build the + tool's input schema. + """ + + @wraps(fn) + def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> str: + with reported_to_model(): + return fn(*args, **kwargs) + + return wrapper + + +READ_ONLY = ToolAnnotations( + read_only_hint=True, + destructive_hint=False, + idempotent_hint=True, + open_world_hint=False, +) + + +def writes(*, destructive: bool, idempotent: bool) -> ToolAnnotations: + return ToolAnnotations( + read_only_hint=False, + destructive_hint=destructive, + idempotent_hint=idempotent, + open_world_hint=False, + ) + + +TIMESTAMP_FORMATS = ( + "Accepts: ISO 8601 format (e.g., '2024-01-15T14:30:25'), relative dates " + "(e.g., '2 weeks ago', 'yesterday'), or absolute dates (e.g., '2024-01-15', " + "'Jan 15 2024')" +) + + +def create_server(repository: Path | None) -> MCPServer: + """The git server, confined to `repository` when one is given.""" + + async def reject_unknown_tools( + ctx: ServerRequestContext[Any, Any], call_next: CallNext + ) -> HandlerResult: + # An unknown tool is a protocol error, JSON-RPC -32602 (spec + # server/tools, "Error Handling"). MCPServer still answers one with an + # isError result (python-sdk#3659), so the name is checked here, + # before MCPServer looks it up; remove this once MCPServer does it + # itself. It also runs before argument validation, so a call without + # repo_path still reports the unknown tool (#4994). + if ctx.method == "tools/call" and ctx.params is not None: + name = ctx.params.get("name") + if isinstance(name, str) and name not in { + tool.name for tool in await server.list_tools() + }: + raise MCPError(code=INVALID_PARAMS, message=f"Unknown tool: {name}") + return await call_next(ctx) + + server = MCPServer( + "mcp-git", + version=SERVER_VERSION, + middleware=[reject_unknown_tools], + # The tool list never changes, so `subscriptions/listen` is not + # served, and the 2026-07-28 capabilities say so (listChanged false). + subscriptions=False, + ) + + def open_repo(repo_path: str) -> git.Repo: + path = Path(repo_path) + validate_repo_path(path, repository) + return git.Repo(path) + + @server.tool( + name=GitTools.STATUS.value, + description="Shows the working tree status", + annotations=READ_ONLY, + structured_output=False, + ) + @reports_failures + def status(repo_path: str) -> str: + return f"Repository status:\n{git_status(open_repo(repo_path))}" + + @server.tool( + name=GitTools.DIFF_UNSTAGED.value, + description="Shows changes in the working directory that are not yet staged", + annotations=READ_ONLY, + structured_output=False, + ) + @reports_failures + def diff_unstaged( + repo_path: str, context_lines: int = DEFAULT_CONTEXT_LINES + ) -> str: + diff = git_diff_unstaged(open_repo(repo_path), context_lines) + return f"Unstaged changes:\n{diff}" + + @server.tool( + name=GitTools.DIFF_STAGED.value, + description="Shows changes that are staged for commit", + annotations=READ_ONLY, + structured_output=False, + ) + @reports_failures + def diff_staged(repo_path: str, context_lines: int = DEFAULT_CONTEXT_LINES) -> str: + diff = git_diff_staged(open_repo(repo_path), context_lines) + return f"Staged changes:\n{diff}" + + @server.tool( + name=GitTools.DIFF.value, + description="Shows differences between branches or commits", + annotations=READ_ONLY, + structured_output=False, + ) + @reports_failures + def diff( + repo_path: str, target: str, context_lines: int = DEFAULT_CONTEXT_LINES + ) -> str: + result = git_diff(open_repo(repo_path), target, context_lines) + return f"Diff with {target}:\n{result}" + + @server.tool( + name=GitTools.COMMIT.value, + description="Records changes to the repository", + annotations=writes(destructive=False, idempotent=False), + structured_output=False, + ) + @reports_failures + def commit(repo_path: str, message: str) -> str: + return git_commit(open_repo(repo_path), message) + + @server.tool( + name=GitTools.ADD.value, + description="Adds file contents to the staging area", + annotations=writes(destructive=False, idempotent=True), + structured_output=False, + ) + @reports_failures + def add(repo_path: str, files: Annotated[list[str], Field(min_length=1)]) -> str: + return git_add(open_repo(repo_path), files) + + @server.tool( + name=GitTools.RESET.value, + description="Unstages all staged changes", + annotations=writes(destructive=True, idempotent=True), + structured_output=False, + ) + @reports_failures + def reset(repo_path: str) -> str: + return git_reset(open_repo(repo_path)) + + @server.tool( + name=GitTools.LOG.value, + description="Shows the commit logs", + annotations=READ_ONLY, + structured_output=False, + ) + @reports_failures + def log( + repo_path: str, + max_count: int = 10, + start_timestamp: Annotated[ + Optional[str], + Field( + description=f"Start timestamp for filtering commits. {TIMESTAMP_FORMATS}" + ), + ] = None, + end_timestamp: Annotated[ + Optional[str], + Field( + description=f"End timestamp for filtering commits. {TIMESTAMP_FORMATS}" + ), + ] = None, + ) -> str: + entries = git_log( + open_repo(repo_path), max_count, start_timestamp, end_timestamp + ) + return "Commit history:\n" + "\n".join(entries) + + @server.tool( + name=GitTools.CREATE_BRANCH.value, + description="Creates a new branch from an optional base branch", + annotations=writes(destructive=False, idempotent=False), + structured_output=False, + ) + @reports_failures + def create_branch( + repo_path: str, branch_name: str, base_branch: str | None = None + ) -> str: + return git_create_branch(open_repo(repo_path), branch_name, base_branch) + + @server.tool( + name=GitTools.CHECKOUT.value, + description="Switches branches", + annotations=writes(destructive=False, idempotent=False), + structured_output=False, ) + @reports_failures + def checkout(repo_path: str, branch_name: str) -> str: + return git_checkout(open_repo(repo_path), branch_name) + + @server.tool( + name=GitTools.SHOW.value, + description="Shows the contents of a commit, or of a file or directory given as :", + annotations=READ_ONLY, + structured_output=False, + ) + @reports_failures + def show(repo_path: str, revision: str) -> str: + return git_show(open_repo(repo_path), revision) + + @server.tool( + name=GitTools.BRANCH.value, + description="List Git branches", + annotations=READ_ONLY, + structured_output=False, + ) + @reports_failures + def branch( + repo_path: Annotated[str, Field(description="The path to the Git repository.")], + branch_type: Annotated[ + str, + Field( + description="Whether to list local branches ('local'), remote branches ('remote') or all branches('all')." + ), + ], + contains: Annotated[ + Optional[str], + Field( + description="The commit sha that branch should contain. Do not pass anything to this param if no commit sha is specified" + ), + ] = None, + not_contains: Annotated[ + Optional[str], + Field( + description="The commit sha that branch should NOT contain. Do not pass anything to this param if no commit sha is specified" + ), + ] = None, + ) -> str: + return git_branch(open_repo(repo_path), branch_type, contains, not_contains) + + return server async def serve(repository: Path | None) -> None: @@ -514,270 +703,9 @@ async def serve(repository: Path | None) -> None: repository = root logger.info(f"Using repository at {repository}") - tools = [ - Tool( - name=GitTools.STATUS, - description="Shows the working tree status", - input_schema=GitStatus.model_json_schema(), - annotations=ToolAnnotations( - read_only_hint=True, - destructive_hint=False, - idempotent_hint=True, - open_world_hint=False, - ), - ), - Tool( - name=GitTools.DIFF_UNSTAGED, - description="Shows changes in the working directory that are not yet staged", - input_schema=GitDiffUnstaged.model_json_schema(), - annotations=ToolAnnotations( - read_only_hint=True, - destructive_hint=False, - idempotent_hint=True, - open_world_hint=False, - ), - ), - Tool( - name=GitTools.DIFF_STAGED, - description="Shows changes that are staged for commit", - input_schema=GitDiffStaged.model_json_schema(), - annotations=ToolAnnotations( - read_only_hint=True, - destructive_hint=False, - idempotent_hint=True, - open_world_hint=False, - ), - ), - Tool( - name=GitTools.DIFF, - description="Shows differences between branches or commits", - input_schema=GitDiff.model_json_schema(), - annotations=ToolAnnotations( - read_only_hint=True, - destructive_hint=False, - idempotent_hint=True, - open_world_hint=False, - ), - ), - Tool( - name=GitTools.COMMIT, - description="Records changes to the repository", - input_schema=GitCommit.model_json_schema(), - annotations=ToolAnnotations( - read_only_hint=False, - destructive_hint=False, - idempotent_hint=False, - open_world_hint=False, - ), - ), - Tool( - name=GitTools.ADD, - description="Adds file contents to the staging area", - input_schema=GitAdd.model_json_schema(), - annotations=ToolAnnotations( - read_only_hint=False, - destructive_hint=False, - idempotent_hint=True, - open_world_hint=False, - ), - ), - Tool( - name=GitTools.RESET, - description="Unstages all staged changes", - input_schema=GitReset.model_json_schema(), - annotations=ToolAnnotations( - read_only_hint=False, - destructive_hint=True, - idempotent_hint=True, - open_world_hint=False, - ), - ), - Tool( - name=GitTools.LOG, - description="Shows the commit logs", - input_schema=GitLog.model_json_schema(), - annotations=ToolAnnotations( - read_only_hint=True, - destructive_hint=False, - idempotent_hint=True, - open_world_hint=False, - ), - ), - Tool( - name=GitTools.CREATE_BRANCH, - description="Creates a new branch from an optional base branch", - input_schema=GitCreateBranch.model_json_schema(), - annotations=ToolAnnotations( - read_only_hint=False, - destructive_hint=False, - idempotent_hint=False, - open_world_hint=False, - ), - ), - Tool( - name=GitTools.CHECKOUT, - description="Switches branches", - input_schema=GitCheckout.model_json_schema(), - annotations=ToolAnnotations( - read_only_hint=False, - destructive_hint=False, - idempotent_hint=False, - open_world_hint=False, - ), - ), - Tool( - name=GitTools.SHOW, - description="Shows the contents of a commit, or of a file or directory given as :", - input_schema=GitShow.model_json_schema(), - annotations=ToolAnnotations( - read_only_hint=True, - destructive_hint=False, - idempotent_hint=True, - open_world_hint=False, - ), - ), - Tool( - name=GitTools.BRANCH, - description="List Git branches", - input_schema=GitBranch.model_json_schema(), - annotations=ToolAnnotations( - read_only_hint=True, - destructive_hint=False, - idempotent_hint=True, - open_world_hint=False, - ), - ), - ] - schemas = {tool.name: tool.input_schema for tool in tools} - - async def list_tools( - ctx: ServerRequestContext, params: PaginatedRequestParams | None - ) -> ListToolsResult: - return ListToolsResult(tools=tools) - - async def run_tool(name: str, arguments: dict[str, Any]) -> list[ContentBlock]: - # Reject an unknown tool before reading its arguments, so a call - # without repo_path still reports "Unknown tool" (#4994). - if name not in {tool.value for tool in GitTools}: - raise ValueError(f"Unknown tool: {name}") - - repo_path = Path(arguments["repo_path"]) - - # Validate repo_path is within allowed repository - validate_repo_path(repo_path, repository) - - # For all commands, we need an existing repo - repo = git.Repo(repo_path) - - match name: - case GitTools.STATUS: - status = git_status(repo) - return [TextContent(type="text", text=f"Repository status:\n{status}")] - - case GitTools.DIFF_UNSTAGED: - diff = git_diff_unstaged( - repo, arguments.get("context_lines", DEFAULT_CONTEXT_LINES) - ) - return [TextContent(type="text", text=f"Unstaged changes:\n{diff}")] - - case GitTools.DIFF_STAGED: - diff = git_diff_staged( - repo, arguments.get("context_lines", DEFAULT_CONTEXT_LINES) - ) - return [TextContent(type="text", text=f"Staged changes:\n{diff}")] - - case GitTools.DIFF: - diff = git_diff( - repo, - arguments["target"], - arguments.get("context_lines", DEFAULT_CONTEXT_LINES), - ) - return [ - TextContent( - type="text", text=f"Diff with {arguments['target']}:\n{diff}" - ) - ] - - case GitTools.COMMIT: - result = git_commit(repo, arguments["message"]) - return [TextContent(type="text", text=result)] - - case GitTools.ADD: - result = git_add(repo, arguments["files"]) - return [TextContent(type="text", text=result)] - - case GitTools.RESET: - result = git_reset(repo) - return [TextContent(type="text", text=result)] - - # Update the LOG case: - case GitTools.LOG: - log = git_log( - repo, - arguments.get("max_count", 10), - arguments.get("start_timestamp"), - arguments.get("end_timestamp"), - ) - return [ - TextContent(type="text", text="Commit history:\n" + "\n".join(log)) - ] - - case GitTools.CREATE_BRANCH: - result = git_create_branch( - repo, arguments["branch_name"], arguments.get("base_branch") - ) - return [TextContent(type="text", text=result)] - - case GitTools.CHECKOUT: - result = git_checkout(repo, arguments["branch_name"]) - return [TextContent(type="text", text=result)] - - case GitTools.SHOW: - result = git_show(repo, arguments["revision"]) - return [TextContent(type="text", text=result)] - - case GitTools.BRANCH: - result = git_branch( - repo, - arguments.get("branch_type", "local"), - arguments.get("contains", None), - arguments.get("not_contains", None), - ) - return [TextContent(type="text", text=result)] - - # Unreachable: unknown names are rejected before the repo is opened. - case _: # pragma: no cover - raise ValueError(f"Unknown tool: {name}") - - async def call_tool( - ctx: ServerRequestContext, params: CallToolRequestParams - ) -> CallToolResult: - # SDK v2's low-level server neither validates arguments nor turns a - # handler exception into an isError result, both of which SDK v1 did. - # Both are done here, with v1's messages, so tool results are unchanged. - arguments = params.arguments or {} - schema = schemas.get(params.name) - if schema is not None: - try: - jsonschema.validate(instance=arguments, schema=schema) - except jsonschema.ValidationError as e: - return tool_error(f"Input validation error: {e.message}") - try: - return CallToolResult(content=await run_tool(params.name, arguments)) - except Exception as e: - return tool_error(str(e)) - - server = Server( - "mcp-git", - version=SERVER_VERSION, - on_list_tools=list_tools, - on_call_tool=call_tool, - ) - options = server.create_initialization_options() - async with stdio_server() as (read_stream, write_stream): - # Both eras (#4853): Server.run()'s dual-era loop lets the client's - # first request decide. `initialize` gets the 2025-11-25 handshake; - # `server/discover` or a request carrying a 2026-07-28 envelope gets - # the modern, sessionless era, with its required result fields - # (resultType, ttlMs, cacheScope) filled in by the SDK. - await server.run(read_stream, write_stream, options) + # Both eras (#4853): the SDK's dual-era loop lets the client's first + # request decide. `initialize` gets the 2025-11-25 handshake; + # `server/discover` or a request carrying a 2026-07-28 envelope gets the + # modern, sessionless era, with its required result fields (resultType, + # ttlMs, cacheScope) filled in by the SDK. + await create_server(repository).run_stdio_async() diff --git a/src/git/tests/conftest.py b/src/git/tests/conftest.py index b68086bdc0..4f62987ec3 100644 --- a/src/git/tests/conftest.py +++ b/src/git/tests/conftest.py @@ -116,7 +116,7 @@ async def connect( async def fake_stdio_server() -> AsyncIterator[Any]: yield server_streams - with mock.patch("mcp_server_git.server.stdio_server", fake_stdio_server): + with mock.patch("mcp.server.mcpserver.server.stdio_server", fake_stdio_server): async with anyio.create_task_group() as tg: tg.start_soon(serve, repository) async with ClientSession( @@ -141,3 +141,11 @@ def wire(model: BaseModel) -> dict[str, Any]: def text_result(text: str, *, is_error: bool = False) -> dict[str, Any]: """The wire shape of a tool result holding a single text block.""" return {"content": [{"type": "text", "text": text}], "isError": is_error} + + +def tool_error(tool: str, message: str) -> dict[str, Any]: + """The wire shape of a tool's `ToolError`, as MCPServer reports it. + + MCPServer prefixes the tool's own message with the tool's name. + """ + return text_result(f"Error executing tool {tool}: {message}", is_error=True) diff --git a/src/git/tests/snapshots/list_tools.json b/src/git/tests/snapshots/list_tools.json index 4848ea05c4..3f922345ff 100644 --- a/src/git/tests/snapshots/list_tools.json +++ b/src/git/tests/snapshots/list_tools.json @@ -13,8 +13,8 @@ "required": [ "repo_path" ], - "title": "GitStatus", - "type": "object" + "type": "object", + "title": "statusArguments" }, "annotations": { "readOnlyHint": true, @@ -41,8 +41,8 @@ "required": [ "repo_path" ], - "title": "GitDiffUnstaged", - "type": "object" + "type": "object", + "title": "diff_unstagedArguments" }, "annotations": { "readOnlyHint": true, @@ -69,8 +69,8 @@ "required": [ "repo_path" ], - "title": "GitDiffStaged", - "type": "object" + "type": "object", + "title": "diff_stagedArguments" }, "annotations": { "readOnlyHint": true, @@ -102,8 +102,8 @@ "repo_path", "target" ], - "title": "GitDiff", - "type": "object" + "type": "object", + "title": "diffArguments" }, "annotations": { "readOnlyHint": true, @@ -130,8 +130,8 @@ "repo_path", "message" ], - "title": "GitCommit", - "type": "object" + "type": "object", + "title": "commitArguments" }, "annotations": { "readOnlyHint": false, @@ -162,8 +162,8 @@ "repo_path", "files" ], - "title": "GitAdd", - "type": "object" + "type": "object", + "title": "addArguments" }, "annotations": { "readOnlyHint": false, @@ -185,8 +185,8 @@ "required": [ "repo_path" ], - "title": "GitReset", - "type": "object" + "type": "object", + "title": "resetArguments" }, "annotations": { "readOnlyHint": false, @@ -239,8 +239,8 @@ "required": [ "repo_path" ], - "title": "GitLog", - "type": "object" + "type": "object", + "title": "logArguments" }, "annotations": { "readOnlyHint": true, @@ -279,8 +279,8 @@ "repo_path", "branch_name" ], - "title": "GitCreateBranch", - "type": "object" + "type": "object", + "title": "create_branchArguments" }, "annotations": { "readOnlyHint": false, @@ -307,8 +307,8 @@ "repo_path", "branch_name" ], - "title": "GitCheckout", - "type": "object" + "type": "object", + "title": "checkoutArguments" }, "annotations": { "readOnlyHint": false, @@ -335,8 +335,8 @@ "repo_path", "revision" ], - "title": "GitShow", - "type": "object" + "type": "object", + "title": "showArguments" }, "annotations": { "readOnlyHint": true, @@ -391,8 +391,8 @@ "repo_path", "branch_type" ], - "title": "GitBranch", - "type": "object" + "type": "object", + "title": "branchArguments" }, "annotations": { "readOnlyHint": true, diff --git a/src/git/tests/test_entrypoints.py b/src/git/tests/test_entrypoints.py index 8b99cc4078..c1e9c1914e 100644 --- a/src/git/tests/test_entrypoints.py +++ b/src/git/tests/test_entrypoints.py @@ -77,7 +77,7 @@ async def test_serve_returns_early_for_non_repository( ): stdio = mock.MagicMock() with ( - mock.patch("mcp_server_git.server.stdio_server", stdio), + mock.patch("mcp.server.mcpserver.server.stdio_server", stdio), caplog.at_level(logging.ERROR, logger="mcp_server_git.server"), ): assert await serve(tmp_path) is None @@ -93,7 +93,7 @@ async def test_serve_exits_for_nonexistent_repository( stdio = mock.MagicMock() missing = tmp_path / "missing" with ( - mock.patch("mcp_server_git.server.stdio_server", stdio), + mock.patch("mcp.server.mcpserver.server.stdio_server", stdio), caplog.at_level(logging.ERROR, logger="mcp_server_git.server"), ): with pytest.raises(SystemExit) as exc: diff --git a/src/git/tests/test_eras.py b/src/git/tests/test_eras.py index 19a4cd72a5..118e3e991c 100644 --- a/src/git/tests/test_eras.py +++ b/src/git/tests/test_eras.py @@ -30,7 +30,13 @@ from mcp.client._transport import TransportStreams from mcp.shared.exceptions import MCPError from mcp.shared.memory import create_client_server_memory_streams -from mcp.types import METHOD_NOT_FOUND, CallToolResult, TextContent +from mcp.types import ( + INVALID_PARAMS, + METHOD_NOT_FOUND, + CallToolResult, + PromptReference, + TextContent, +) from mcp_server_git.server import serve @@ -61,7 +67,7 @@ async def fake_stdio_server() -> AsyncIterator[Any]: async def transport() -> AsyncIterator[TransportStreams]: yield client_streams - with patch("mcp_server_git.server.stdio_server", fake_stdio_server): + with patch("mcp.server.mcpserver.server.stdio_server", fake_stdio_server): async with anyio.create_task_group() as tg: tg.start_soon(serve, repository) async with Client(transport(), mode=mode) as client: @@ -165,26 +171,47 @@ async def test_repository_confinement_holds_in_each_era( async def test_tool_failure_is_an_is_error_result_in_each_era( repo: git.Repo, mode: str, negotiated: str ) -> None: + # A tool's own failure is an isError result the model can read and act on. async with connect(mode, repo_dir(repo)) as client: missing = await client.call_tool("git_status", {}) - unknown = await client.call_tool("no_such_tool", {}) + outside = await client.call_tool("git_status", {"repo_path": "/"}) assert missing.is_error is True - assert text_of(missing) == ( - "Input validation error: 'repo_path' is a required property" + assert text_of(missing).startswith( + "Error executing tool git_status: 1 validation error for statusArguments\n" + "repo_path\n Field required" + ) + assert outside.is_error is True + assert text_of(outside) == ( + "Error executing tool git_status: Repository path '/' is outside the " + f"allowed repository '{repo_dir(repo)}'" ) - assert unknown.is_error is True - assert text_of(unknown) == "Unknown tool: no_such_tool" + + +@pytest.mark.parametrize(("mode", "negotiated"), ERAS) +async def test_unknown_tool_is_a_json_rpc_invalid_params_error_in_each_era( + repo: git.Repo, mode: str, negotiated: str +) -> None: + # #5174: both spec revisions make an unknown tool a protocol error, + # JSON-RPC -32602, not an isError result. + async with connect(mode, repo_dir(repo)) as client: + with pytest.raises(MCPError) as excinfo: + await client.call_tool("no_such_tool", {}) + assert excinfo.value.error.code == INVALID_PARAMS + assert excinfo.value.error.message == "Unknown tool: no_such_tool" @pytest.mark.parametrize(("mode", "negotiated"), ERAS) async def test_unsupported_method_is_a_json_rpc_error_in_each_era( repo: git.Repo, mode: str, negotiated: str ) -> None: - # The server declares no resources, so resources/list is a protocol error - # (an MCPError on the client), not an isError result. + # The server registers no completion handler, so completion/complete is a + # protocol error (an MCPError on the client), not an isError result. async with connect(mode, repo_dir(repo)) as client: with pytest.raises(MCPError) as excinfo: - await client.list_resources() + await client.complete( + PromptReference(type="ref/prompt", name="none"), + {"name": "x", "value": ""}, + ) assert excinfo.value.error.code == METHOD_NOT_FOUND diff --git a/src/git/tests/test_protocol.py b/src/git/tests/test_protocol.py index 269c02d77d..dc564fc684 100644 --- a/src/git/tests/test_protocol.py +++ b/src/git/tests/test_protocol.py @@ -22,17 +22,26 @@ import git import pytest from mcp.client.session import ClientRequestContext +from mcp.shared.exceptions import MCPError from mcp.shared.memory import create_client_server_memory_streams from mcp.shared.message import SessionMessage -from mcp.types import JSONRPCRequest, ListRootsResult, Root +from mcp.types import INVALID_PARAMS, JSONRPCRequest, ListRootsResult, Root from pydantic import FileUrl from mcp_server_git.server import serve -from conftest import connect, make_repo, text_result, wire +from conftest import connect, make_repo, text_result, tool_error, wire SNAPSHOTS = Path(__file__).parent / "snapshots" +# What the server advertises in either era. MCPServer serves empty prompt and +# resource lists, so both appear; nothing ever changes, so no listChanged. +CAPABILITIES = { + "prompts": {"listChanged": False}, + "resources": {"listChanged": False, "subscribe": False}, + "tools": {"listChanged": False}, +} + def root_of(repo: git.Repo) -> Path: assert repo.working_dir is not None @@ -47,11 +56,6 @@ async def call( return wire(await session.call_tool(name, arguments)) -def rejected(message: str) -> dict[str, Any]: - """The error a flag-injection guard returns: its message, as written.""" - return text_result(message, is_error=True) - - def staged_paths(repo: git.Repo) -> set[str]: return set(repo.git.diff("--cached", "--name-only").splitlines()) @@ -71,9 +75,9 @@ async def test_initialize_reports_package_version_as_server_version(repo: git.Re "version": version("mcp-server-git"), } assert version("mcp") != version("mcp-server-git") - # Behavior change in the SDK v2 port (#4851): v2 omits the empty - # `experimental: {}` object v1 always sent. It advertised nothing. - assert init["capabilities"] == {"tools": {"listChanged": False}} + # MCPServer (#5174) always serves prompts/list and resources/list (empty + # here), so it advertises both capabilities beside tools. + assert init["capabilities"] == CAPABILITIES async def test_list_tools_matches_wire_snapshot(): @@ -108,30 +112,42 @@ async def test_list_tools_names_in_order(): async def test_missing_required_argument_is_input_validation_error(repo: git.Repo): - assert await call(None, "git_status", {}) == text_result( - "Input validation error: 'repo_path' is a required property", is_error=True + # #5174: MCPServer validates arguments against the signature-derived + # schema and reports pydantic's message, prefixed with the tool name. + result = await call(None, "git_status", {}) + assert result["isError"] is True + text = result["content"][0]["text"] + assert text.startswith( + "Error executing tool git_status: 1 validation error for statusArguments\n" + "repo_path\n Field required" ) -async def test_unknown_tool_is_error(repo: git.Repo): - assert await call( - None, "git_nope", {"repo_path": str(root_of(repo))} - ) == text_result("Unknown tool: git_nope", is_error=True) +async def test_unknown_tool_is_a_json_rpc_invalid_params_error(repo: git.Repo): + # #5174: an unknown tool is a protocol error, JSON-RPC -32602, as the spec + # requires (server/tools, "Error Handling"), not an isError result. + async with connect(None) as session: + with pytest.raises(MCPError) as exc: + await session.call_tool("git_nope", {"repo_path": str(root_of(repo))}) + assert exc.value.error.code == INVALID_PARAMS + assert exc.value.error.message == "Unknown tool: git_nope" async def test_unknown_tool_without_repo_path_is_unknown_tool(): - # An unlisted tool skips schema validation; the name is checked before - # any argument is read, so a missing repo_path does not mask it. - assert await call(None, "git_nope", {}) == text_result( - "Unknown tool: git_nope", is_error=True - ) + # The name is checked before any argument is read, so a missing repo_path + # does not mask it (#4994). + async with connect(None) as session: + with pytest.raises(MCPError) as exc: + await session.call_tool("git_nope", {}) + assert exc.value.error.code == INVALID_PARAMS + assert exc.value.error.message == "Unknown tool: git_nope" async def test_nonexistent_repo_path_error_is_the_bare_path(tmp_path: Path): # NoSuchPathError's message is just the path. missing = tmp_path / "missing" assert await call(None, "git_status", {"repo_path": str(missing)}) == ( - text_result(str(missing), is_error=True) + tool_error("git_status", str(missing)) ) @@ -140,7 +156,7 @@ async def test_non_repository_repo_path_error_is_the_bare_path(tmp_path: Path): plain = tmp_path / "plain" plain.mkdir() assert await call(None, "git_status", {"repo_path": str(plain)}) == ( - text_result(str(plain), is_error=True) + tool_error("git_status", str(plain)) ) @@ -149,8 +165,8 @@ async def test_repo_path_subdirectory_is_not_a_repository(repo: git.Repo): # inside a working tree is rejected even though git itself accepts it. sub = root_of(repo) / "sub" sub.mkdir() - assert await call(None, "git_status", {"repo_path": str(sub)}) == text_result( - str(sub), is_error=True + assert await call(None, "git_status", {"repo_path": str(sub)}) == tool_error( + "git_status", str(sub) ) @@ -284,7 +300,7 @@ async def test_git_diff_honors_context_lines(repo: git.Repo): async def test_git_diff_unknown_target_is_error(repo: git.Repo): assert await call( None, "git_diff", {"repo_path": str(root_of(repo)), "target": "nope"} - ) == text_result("Ref 'nope' did not resolve to an object", is_error=True) + ) == tool_error("git_diff", "Ref 'nope' did not resolve to an object") async def test_git_diff_rejects_flag_injection(repo: git.Repo): @@ -292,7 +308,9 @@ async def test_git_diff_rejects_flag_injection(repo: git.Repo): None, "git_diff", {"repo_path": str(root_of(repo)), "target": "--output=/tmp/evil"}, - ) == rejected("Invalid target: '--output=/tmp/evil' - cannot start with '-'") + ) == tool_error( + "git_diff", "Invalid target: '--output=/tmp/evil' - cannot start with '-'" + ) # -------------------------------------------------------------------------- @@ -324,10 +342,10 @@ async def test_git_commit_with_nothing_staged_is_refused(repo: git.Repo): result = await call( None, "git_commit", {"repo_path": str(root), "message": "claims a fix"} ) - assert result == text_result( + assert result == tool_error( + "git_commit", "No changes staged for commit. Use git_add to stage changes first; " "git_status shows what is currently staged.", - is_error=True, ) assert repo.head.commit == before assert repo.is_dirty() @@ -388,12 +406,14 @@ async def test_git_add_file_starting_with_dash_is_a_path(repo: git.Repo): async def test_git_add_empty_list_is_rejected(repo: git.Repo): # #4763: `files: []` would run `git add --`, a no-op. The schema's - # minItems makes call_tool reject it before the tool runs. + # minItems makes MCPServer reject it before the tool runs. root = root_of(repo) (root / "test.txt").write_text("edited\n", newline="\n") result = await call(None, "git_add", {"repo_path": str(root), "files": []}) - assert result == text_result( - "Input validation error: [] should be non-empty", is_error=True + assert result["isError"] is True + assert result["content"][0]["text"].startswith( + "Error executing tool git_add: 1 validation error for addArguments\n" + "files\n List should have at least 1 item after validation, not 0" ) assert staged_paths(repo) == set() @@ -428,9 +448,8 @@ async def test_git_add_rejects_relative_traversal(repo: git.Repo): result = await call( None, "git_add", {"repo_path": str(root), "files": ["../outside.txt"]} ) - assert result == text_result( - f"Path '../outside.txt' is outside the repository '{root.resolve()}'", - is_error=True, + assert result == tool_error( + "git_add", f"Path '../outside.txt' is outside the repository '{root.resolve()}'" ) assert staged_paths(repo) == set() @@ -442,9 +461,8 @@ async def test_git_add_rejects_absolute_path_outside(repo: git.Repo): result = await call( None, "git_add", {"repo_path": str(root), "files": [str(outside)]} ) - assert result == text_result( - f"Path '{outside}' is outside the repository '{root.resolve()}'", - is_error=True, + assert result == tool_error( + "git_add", f"Path '{outside}' is outside the repository '{root.resolve()}'" ) @@ -456,9 +474,8 @@ async def test_git_add_rejects_symlink_out_of_repository(repo: git.Repo): result = await call( None, "git_add", {"repo_path": str(root), "files": ["link.txt"]} ) - assert result == text_result( - f"Path 'link.txt' is outside the repository '{root.resolve()}'", - is_error=True, + assert result == tool_error( + "git_add", f"Path 'link.txt' is outside the repository '{root.resolve()}'" ) @@ -478,7 +495,7 @@ def resolve(self: Path, strict: bool = False) -> Path: result = await call( None, "git_add", {"repo_path": str(root), "files": ["unresolvable"]} ) - assert result == text_result("Invalid path: 'unresolvable'", is_error=True) + assert result == tool_error("git_add", "Invalid path: 'unresolvable'") # -------------------------------------------------------------------------- @@ -570,14 +587,12 @@ async def test_git_log_rejects_timestamp_flag_injection(repo: git.Repo): root = str(root_of(repo)) assert await call( None, "git_log", {"repo_path": root, "start_timestamp": "--all"} - ) == text_result( - "Invalid start_timestamp: '--all' - cannot start with '-'", is_error=True + ) == tool_error( + "git_log", "Invalid start_timestamp: '--all' - cannot start with '-'" ) assert await call( None, "git_log", {"repo_path": root, "end_timestamp": "--all"} - ) == text_result( - "Invalid end_timestamp: '--all' - cannot start with '-'", is_error=True - ) + ) == tool_error("git_log", "Invalid end_timestamp: '--all' - cannot start with '-'") # -------------------------------------------------------------------------- @@ -615,7 +630,7 @@ async def test_git_create_branch_unknown_base_is_error(repo: git.Repo): None, "git_create_branch", {"repo_path": str(root_of(repo)), "branch_name": "x", "base_branch": "nope"}, - ) == text_result("No item found with id nope", is_error=True) + ) == tool_error("git_create_branch", "No item found with id nope") async def test_git_create_branch_existing_name_at_same_commit_is_error( @@ -627,8 +642,9 @@ async def test_git_create_branch_existing_name_at_same_commit_is_error( None, "git_create_branch", {"repo_path": str(root_of(repo)), "branch_name": "main"}, - ) == text_result( - "Cannot create branch 'main': refs/heads/main already exists", is_error=True + ) == tool_error( + "git_create_branch", + "Cannot create branch 'main': refs/heads/main already exists", ) @@ -658,16 +674,52 @@ async def test_git_create_branch_existing_name_at_other_commit_is_error( assert "already exist" in result["content"][0]["text"] +async def test_git_create_branch_on_detached_head_reports_gitpython_message( + repo: git.Repo, +): + # With no base_branch the base is the active branch, and GitPython raises + # TypeError for `active_branch` on a detached HEAD. #5174: that is an + # anticipated failure, so the model still sees GitPython's message rather + # than MCPServer's generic "Error executing tool git_create_branch". + repo.git.checkout("--detach") + result = await call( + None, + "git_create_branch", + {"repo_path": str(root_of(repo)), "branch_name": "x"}, + ) + assert result == tool_error( + "git_create_branch", + f"HEAD is a detached symbolic reference as it points to " + f"'{repo.head.commit.hexsha}'. Use .commit or .object to access the " + "target directly.", + ) + + +async def test_an_unanticipated_exception_is_reported_generically(repo: git.Repo): + # #5174: only the failures listed in ANTICIPATED_ERRORS reach the model + # with their message. Anything else is a crash: MCPServer withholds its + # text and logs the traceback. + with mock.patch( + "mcp_server_git.server.git_status", side_effect=RuntimeError("internal") + ): + result = await call(None, "git_status", {"repo_path": str(root_of(repo))}) + assert result == text_result("Error executing tool git_status", is_error=True) + + async def test_git_create_branch_rejects_flag_injection(repo: git.Repo): root = str(root_of(repo)) assert await call( None, "git_create_branch", {"repo_path": root, "branch_name": "-f"} - ) == rejected("Invalid branch name: '-f' - cannot start with '-'") + ) == tool_error( + "git_create_branch", "Invalid branch name: '-f' - cannot start with '-'" + ) assert await call( None, "git_create_branch", {"repo_path": root, "branch_name": "x", "base_branch": "--track"}, - ) == rejected("Invalid base branch: '--track' - cannot start with '-'") + ) == tool_error( + "git_create_branch", "Invalid base branch: '--track' - cannot start with '-'" + ) # -------------------------------------------------------------------------- @@ -713,7 +765,7 @@ async def test_git_checkout_unknown_branch_is_error(repo: git.Repo): None, "git_checkout", {"repo_path": str(root_of(repo)), "branch_name": "nope"}, - ) == text_result("Ref 'nope' did not resolve to an object", is_error=True) + ) == tool_error("git_checkout", "Ref 'nope' did not resolve to an object") assert repo.active_branch.name == "main" @@ -722,7 +774,9 @@ async def test_git_checkout_rejects_flag_injection(repo: git.Repo): None, "git_checkout", {"repo_path": str(root_of(repo)), "branch_name": "--orphan=evil"}, - ) == rejected("Invalid branch name: '--orphan=evil' - cannot start with '-'") + ) == tool_error( + "git_checkout", "Invalid branch name: '--orphan=evil' - cannot start with '-'" + ) # -------------------------------------------------------------------------- @@ -902,21 +956,23 @@ async def test_git_show_revision_path_not_found_is_error(repo: git.Repo): "git_show", {"repo_path": str(root_of(repo)), "revision": "HEAD:nope.txt"}, ) - assert result == text_result( - "\"Blob or Tree named 'nope.txt' not found\"", is_error=True + assert result == tool_error( + "git_show", "\"Blob or Tree named 'nope.txt' not found\"" ) async def test_git_show_unknown_revision_is_error(repo: git.Repo): assert await call( None, "git_show", {"repo_path": str(root_of(repo)), "revision": "nope"} - ) == text_result("Ref 'nope' did not resolve to an object", is_error=True) + ) == tool_error("git_show", "Ref 'nope' did not resolve to an object") async def test_git_show_rejects_flag_injection(repo: git.Repo): assert await call( None, "git_show", {"repo_path": str(root_of(repo)), "revision": "--format=x"} - ) == rejected("Invalid revision: '--format=x' - cannot start with '-'") + ) == tool_error( + "git_show", "Invalid revision: '--format=x' - cannot start with '-'" + ) # -------------------------------------------------------------------------- @@ -974,7 +1030,7 @@ async def test_git_branch_contains_and_not_contains(repo: git.Repo): async def test_git_branch_unknown_type_is_error(repo: git.Repo): assert await call( None, "git_branch", {"repo_path": str(root_of(repo)), "branch_type": "bogus"} - ) == text_result("Invalid branch type: bogus", is_error=True) + ) == tool_error("git_branch", "Invalid branch type: bogus") async def test_git_branch_rejects_flag_injection(repo: git.Repo): @@ -983,12 +1039,16 @@ async def test_git_branch_rejects_flag_injection(repo: git.Repo): None, "git_branch", {"repo_path": root, "branch_type": "local", "contains": "--x"}, - ) == rejected("Invalid contains value: '--x' - cannot start with '-'") + ) == tool_error( + "git_branch", "Invalid contains value: '--x' - cannot start with '-'" + ) assert await call( None, "git_branch", {"repo_path": root, "branch_type": "local", "not_contains": "--x"}, - ) == rejected("Invalid not_contains value: '--x' - cannot start with '-'") + ) == tool_error( + "git_branch", "Invalid not_contains value: '--x' - cannot start with '-'" + ) # -------------------------------------------------------------------------- @@ -997,10 +1057,10 @@ async def test_git_branch_rejects_flag_injection(repo: git.Repo): def outside_error(repo_path: Path | str, allowed: Path) -> dict[str, Any]: - return text_result( + return tool_error( + "git_status", f"Repository path '{repo_path}' is outside the allowed repository " f"'{allowed}'", - is_error=True, ) @@ -1106,8 +1166,8 @@ async def test_restricted_server_subdirectory_passes_check_but_is_not_a_repo( root = root_of(repo) sub = root / "sub" sub.mkdir() - assert await call(root, "git_status", {"repo_path": str(sub)}) == text_result( - str(sub), is_error=True + assert await call(root, "git_status", {"repo_path": str(sub)}) == tool_error( + "git_status", str(sub) ) @@ -1137,7 +1197,7 @@ def resolve(self: Path, strict: bool = False) -> Path: target = root / "unresolvable" with mock.patch.object(Path, "resolve", resolve): result = await call(root, "git_status", {"repo_path": str(target)}) - assert result == text_result(f"Invalid path: {target}", is_error=True) + assert result == tool_error("git_status", f"Invalid path: {target}") # -------------------------------------------------------------------------- @@ -1217,7 +1277,7 @@ async def test_server_discover_answers_the_2026_07_28_era() -> None: async def fake_stdio_server() -> AsyncIterator[Any]: yield server_streams - with patch("mcp_server_git.server.stdio_server", fake_stdio_server): + with patch("mcp.server.mcpserver.server.stdio_server", fake_stdio_server): async with anyio.create_task_group() as tg: tg.start_soon(serve, None) await client_write.send(SessionMessage(discover)) @@ -1230,7 +1290,7 @@ async def fake_stdio_server() -> AsyncIterator[Any]: "jsonrpc": "2.0", "id": 1, "result": { - "capabilities": {"tools": {"listChanged": False}}, + "capabilities": CAPABILITIES, "supportedVersions": ["2026-07-28"], "resultType": "complete", "ttlMs": 0, diff --git a/src/git/tests/test_server.py b/src/git/tests/test_server.py index 55e46bb12c..f2f25b0dea 100644 --- a/src/git/tests/test_server.py +++ b/src/git/tests/test_server.py @@ -862,7 +862,7 @@ async def test_serve_does_not_raise_exceptions(tmp_path: Path): repo_path = tmp_path / "serve_test_repo" git.Repo.init(repo_path).close() - with mock.patch("mcp_server_git.server.stdio_server") as mock_stdio: + with mock.patch("mcp.server.mcpserver.server.stdio_server") as mock_stdio: mock_read = mock.AsyncMock() mock_write = mock.AsyncMock() mock_stdio.return_value.__aenter__.return_value = (mock_read, mock_write) diff --git a/src/git/uv.lock b/src/git/uv.lock index 0ce4812c91..3c132737cf 100644 --- a/src/git/uv.lock +++ b/src/git/uv.lock @@ -514,7 +514,6 @@ source = { editable = "." } dependencies = [ { name = "click" }, { name = "gitpython" }, - { name = "jsonschema" }, { name = "mcp" }, { name = "pydantic" }, ] @@ -532,7 +531,6 @@ dev = [ requires-dist = [ { name = "click", specifier = ">=8.1.7" }, { name = "gitpython", specifier = ">=3.1.60" }, - { name = "jsonschema", specifier = ">=4.20.0" }, { name = "mcp", specifier = ">=2.2,<3" }, { name = "pydantic", specifier = ">=2.0.0" }, ] From 2b2e2a2f58e23e4921c1697eb79882027d702fb7 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 19 Oct 2026 01:32:23 -0400 Subject: [PATCH 112/117] fix(git): report a detached HEAD in git_create_branch instead of catching TypeError Catching TypeError for every git tool would show a real bug's text to the model as if it were an anticipated failure. The only TypeError the git tools anticipated was GitPython's `active_branch` on a detached HEAD, so git_create_branch now checks for that and asks for base_branch. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- src/git/src/mcp_server_git/server.py | 12 +++++++++--- src/git/tests/test_protocol.py | 14 ++++++-------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/git/src/mcp_server_git/server.py b/src/git/src/mcp_server_git/server.py index 8f6fc8a03d..f13b8e8679 100644 --- a/src/git/src/mcp_server_git/server.py +++ b/src/git/src/mcp_server_git/server.py @@ -258,6 +258,12 @@ def git_create_branch( ) if base_branch: base = repo.references[base_branch] + elif repo.head.is_detached: + # GitPython's `active_branch` raises TypeError on a detached HEAD. + raise ValueError( + "HEAD is detached, so there is no current branch to branch from; " + "pass base_branch" + ) else: base = repo.active_branch @@ -412,15 +418,15 @@ def git_branch( # command, a missing or invalid repository), gitdb's lookup errors (`ODBError`: # `BadName` for an unknown revision, `BadObject`), and the builtins GitPython # raises for a failed lookup: `KeyError` for a path not in a tree, `IndexError` -# for a missing ref in `repo.references[...]`, `TypeError` for -# `active_branch` on a detached HEAD, `OSError` for an unreadable path. +# for a missing ref in `repo.references[...]`, `OSError` for an unreadable +# path. A detached HEAD is checked before `active_branch` (whose TypeError +# would otherwise be a crash), so TypeError stays a crash, as a bug should. # Anything else escapes as a crash, which the SDK reports only as # "Error executing tool ", logging the traceback. ANTICIPATED_ERRORS = ( ValueError, KeyError, IndexError, - TypeError, OSError, git.GitError, ODBError, diff --git a/src/git/tests/test_protocol.py b/src/git/tests/test_protocol.py index dc564fc684..1d80b1b7bf 100644 --- a/src/git/tests/test_protocol.py +++ b/src/git/tests/test_protocol.py @@ -674,13 +674,12 @@ async def test_git_create_branch_existing_name_at_other_commit_is_error( assert "already exist" in result["content"][0]["text"] -async def test_git_create_branch_on_detached_head_reports_gitpython_message( +async def test_git_create_branch_on_detached_head_asks_for_a_base( repo: git.Repo, ): - # With no base_branch the base is the active branch, and GitPython raises - # TypeError for `active_branch` on a detached HEAD. #5174: that is an - # anticipated failure, so the model still sees GitPython's message rather - # than MCPServer's generic "Error executing tool git_create_branch". + # With no base_branch the base is the active branch, which a detached HEAD + # does not have (GitPython would raise TypeError, a crash under MCPServer). + # #5174: the tool says so instead, as an anticipated failure. repo.git.checkout("--detach") result = await call( None, @@ -689,9 +688,8 @@ async def test_git_create_branch_on_detached_head_reports_gitpython_message( ) assert result == tool_error( "git_create_branch", - f"HEAD is a detached symbolic reference as it points to " - f"'{repo.head.commit.hexsha}'. Use .commit or .object to access the " - "target directly.", + "HEAD is detached, so there is no current branch to branch from; " + "pass base_branch", ) From a5587f2835ddd0c6e8dfe4eeb9bc2e0d3bd70ab2 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 19 Oct 2026 01:43:24 -0400 Subject: [PATCH 113/117] test(git): expect the platform's form of / in the outside-repository message repo_path is a Path parameter, so on Windows "/" reaches the guard as "\\" and the message echoes that. Fixes Test git (Windows) on #5175. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- src/git/tests/test_eras.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/git/tests/test_eras.py b/src/git/tests/test_eras.py index 118e3e991c..7de0ccce59 100644 --- a/src/git/tests/test_eras.py +++ b/src/git/tests/test_eras.py @@ -182,8 +182,10 @@ async def test_tool_failure_is_an_is_error_result_in_each_era( ) assert outside.is_error is True assert text_of(outside) == ( - "Error executing tool git_status: Repository path '/' is outside the " - f"allowed repository '{repo_dir(repo)}'" + # repo_path is a Path parameter, so the message echoes the platform's + # form of "/" ("\\" on Windows). + f"Error executing tool git_status: Repository path '{Path('/')}' is " + f"outside the allowed repository '{repo_dir(repo)}'" ) From 005e7e6ea55a61300f05bd2e02a02bc0822db0f4 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 19 Oct 2026 01:53:34 -0400 Subject: [PATCH 114/117] fix(python): serialize git tool calls, delegate malformed names, require mcp 2.3 Copilot round 1 on #5175: - git: MCPServer runs a synchronous tool in a worker thread, so overlapping calls could interleave a check and a change on the shared index. A lock keeps one tool call at a time, as when tools ran on the event loop. - fetch: the unknown-name middleware sees raw params; a non-string name or non-mapping prompt arguments now go to the SDK's validation (-32602) instead of raising TypeError (-32603). - fetch, git, time: subscriptions=False needs SDK 2.3, so the floor is mcp>=2.3,<3 in pyproject, uv.lock and the READMEs. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- src/fetch/README.md | 2 +- src/fetch/pyproject.toml | 2 +- src/fetch/src/mcp_server_fetch/server.py | 9 ++- src/fetch/tests/harness.py | 76 +++++++++++++++++++++++- src/fetch/tests/test_protocol.py | 31 +++++++++- src/fetch/uv.lock | 2 +- src/git/README.md | 2 +- src/git/pyproject.toml | 2 +- src/git/src/mcp_server_git/server.py | 12 +++- src/git/tests/test_protocol.py | 29 +++++++++ src/git/uv.lock | 2 +- src/time/README.md | 2 +- src/time/pyproject.toml | 2 +- src/time/uv.lock | 2 +- 14 files changed, 159 insertions(+), 16 deletions(-) diff --git a/src/fetch/README.md b/src/fetch/README.md index 3b1a3894c2..266eb9f648 100644 --- a/src/fetch/README.md +++ b/src/fetch/README.md @@ -6,7 +6,7 @@ A Model Context Protocol server that provides web content fetching capabilities. Source: https://github.com/modelcontextprotocol/servers/tree/main/src/fetch -Requires MCP Python SDK 2.x (`mcp>=2.2,<3`). +Requires MCP Python SDK 2.x (`mcp>=2.3,<3`). > [!CAUTION] > This server can access local/internal IP addresses and may represent a security risk. Exercise caution when using this MCP server to ensure this does not expose any sensitive data. diff --git a/src/fetch/pyproject.toml b/src/fetch/pyproject.toml index 765b74fa5b..ca845dae77 100644 --- a/src/fetch/pyproject.toml +++ b/src/fetch/pyproject.toml @@ -18,7 +18,7 @@ classifiers = [ dependencies = [ "httpx[socks]>=0.27", "markdownify>=0.13.1", - "mcp>=2.2,<3", + "mcp>=2.3,<3", "protego>=0.6.2", "pydantic>=2.0.0", "readabilipy>=0.2.0", diff --git a/src/fetch/src/mcp_server_fetch/server.py b/src/fetch/src/mcp_server_fetch/server.py index bb058a9954..bdac27295a 100644 --- a/src/fetch/src/mcp_server_fetch/server.py +++ b/src/fetch/src/mcp_server_fetch/server.py @@ -359,8 +359,13 @@ def reject_unknown_names(server: MCPServer) -> ServerMiddleware[Any]: async def middleware( ctx: ServerRequestContext[Any, Any], call_next: CallNext ) -> HandlerResult: - params: Mapping[str, Any] = ctx.params or {} + # Raw params, not yet validated: only a string name and a mapping of + # arguments are inspected here. Anything malformed is left to the + # SDK's own params validation, which answers it with -32602. + params = ctx.params if isinstance(ctx.params, Mapping) else {} name = params.get("name") + if not isinstance(name, str): + return await call_next(ctx) if ctx.method == "tools/call": if name not in {tool.name for tool in await server.list_tools()}: raise MCPError(code=INVALID_PARAMS, message=f"Unknown tool: {name}") @@ -368,7 +373,7 @@ async def middleware( if name not in {prompt.name for prompt in await server.list_prompts()}: raise MCPError(code=INVALID_PARAMS, message=f"Unknown prompt: {name}") arguments = params.get("arguments") or {} - if "url" not in arguments: + if isinstance(arguments, Mapping) and "url" not in arguments: raise MCPError(code=INVALID_PARAMS, message="URL is required") return await call_next(ctx) diff --git a/src/fetch/tests/harness.py b/src/fetch/tests/harness.py index c477dc49d9..2d566a62a9 100644 --- a/src/fetch/tests/harness.py +++ b/src/fetch/tests/harness.py @@ -30,7 +30,14 @@ import readabilipy.simple_json from mcp import ClientSession from mcp.shared.memory import create_client_server_memory_streams -from mcp.types import InitializeResult +from mcp.shared.message import SessionMessage +from mcp.types import ( + InitializeResult, + JSONRPCError, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResponse, +) import mcp_server_fetch.server as server_module @@ -152,3 +159,70 @@ def private_tempdir(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: each other's pages. """ monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + + +async def raw_request( + method: str, params: dict[str, Any] +) -> Union[JSONRPCResponse, JSONRPCError]: + """Send one request exactly as given, bypassing the client's own checks. + + `ClientSession` validates what it sends, so a malformed request (a list as + a tool name, a number as the arguments) can only reach the server as raw + JSON-RPC: a 2025-11-25 handshake, then the request, and its response. + """ + async with create_client_server_memory_streams() as ( + (client_read, client_write), + server_streams, + ): + + @asynccontextmanager + async def fake_stdio_server() -> AsyncIterator[Any]: + yield server_streams + + async def receive(request_id: int) -> Union[JSONRPCResponse, JSONRPCError]: + async for item in client_read: + assert isinstance(item, SessionMessage) + message = item.message + if ( + isinstance(message, (JSONRPCResponse, JSONRPCError)) + and message.id == request_id + ): + return message + raise AssertionError("the server closed the stream") # pragma: no cover + + with patch("mcp.server.mcpserver.server.stdio_server", fake_stdio_server): + async with anyio.create_task_group() as tg: + tg.start_soon(lambda: server_module.serve()) + await client_write.send( + SessionMessage( + JSONRPCRequest( + jsonrpc="2.0", + id=1, + method="initialize", + params={ + "protocolVersion": "2025-11-25", + "capabilities": {}, + "clientInfo": {"name": "raw", "version": "0"}, + }, + ) + ) + ) + await receive(1) + await client_write.send( + SessionMessage( + JSONRPCNotification( + jsonrpc="2.0", method="notifications/initialized" + ) + ) + ) + await client_write.send( + SessionMessage( + JSONRPCRequest( + jsonrpc="2.0", id=2, method=method, params=params + ) + ) + ) + response = await receive(2) + tg.cancel_scope.cancel() + return response + raise AssertionError("unreachable") # pragma: no cover diff --git a/src/fetch/tests/test_protocol.py b/src/fetch/tests/test_protocol.py index 5e9f0b2c6b..cb65b45f1a 100644 --- a/src/fetch/tests/test_protocol.py +++ b/src/fetch/tests/test_protocol.py @@ -29,7 +29,7 @@ from mcp.shared.exceptions import MCPError from mcp.shared.memory import create_client_server_memory_streams from mcp.shared.message import SessionMessage -from mcp.types import INVALID_PARAMS, CallToolResult, JSONRPCRequest +from mcp.types import INVALID_PARAMS, CallToolResult, JSONRPCError, JSONRPCRequest from mcp_server_fetch.server import ( DEFAULT_USER_AGENT_AUTONOMOUS, @@ -37,7 +37,7 @@ serve, ) -from .harness import FakeWeb, connect +from .harness import FakeWeb, connect, raw_request PAGE = "https://example.com/page" ROBOTS = "https://example.com/robots.txt" @@ -287,6 +287,33 @@ async def test_unknown_tool_name_is_a_jsonrpc_error(web: FakeWeb) -> None: assert web.requests == [] +@pytest.mark.parametrize( + ("method", "params"), + [ + ("tools/call", {"name": [], "arguments": {}}), + ("tools/call", {"name": {}, "arguments": {}}), + ("prompts/get", {"name": [], "arguments": {}}), + ("prompts/get", {"name": "fetch", "arguments": 1}), + ], + ids=[ + "tool-name-list", + "tool-name-object", + "prompt-name-list", + "prompt-arguments-number", + ], +) +async def test_malformed_request_is_invalid_params_not_an_internal_error( + web: FakeWeb, method: str, params: dict[str, Any] +) -> None: + # #5175 review: the unknown-name middleware sees raw params, so it leaves + # a non-string name or non-mapping arguments to the SDK's validation, + # which answers -32602, instead of failing on them with -32603. + response = await raw_request(method, params) + assert isinstance(response, JSONRPCError), response + assert response.error.code == INVALID_PARAMS + assert web.requests == [] + + async def test_unknown_tool_name_is_rejected_before_argument_validation( web: FakeWeb, ) -> None: diff --git a/src/fetch/uv.lock b/src/fetch/uv.lock index 720939bade..704eb63111 100644 --- a/src/fetch/uv.lock +++ b/src/fetch/uv.lock @@ -770,7 +770,7 @@ dev = [ requires-dist = [ { name = "httpx", extras = ["socks"], specifier = ">=0.27" }, { name = "markdownify", specifier = ">=0.13.1" }, - { name = "mcp", specifier = ">=2.2,<3" }, + { name = "mcp", specifier = ">=2.3,<3" }, { name = "protego", specifier = ">=0.6.2" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "readabilipy", specifier = ">=0.2.0" }, diff --git a/src/git/README.md b/src/git/README.md index 11cdfe59a4..b155baf128 100644 --- a/src/git/README.md +++ b/src/git/README.md @@ -8,7 +8,7 @@ A Model Context Protocol server for Git repository interaction and automation. T Source: https://github.com/modelcontextprotocol/servers/tree/main/src/git -Requires MCP Python SDK 2.x (`mcp>=2.2,<3`). The server is built on the SDK's high-level `MCPServer`: each tool is a typed Python function, and the SDK derives its input schema from the signature. +Requires MCP Python SDK 2.x (`mcp>=2.3,<3`). The server is built on the SDK's high-level `MCPServer`: each tool is a typed Python function, and the SDK derives its input schema from the signature. Please note that mcp-server-git is currently in early development. The functionality and available tools are subject to change and expansion as we continue to develop and improve the server. diff --git a/src/git/pyproject.toml b/src/git/pyproject.toml index a88372750e..fe6a6a395f 100644 --- a/src/git/pyproject.toml +++ b/src/git/pyproject.toml @@ -18,7 +18,7 @@ classifiers = [ dependencies = [ "click>=8.1.7", "gitpython>=3.1.60", - "mcp>=2.2,<3", + "mcp>=2.3,<3", "pydantic>=2.0.0", ] diff --git a/src/git/src/mcp_server_git/server.py b/src/git/src/mcp_server_git/server.py index f13b8e8679..9c07c0b328 100644 --- a/src/git/src/mcp_server_git/server.py +++ b/src/git/src/mcp_server_git/server.py @@ -17,6 +17,7 @@ # logging), so nothing differs between them. import logging +import threading from collections.abc import Callable, Iterator from contextlib import contextmanager from enum import Enum @@ -445,8 +446,15 @@ def reported_to_model() -> Iterator[None]: _P = ParamSpec("_P") +# `MCPServer` runs a synchronous tool in a worker thread, so two calls can +# overlap. A git operation is a check followed by a change (staged changes, +# then the commit), and the index is shared, so one call at a time: the lock +# keeps the serial behavior the server had when tools ran on the event loop. +_GIT_LOCK = threading.Lock() + + def reports_failures(fn: Callable[_P, str]) -> Callable[_P, str]: - """Run a tool function inside `reported_to_model()`. + """Run a tool function alone, inside `reported_to_model()`. `wraps` keeps the signature, which is what `MCPServer` reads to build the tool's input schema. @@ -454,7 +462,7 @@ def reports_failures(fn: Callable[_P, str]) -> Callable[_P, str]: @wraps(fn) def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> str: - with reported_to_model(): + with _GIT_LOCK, reported_to_model(): return fn(*args, **kwargs) return wrapper diff --git a/src/git/tests/test_protocol.py b/src/git/tests/test_protocol.py index 1d80b1b7bf..a00ac7f45b 100644 --- a/src/git/tests/test_protocol.py +++ b/src/git/tests/test_protocol.py @@ -10,6 +10,8 @@ import json import re +import threading +import time from collections.abc import AsyncIterator from contextlib import asynccontextmanager from importlib.metadata import version @@ -704,6 +706,33 @@ async def test_an_unanticipated_exception_is_reported_generically(repo: git.Repo assert result == text_result("Error executing tool git_status", is_error=True) +async def test_overlapping_tool_calls_run_one_at_a_time(repo: git.Repo): + # #5175 review: MCPServer runs a synchronous tool in a worker thread, so + # two calls can overlap, and a git operation is a check and then a change + # on a shared index. The server runs one tool call at a time. + active = 0 + peak = 0 + guard = threading.Lock() + + def slow_status(_repo: git.Repo) -> str: + nonlocal active, peak + with guard: + active += 1 + peak = max(peak, active) + time.sleep(0.2) + with guard: + active -= 1 + return "clean" + + root = str(root_of(repo)) + with mock.patch("mcp_server_git.server.git_status", side_effect=slow_status): + async with connect(None) as session: + async with anyio.create_task_group() as tg: + for _ in range(3): + tg.start_soon(session.call_tool, "git_status", {"repo_path": root}) + assert peak == 1 + + async def test_git_create_branch_rejects_flag_injection(repo: git.Repo): root = str(root_of(repo)) assert await call( diff --git a/src/git/uv.lock b/src/git/uv.lock index 3c132737cf..e85fc8823e 100644 --- a/src/git/uv.lock +++ b/src/git/uv.lock @@ -531,7 +531,7 @@ dev = [ requires-dist = [ { name = "click", specifier = ">=8.1.7" }, { name = "gitpython", specifier = ">=3.1.60" }, - { name = "mcp", specifier = ">=2.2,<3" }, + { name = "mcp", specifier = ">=2.3,<3" }, { name = "pydantic", specifier = ">=2.0.0" }, ] diff --git a/src/time/README.md b/src/time/README.md index 53b37b3022..e9ad93e260 100644 --- a/src/time/README.md +++ b/src/time/README.md @@ -6,7 +6,7 @@ A Model Context Protocol server that provides time and timezone conversion capab Source: https://github.com/modelcontextprotocol/servers/tree/main/src/time -Requires MCP Python SDK 2.x (`mcp>=2.2,<3`). +Requires MCP Python SDK 2.x (`mcp>=2.3,<3`). ### Available Tools diff --git a/src/time/pyproject.toml b/src/time/pyproject.toml index a15f193ceb..9afaaeb651 100644 --- a/src/time/pyproject.toml +++ b/src/time/pyproject.toml @@ -17,7 +17,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", ] dependencies = [ - "mcp>=2.2,<3", + "mcp>=2.3,<3", "pydantic>=2.0.0", "tzdata>=2024.2", "tzlocal>=5.3.1", diff --git a/src/time/uv.lock b/src/time/uv.lock index 7afc3c2923..b08b0cc546 100644 --- a/src/time/uv.lock +++ b/src/time/uv.lock @@ -518,7 +518,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "mcp", specifier = ">=2.2,<3" }, + { name = "mcp", specifier = ">=2.3,<3" }, { name = "pydantic", specifier = ">=2.0.0" }, { name = "tzdata", specifier = ">=2024.2" }, { name = "tzlocal", specifier = ">=5.3.1" }, From eaaa712b228bfcbb848622b746ca22defbe2c8b5 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 19 Oct 2026 02:06:40 -0400 Subject: [PATCH 115/117] chore: stamp Python CalVer versions for 2026.10.19 fetch, git and time changed after #5165 stamped 2026.10.18 (the MCPServer migration, #5175), and 2026.10.18 was never published. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- src/fetch/pyproject.toml | 2 +- src/fetch/uv.lock | 2 +- src/git/pyproject.toml | 2 +- src/git/uv.lock | 2 +- src/time/pyproject.toml | 2 +- src/time/uv.lock | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/fetch/pyproject.toml b/src/fetch/pyproject.toml index ca845dae77..38337649cd 100644 --- a/src/fetch/pyproject.toml +++ b/src/fetch/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcp-server-fetch" -version = "2026.10.18" +version = "2026.10.19" description = "A Model Context Protocol server providing tools to fetch and convert web content for usage by LLMs" readme = "README.md" requires-python = ">=3.10" diff --git a/src/fetch/uv.lock b/src/fetch/uv.lock index 704eb63111..af6b212726 100644 --- a/src/fetch/uv.lock +++ b/src/fetch/uv.lock @@ -745,7 +745,7 @@ wheels = [ [[package]] name = "mcp-server-fetch" -version = "2026.10.18" +version = "2026.10.19" source = { editable = "." } dependencies = [ { name = "httpx", extra = ["socks"] }, diff --git a/src/git/pyproject.toml b/src/git/pyproject.toml index fe6a6a395f..c2460ac6cd 100644 --- a/src/git/pyproject.toml +++ b/src/git/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcp-server-git" -version = "2026.10.18" +version = "2026.10.19" description = "A Model Context Protocol server providing tools to read, search, and manipulate Git repositories programmatically via LLMs" readme = "README.md" requires-python = ">=3.10" diff --git a/src/git/uv.lock b/src/git/uv.lock index e85fc8823e..7802a4a1c3 100644 --- a/src/git/uv.lock +++ b/src/git/uv.lock @@ -509,7 +509,7 @@ wheels = [ [[package]] name = "mcp-server-git" -version = "2026.10.18" +version = "2026.10.19" source = { editable = "." } dependencies = [ { name = "click" }, diff --git a/src/time/pyproject.toml b/src/time/pyproject.toml index 9afaaeb651..7f6c8610db 100644 --- a/src/time/pyproject.toml +++ b/src/time/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcp-server-time" -version = "2026.10.18" +version = "2026.10.19" description = "A Model Context Protocol server providing tools for time queries and timezone conversions for LLMs" readme = "README.md" requires-python = ">=3.10" diff --git a/src/time/uv.lock b/src/time/uv.lock index b08b0cc546..b546371ffc 100644 --- a/src/time/uv.lock +++ b/src/time/uv.lock @@ -497,7 +497,7 @@ wheels = [ [[package]] name = "mcp-server-time" -version = "2026.10.18" +version = "2026.10.19" source = { editable = "." } dependencies = [ { name = "mcp" }, From d533150b9854e389b03a91b764a4f35a463501d0 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 19 Oct 2026 02:13:35 -0400 Subject: [PATCH 116/117] fix(ci): a superseded dispatched run posts no status, whatever its jobs did dispatch-status checked for a newer dispatched run only in its cancellation branch, so a superseded run whose job had already failed posted `failure` over the newer run's status. The check now runs first, in both python.yml and typescript.yml. A behavioral test runs each job's script with a stub gh. Found by Copilot on #5166. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- .github/workflows/python.yml | 24 +++++---- .github/workflows/typescript.yml | 24 +++++---- scripts/version-pr-ci.test.mjs | 90 +++++++++++++++++++++++++++++++- 3 files changed, 115 insertions(+), 23 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 271c1167b1..ff493cea3d 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -208,8 +208,8 @@ jobs: -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" # `always()`, so a cancelled run cannot leave the status pending. A run that - # was cancelled because a newer dispatch on the same commit superseded it - # posts nothing, and the newer run reports instead; any other cancellation + # a newer dispatch on the same commit superseded posts nothing, whatever its + # jobs' results, and the newer run reports instead; any other cancellation # is reported as an error. A job skipped by its own `if:` counts as passed. dispatch-status: name: Report the dispatched run's result @@ -237,20 +237,22 @@ jobs: GH_REPO: ${{ github.repository }} NEEDS: ${{ toJSON(needs) }} run: | + # A run superseded by a newer dispatch on the same commit posts + # nothing, whatever its jobs' results: the newer run owns the status + # (#5178). A failed lookup counts as no newer run, so this run still + # reports: a stale status is better than one left pending. + NEWER=$(gh run list --workflow python.yml --event workflow_dispatch \ + --commit "$GITHUB_SHA" --json databaseId \ + --jq "[.[] | select(.databaseId > $GITHUB_RUN_ID)] | length") || NEWER=0 + if [ "${NEWER:-0}" -gt 0 ]; then + echo "Superseded by a newer dispatched run, which reports instead" + exit 0 + fi if jq -e 'all(.[]; .result == "success" or .result == "skipped")' <<<"$NEEDS" >/dev/null; then STATE=success DESCRIPTION="Every job passed" elif jq -e 'any(.[]; .result == "failure")' <<<"$NEEDS" >/dev/null; then STATE=failure DESCRIPTION="A job failed; open the run for details" else - NEWER=$(gh run list --workflow python.yml --event workflow_dispatch \ - --commit "$GITHUB_SHA" --json databaseId \ - --jq "[.[] | select(.databaseId > $GITHUB_RUN_ID)] | length") || NEWER=0 - # A failed lookup counts as no newer run: an error status is - # better than one left pending. - if [ "${NEWER:-0}" -gt 0 ]; then - echo "Superseded by a newer dispatched run, which reports instead" - exit 0 - fi STATE=error DESCRIPTION="The run was cancelled; dispatch it again" fi gh api "repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" \ diff --git a/.github/workflows/typescript.yml b/.github/workflows/typescript.yml index 40be37b425..0b56e57946 100644 --- a/.github/workflows/typescript.yml +++ b/.github/workflows/typescript.yml @@ -241,8 +241,8 @@ jobs: -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" # `always()`, so a cancelled run cannot leave the status pending. A run that - # was cancelled because a newer dispatch on the same commit superseded it - # posts nothing, and the newer run reports instead; any other cancellation + # a newer dispatch on the same commit superseded posts nothing, whatever its + # jobs' results, and the newer run reports instead; any other cancellation # is reported as an error. A job skipped by its own `if:` counts as passed. dispatch-status: name: Report the dispatched run's result @@ -270,20 +270,22 @@ jobs: GH_REPO: ${{ github.repository }} NEEDS: ${{ toJSON(needs) }} run: | + # A run superseded by a newer dispatch on the same commit posts + # nothing, whatever its jobs' results: the newer run owns the status + # (#5178). A failed lookup counts as no newer run, so this run still + # reports: a stale status is better than one left pending. + NEWER=$(gh run list --workflow typescript.yml --event workflow_dispatch \ + --commit "$GITHUB_SHA" --json databaseId \ + --jq "[.[] | select(.databaseId > $GITHUB_RUN_ID)] | length") || NEWER=0 + if [ "${NEWER:-0}" -gt 0 ]; then + echo "Superseded by a newer dispatched run, which reports instead" + exit 0 + fi if jq -e 'all(.[]; .result == "success" or .result == "skipped")' <<<"$NEEDS" >/dev/null; then STATE=success DESCRIPTION="Every job passed" elif jq -e 'any(.[]; .result == "failure")' <<<"$NEEDS" >/dev/null; then STATE=failure DESCRIPTION="A job failed; open the run for details" else - NEWER=$(gh run list --workflow typescript.yml --event workflow_dispatch \ - --commit "$GITHUB_SHA" --json databaseId \ - --jq "[.[] | select(.databaseId > $GITHUB_RUN_ID)] | length") || NEWER=0 - # A failed lookup counts as no newer run: an error status is - # better than one left pending. - if [ "${NEWER:-0}" -gt 0 ]; then - echo "Superseded by a newer dispatched run, which reports instead" - exit 0 - fi STATE=error DESCRIPTION="The run was cancelled; dispatch it again" fi gh api "repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" \ diff --git a/scripts/version-pr-ci.test.mjs b/scripts/version-pr-ci.test.mjs index 227d7781f4..a8ddeb0df4 100644 --- a/scripts/version-pr-ci.test.mjs +++ b/scripts/version-pr-ci.test.mjs @@ -12,7 +12,15 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { + chmodSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { parse } from "yaml"; @@ -67,6 +75,86 @@ for (const { file, context } of CI) { }); } +// Run a `dispatch-status` script with a stub `gh` on PATH. The stub answers +// `gh run list` with the number of newer dispatched runs and records every +// other call, so a test sees exactly which status, if any, the run posts. +function runResultScript(script, { needs, newer }) { + const dir = mkdtempSync(path.join(tmpdir(), "dispatch-status-")); + try { + const calls = path.join(dir, "calls"); + writeFileSync(calls, ""); + writeFileSync( + path.join(dir, "gh"), + [ + "#!/usr/bin/env bash", + `if [ "$1 $2" = "run list" ]; then echo ${newer}; exit 0; fi`, + `printf '%s\\n' "$*" >> "${calls}"`, + ].join("\n"), + ); + chmodSync(path.join(dir, "gh"), 0o755); + const result = spawnSync("bash", ["-e", "-c", script], { + encoding: "utf8", + env: { + ...process.env, + PATH: `${dir}${path.delimiter}${process.env.PATH}`, + NEEDS: JSON.stringify(needs), + GITHUB_SHA: "0123abcd", + GITHUB_RUN_ID: "100", + GITHUB_REPOSITORY: "owner/repo", + GITHUB_SERVER_URL: "https://github.com", + }, + }); + assert.equal(result.status, 0, result.stderr); + const posted = readFileSync(calls, "utf8").trim(); + return posted ? posted.match(/state=(\w+)/)?.[1] : null; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +const FAILED = { a: { result: "failure" }, b: { result: "cancelled" } }; +const PASSED = { a: { result: "success" }, b: { result: "skipped" } }; +const CANCELLED = { a: { result: "success" }, b: { result: "cancelled" } }; + +for (const { file } of CI) { + const script = workflow(file).jobs["dispatch-status"].steps[0].run; + + // #5178: the newer-run check guarded only the cancellation branch, so a + // superseded run whose job had already failed posted `failure` over the + // newer run's status. A superseded run now posts nothing at all. + test( + `${file}: a superseded run posts no status, whatever its jobs did`, + { + skip: process.platform === "win32" && "runs the job's bash script", + }, + () => { + for (const needs of [FAILED, PASSED, CANCELLED]) + assert.equal(runResultScript(script, { needs, newer: 1 }), null); + }, + ); + + test( + `${file}: the latest run posts success, failure or error`, + { + skip: process.platform === "win32" && "runs the job's bash script", + }, + () => { + assert.equal( + runResultScript(script, { needs: PASSED, newer: 0 }), + "success", + ); + assert.equal( + runResultScript(script, { needs: FAILED, newer: 0 }), + "failure", + ); + assert.equal( + runResultScript(script, { needs: CANCELLED, newer: 0 }), + "error", + ); + }, + ); +} + for (const { file, job } of VERSION_WORKFLOWS) { const steps = workflow(file).jobs[job]; From 163a5e9357567a19be2eacfb4ae5ec4f9614394b Mon Sep 17 00:00:00 2001 From: cliffhall Date: Mon, 19 Oct 2026 02:21:31 -0400 Subject: [PATCH 117/117] test: skip the dispatch-status script tests without bash or jq The job's script calls jq; on a machine without it the latest-run case posts error instead of success, failing the gate over a missing test prerequisite. Copilot round 1 on #5179. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: cliffhall --- scripts/version-pr-ci.test.mjs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/scripts/version-pr-ci.test.mjs b/scripts/version-pr-ci.test.mjs index a8ddeb0df4..c0b0d575f4 100644 --- a/scripts/version-pr-ci.test.mjs +++ b/scripts/version-pr-ci.test.mjs @@ -112,6 +112,19 @@ function runResultScript(script, { needs, newer }) { } } +// The job's script needs bash and jq, as on the ubuntu runner. Without +// either (Windows, or a machine with no jq), skip rather than fail the gate +// over a missing test prerequisite. +const hasTool = (cmd) => spawnSync(cmd, ["--version"]).status === 0; +const SCRIPT_SKIP = + process.platform === "win32" + ? "runs the job's bash script" + : !hasTool("bash") + ? "needs bash" + : !hasTool("jq") + ? "needs jq, which the job's script calls" + : false; + const FAILED = { a: { result: "failure" }, b: { result: "cancelled" } }; const PASSED = { a: { result: "success" }, b: { result: "skipped" } }; const CANCELLED = { a: { result: "success" }, b: { result: "cancelled" } }; @@ -125,7 +138,7 @@ for (const { file } of CI) { test( `${file}: a superseded run posts no status, whatever its jobs did`, { - skip: process.platform === "win32" && "runs the job's bash script", + skip: SCRIPT_SKIP, }, () => { for (const needs of [FAILED, PASSED, CANCELLED]) @@ -136,7 +149,7 @@ for (const { file } of CI) { test( `${file}: the latest run posts success, failure or error`, { - skip: process.platform === "win32" && "runs the job's bash script", + skip: SCRIPT_SKIP, }, () => { assert.equal(