Skip to content

Commit 476d758

Browse files
committed
Unversioned servers report an empty version
The version fallback to the installed mcp package dated to the SDK's first initialization-options helper and reported the SDK's version as the server's own. No spec text supports that: the schema requires the field but says only 'the version of this implementation', and the ecosystem expectation (spec discussion #1176) is the server's distributed version. typescript and kotlin require an explicit identity; go serializes an empty string for an unset version. Align with go: version='' unless the constructor sets one, and delete the _package_version helper. The stamp is now fully deterministic for unversioned servers, so the unit suites carry it literally in their expectations instead of stripping it (tests._stamp stays for the dual-era interaction matrix, whose era split is unrelated), and the matrix conftest's version pin is gone. Small migration note added: this is a v1-visible behavior change for servers that never set a version.
1 parent 9a0881f commit 476d758

17 files changed

Lines changed: 174 additions & 112 deletions

File tree

docs/advanced/low-level-server.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ Call it and the result carries both representations:
107107
}
108108
```
109109

110-
The `_meta` block is the server's identity stamp: the SDK adds it to every 2026-era result, with the `version` taken from the constructor or the installed package. A server that must not identify itself can strip the key with a middleware, which owns the results it returns.
110+
The `_meta` block is the server's identity stamp: the SDK adds it to every 2026-era result, with the `version` from the constructor (a server that sets none reports an empty string). A server that must not identify itself can strip the key with a middleware, which owns the results it returns.
111111

112112
The server never compares the two fields. This SDK's `Client` does: return `structured_content` that doesn't satisfy the `output_schema` you declared and `call_tool` raises a `RuntimeError` that starts with `Invalid structured content returned by tool search_books` and goes on to quote the `jsonschema` failure. Promising a schema is cheap; keeping it is on you. The whole ladder of return types and schemas is in **[Structured Output](../servers/structured-output.md)**.
113113

docs/get-started/testing.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ async def client(): # (2)!
5959
@pytest.mark.anyio
6060
async def test_call_add_tool(client: Client):
6161
result = await client.call_tool("add", {"a": 1, "b": 2})
62-
# Ignore the server identity stamp in `_meta`; its `version` tracks the installed package.
62+
# Drop the server identity stamp in `_meta`; it is not what this test is about.
6363
result.meta = None
6464
assert result == snapshot(
6565
CallToolResult(

docs/migration.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,14 @@ mcp = MCPServer("Demo", instructions="You answer questions about the weather.")
606606

607607
Keep `name` positional and pass everything else by keyword.
608608

609+
### Unversioned servers report an empty version
610+
611+
In v1, a server constructed without a `version` reported the installed `mcp`
612+
package's version as its own in the `initialize` result's `serverInfo`. In v2
613+
it reports an empty string instead: the SDK's version is not your server's
614+
version. Pass `version="..."` to `Server(...)` or `MCPServer(...)` to identify
615+
your server properly. The field is display-only; nothing breaks either way.
616+
609617
### `mount_path` parameter removed from MCPServer
610618

611619
The `mount_path` parameter has been removed from `MCPServer.__init__()`, `MCPServer.run()`, `MCPServer.run_sse_async()`, and `MCPServer.sse_app()`. It was also removed from the `Settings` class.

src/mcp/server/lowlevel/server.py

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ async def main():
4343
from contextlib import AbstractAsyncContextManager, asynccontextmanager
4444
from dataclasses import dataclass
4545
from functools import cached_property
46-
from importlib.metadata import version as importlib_version
4746
from typing import Any, Generic, overload
4847

4948
import mcp_types as types
@@ -126,15 +125,6 @@ async def _ping_handler(ctx: ServerRequestContext[Any], params: types.RequestPar
126125
return types.EmptyResult()
127126

128127

129-
def _package_version(package: str) -> str:
130-
try:
131-
return importlib_version(package)
132-
except Exception: # pragma: no cover
133-
pass
134-
135-
return "unknown" # pragma: no cover
136-
137-
138128
class Server(Generic[LifespanResultT]):
139129
@overload
140130
def __init__(
@@ -549,7 +539,7 @@ def create_initialization_options(
549539
"""
550540
return InitializationOptions(
551541
server_name=self.name,
552-
server_version=self.version if self.version else _package_version("mcp"),
542+
server_version=self.version or "",
553543
title=self.title,
554544
description=self.description,
555545
capabilities=self.get_capabilities(
@@ -643,7 +633,7 @@ def server_info(self) -> types.Implementation:
643633
"""
644634
return types.Implementation(
645635
name=self.name,
646-
version=self.version if self.version else _package_version("mcp"),
636+
version=self.version or "",
647637
title=self.title,
648638
description=self.description,
649639
website_url=self.website_url,

tests/_stamp.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
"""Shared helper: strip the 2026-era serverInfo `_meta` stamp from a result.
22
33
Servers stamp `io.modelcontextprotocol/serverInfo` into every 2026-era
4-
result's `_meta` and never into handshake-era ones, and the stamp's `version`
5-
defaults to the installed package version. Suites whose expected payloads
6-
should stay identity-free strip the stamp before exact comparison - and the
7-
strip is strict, so a modern result that lost its stamp fails the test
8-
instead of passing silently.
4+
result's `_meta` and never into handshake-era ones. Suites whose expected
5+
payloads should stay identity-free strip the stamp before exact comparison -
6+
and the strip is strict, so a modern result that lost its stamp fails the
7+
test instead of passing silently.
98
109
The interaction matrix does not use this function directly: its `unstamped`
1110
fixture (tests/interaction/conftest.py) resolves per cell to this strict

tests/client/test_client.py

Lines changed: 52 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@
4545
from mcp.server.mcpserver import Context, MCPServer
4646
from mcp.shared.memory import MessageStream, create_client_server_memory_streams
4747
from mcp.shared.message import SessionMessage
48-
from tests._stamp import unstamped
4948
from tests.interaction._connect import BASE_URL, mounted_app
5049

5150
pytestmark = pytest.mark.anyio
@@ -134,9 +133,10 @@ async def test_client_with_simple_server(simple_server: Server):
134133
"""Test that from_server works with a basic Server instance."""
135134
async with Client(simple_server) as client:
136135
resources = await client.list_resources()
137-
assert unstamped(resources) == snapshot(
136+
assert resources == snapshot(
138137
ListResourcesResult(
139-
resources=[Resource(name="Test Resource", uri="memory://test", description="A test resource")]
138+
_meta={"io.modelcontextprotocol/serverInfo": {"name": "test_server", "version": ""}},
139+
resources=[Resource(name="Test Resource", uri="memory://test", description="A test resource")],
140140
)
141141
)
142142

@@ -150,8 +150,9 @@ async def test_client_send_ping(app: MCPServer):
150150
async def test_client_list_tools(app: MCPServer):
151151
async with Client(app) as client:
152152
result = await client.list_tools()
153-
assert unstamped(result) == snapshot(
153+
assert result == snapshot(
154154
ListToolsResult(
155+
_meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
155156
tools=[
156157
Tool(
157158
name="greet",
@@ -169,16 +170,17 @@ async def test_client_list_tools(app: MCPServer):
169170
"type": "object",
170171
},
171172
)
172-
]
173+
],
173174
)
174175
)
175176

176177

177178
async def test_client_call_tool(app: MCPServer):
178179
async with Client(app) as client:
179180
result = await client.call_tool("greet", {"name": "World"})
180-
assert unstamped(result) == snapshot(
181+
assert result == snapshot(
181182
CallToolResult(
183+
_meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
182184
content=[TextContent(text="Hello, World!")],
183185
structured_content={"result": "Hello, World!"},
184186
)
@@ -189,9 +191,10 @@ async def test_read_resource(app: MCPServer):
189191
"""Test reading a resource."""
190192
async with Client(app) as client:
191193
result = await client.read_resource("test://resource")
192-
assert unstamped(result) == snapshot(
194+
assert result == snapshot(
193195
ReadResourceResult(
194-
contents=[TextResourceContents(uri="test://resource", mime_type="text/plain", text="Test content")]
196+
_meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
197+
contents=[TextResourceContents(uri="test://resource", mime_type="text/plain", text="Test content")],
195198
)
196199
)
197200

@@ -269,8 +272,9 @@ async def test_get_prompt(app: MCPServer):
269272
"""Test getting a prompt."""
270273
async with Client(app) as client:
271274
result = await client.get_prompt("greeting_prompt", {"name": "Alice"})
272-
assert unstamped(result) == snapshot(
275+
assert result == snapshot(
273276
GetPromptResult(
277+
_meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
274278
description="A greeting prompt.",
275279
messages=[PromptMessage(role="user", content=TextContent(text="Please greet Alice warmly."))],
276280
)
@@ -335,16 +339,17 @@ async def test_client_list_resources_with_params(app: MCPServer):
335339
"""Test listing resources with params parameter."""
336340
async with Client(app) as client:
337341
result = await client.list_resources()
338-
assert unstamped(result) == snapshot(
342+
assert result == snapshot(
339343
ListResourcesResult(
344+
_meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
340345
resources=[
341346
Resource(
342347
name="test_resource",
343348
uri="test://resource",
344349
description="A test resource.",
345350
mime_type="text/plain",
346351
)
347-
]
352+
],
348353
)
349354
)
350355

@@ -353,22 +358,27 @@ async def test_client_list_resource_templates(app: MCPServer):
353358
"""Test listing resource templates with params parameter."""
354359
async with Client(app) as client:
355360
result = await client.list_resource_templates()
356-
assert unstamped(result) == snapshot(ListResourceTemplatesResult(resource_templates=[]))
361+
assert result == snapshot(
362+
ListResourceTemplatesResult(
363+
_meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}}, resource_templates=[]
364+
)
365+
)
357366

358367

359368
async def test_list_prompts(app: MCPServer):
360369
"""Test listing prompts with params parameter."""
361370
async with Client(app) as client:
362371
result = await client.list_prompts()
363-
assert unstamped(result) == snapshot(
372+
assert result == snapshot(
364373
ListPromptsResult(
374+
_meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
365375
prompts=[
366376
Prompt(
367377
name="greeting_prompt",
368378
description="A greeting prompt.",
369379
arguments=[PromptArgument(name="name", required=True)],
370380
)
371-
]
381+
],
372382
)
373383
)
374384

@@ -378,7 +388,12 @@ async def test_complete_with_prompt_reference(simple_server: Server):
378388
async with Client(simple_server) as client:
379389
ref = types.PromptReference(type="ref/prompt", name="test_prompt")
380390
result = await client.complete(ref=ref, argument={"name": "arg", "value": "test"})
381-
assert unstamped(result) == snapshot(types.CompleteResult(completion=types.Completion(values=[])))
391+
assert result == snapshot(
392+
types.CompleteResult(
393+
_meta={"io.modelcontextprotocol/serverInfo": {"name": "test_server", "version": ""}},
394+
completion=types.Completion(values=[]),
395+
)
396+
)
382397

383398

384399
def test_client_with_url_initializes_streamable_http_transport():
@@ -756,8 +771,12 @@ async def elicitation_callback(
756771
async with Client(server, elicitation_callback=elicitation_callback) as client:
757772
result = await client.call_tool("greet")
758773

759-
assert unstamped(result) == snapshot(
760-
CallToolResult(content=[TextContent(text="Hello, Ada!")], structured_content={"result": "Hello, Ada!"})
774+
assert result == snapshot(
775+
CallToolResult(
776+
_meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
777+
content=[TextContent(text="Hello, Ada!")],
778+
structured_content={"result": "Hello, Ada!"},
779+
)
761780
)
762781
assert len(callback_params) == 1
763782
assert isinstance(callback_params[0], types.ElicitRequestFormParams)
@@ -801,9 +820,11 @@ async def sampling_callback(
801820
async with Client(server, sampling_callback=sampling_callback) as client:
802821
result = await client.call_tool("ask")
803822

804-
assert unstamped(result) == snapshot(
823+
assert result == snapshot(
805824
CallToolResult(
806-
content=[TextContent(text="Model said: Paris")], structured_content={"result": "Model said: Paris"}
825+
_meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
826+
content=[TextContent(text="Model said: Paris")],
827+
structured_content={"result": "Model said: Paris"},
807828
)
808829
)
809830
assert len(callback_params) == 1
@@ -834,8 +855,9 @@ async def list_roots_callback(context: ClientRequestContext) -> types.ListRootsR
834855
async with Client(server, list_roots_callback=list_roots_callback) as client:
835856
result = await client.call_tool("count_roots")
836857

837-
assert unstamped(result) == snapshot(
858+
assert result == snapshot(
838859
CallToolResult(
860+
_meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
839861
content=[TextContent(text="Client exposed 1 root(s).")],
840862
structured_content={"result": "Client exposed 1 root(s)."},
841863
)
@@ -920,8 +942,11 @@ async def elicitation_callback(
920942
with anyio.fail_after(5):
921943
async with Client(server, mode="2026-07-28", elicitation_callback=elicitation_callback) as client:
922944
result = await client.get_prompt("summary")
923-
assert unstamped(result) == snapshot(
924-
GetPromptResult(messages=[PromptMessage(role="user", content=TextContent(text="ok"))])
945+
assert result == snapshot(
946+
GetPromptResult(
947+
_meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
948+
messages=[PromptMessage(role="user", content=TextContent(text="ok"))],
949+
)
925950
)
926951

927952

@@ -948,6 +973,9 @@ async def elicitation_callback(
948973
with anyio.fail_after(5):
949974
async with Client(server, mode="2026-07-28", elicitation_callback=elicitation_callback) as client:
950975
result = await client.read_resource("memory://gated")
951-
assert unstamped(result) == snapshot(
952-
ReadResourceResult(contents=[TextResourceContents(uri="memory://gated", text="unlocked")])
976+
assert result == snapshot(
977+
ReadResourceResult(
978+
_meta={"io.modelcontextprotocol/serverInfo": {"name": "test", "version": ""}},
979+
contents=[TextResourceContents(uri="memory://gated", text="unlocked")],
980+
)
953981
)

tests/client/test_session_concurrency.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
from mcp import Client
1515
from mcp.client import ClientRequestContext
1616
from mcp.server.mcpserver import Context, MCPServer
17-
from tests._stamp import unstamped
1817

1918
pytestmark = pytest.mark.anyio
2019

@@ -67,11 +66,23 @@ async def call_and_record(tag: str) -> None:
6766
await done[tag].wait()
6867

6968
assert completion_order == ["c", "b", "a"]
70-
assert {tag: unstamped(result) for tag, result in results.items()} == snapshot(
69+
assert results == snapshot(
7170
{
72-
"c": CallToolResult(content=[TextContent(text="result:c")], structured_content={"result": "result:c"}),
73-
"b": CallToolResult(content=[TextContent(text="result:b")], structured_content={"result": "result:b"}),
74-
"a": CallToolResult(content=[TextContent(text="result:a")], structured_content={"result": "result:a"}),
71+
"c": CallToolResult(
72+
_meta={"io.modelcontextprotocol/serverInfo": {"name": "parking", "version": ""}},
73+
content=[TextContent(text="result:c")],
74+
structured_content={"result": "result:c"},
75+
),
76+
"b": CallToolResult(
77+
_meta={"io.modelcontextprotocol/serverInfo": {"name": "parking", "version": ""}},
78+
content=[TextContent(text="result:b")],
79+
structured_content={"result": "result:b"},
80+
),
81+
"a": CallToolResult(
82+
_meta={"io.modelcontextprotocol/serverInfo": {"name": "parking", "version": ""}},
83+
content=[TextContent(text="result:a")],
84+
structured_content={"result": "result:a"},
85+
),
7586
}
7687
)
7788

tests/docs_src/_helpers.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
"""Shared helpers for the docs_src tests."""
22

3-
from importlib.metadata import version
43
from typing import TypeVar
54

65
from mcp_types import SERVER_INFO_META_KEY, Result
@@ -12,13 +11,13 @@
1211

1312

1413
def strip_server_info(result: R, server: Server | MCPServer) -> R:
15-
"""Assert the 2026-era serverInfo stamp, then drop it so snapshots stay stable.
14+
"""Assert the 2026-era serverInfo stamp, then drop it so snapshots stay focused.
1615
17-
The doc snippets set no explicit version, so the stamp's version is the
18-
installed mcp package version; keeping it in the inline snapshots would
19-
make them commit-dependent.
16+
The doc snippets set no explicit version, so the stamp's version is empty;
17+
the fenced outputs in the docs pages leave the stamp out, and the tests
18+
mirror the fences.
2019
"""
2120
assert result.meta is not None
22-
assert result.meta[SERVER_INFO_META_KEY] == {"name": server.name, "version": version("mcp")}
21+
assert result.meta[SERVER_INFO_META_KEY] == {"name": server.name, "version": ""}
2322
result.meta = None
2423
return result

tests/docs_src/test_lowlevel.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ async def test_meta_reaches_the_client_application() -> None:
118118
result = await client.call_tool("search_books", {"query": "dune", "limit": 5})
119119
assert result.meta is not None
120120
# The server identity stamp shares `_meta` with the handler's keys without clobbering
121-
# them. Remove it before the exact compares: its `version` tracks the installed package.
121+
# them. Remove it before the exact compares: the page's fence leaves the stamp out.
122122
del result.meta[SERVER_INFO_META_KEY]
123123
assert result.meta == {"bookshop/record_ids": ["bk_17", "bk_42", "bk_99"]}
124124
assert result.model_dump(by_alias=True, exclude_none=True) == snapshot(

tests/interaction/conftest.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,6 @@ def __init__(self, factory: Connect, spec_version: str) -> None:
5555
self.spec_version = spec_version
5656

5757
def __call__(self, server: Server | MCPServer, **kwargs: Any) -> AbstractAsyncContextManager[Client]:
58-
# The matrix compares exact result payloads, and the 2026-era serverInfo
59-
# `_meta` stamp carries the server version, which defaults to the
60-
# commit-dependent installed package version. Pin it so expected
61-
# payloads stay deterministic across commits.
62-
lowlevel = server._lowlevel_server if isinstance(server, MCPServer) else server
63-
if lowlevel.version is None:
64-
lowlevel.version = "1.0.0"
6558
return self._factory(server, spec_version=self.spec_version, **kwargs)
6659

6760

0 commit comments

Comments
 (0)