forked from modelcontextprotocol/python-sdk
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_initialize.py
More file actions
385 lines (319 loc) · 16 KB
/
Copy pathtest_initialize.py
File metadata and controls
385 lines (319 loc) · 16 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
"""Initialization handshake against the low-level Server, driven through the public Client API.
The later tests drive a bare ClientSession over an InMemoryTransport instead: Client always
performs the full handshake with the latest protocol version, so skipping initialization or
requesting a different version can only be expressed one level down. The final test goes one step
further and plays the server's side of the wire by hand, because no real Server can be made to
answer initialize with an unsupported protocol version.
"""
import anyio
import mcp_types as types
import pytest
from inline_snapshot import snapshot
from mcp_types import (
INVALID_PARAMS,
CallToolResult,
ClientCapabilities,
CompletionsCapability,
EmptyResult,
ErrorData,
Icon,
Implementation,
InitializeRequest,
InitializeRequestParams,
InitializeResult,
JSONRPCRequest,
JSONRPCResponse,
ListToolsRequest,
ListToolsResult,
LoggingCapability,
PromptsCapability,
ResourcesCapability,
ServerCapabilities,
TextContent,
ToolsCapability,
)
from mcp import MCPError
from mcp.client import ClientRequestContext, ClientSession
from mcp.client._memory import InMemoryTransport
from mcp.server import Server, ServerRequestContext
from mcp.shared.memory import MessageStream, create_client_server_memory_streams
from mcp.shared.message import SessionMessage
from tests.interaction._connect import Connect
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
@requirement("lifecycle:initialize:basic")
@requirement("lifecycle:initialize:server-info")
async def test_initialize_returns_server_info(connect: Connect) -> None:
"""Every identity field the server declares is returned to the client in server_info."""
server = Server(
"greeter",
version="1.2.3",
title="Greeter",
description="Greets people.",
website_url="https://example.com/greeter",
icons=[Icon(src="https://example.com/icon.png", mime_type="image/png", sizes=["48x48"])],
)
async with connect(server) as client:
server_info = client.server_info
assert server_info == snapshot(
Implementation(
name="greeter",
title="Greeter",
description="Greets people.",
version="1.2.3",
website_url="https://example.com/greeter",
icons=[Icon(src="https://example.com/icon.png", mime_type="image/png", sizes=["48x48"])],
)
)
@requirement("lifecycle:initialize:instructions")
async def test_initialize_returns_instructions(connect: Connect) -> None:
"""Instructions are returned when the server declares them and omitted when it does not."""
async with connect(Server("guided", instructions="Call the add tool.")) as client:
assert client.instructions == snapshot("Call the add tool.")
async with connect(Server("unguided")) as client:
assert client.instructions is None
@requirement("lifecycle:initialize:capabilities:from-handlers")
@requirement("tools:capability:declared")
@requirement("resources:capability:declared")
@requirement("prompts:capability:declared")
@requirement("completion:capability:declared")
async def test_initialize_capabilities_reflect_registered_handlers(connect: Connect) -> None:
"""Each feature area with a registered handler is advertised as a capability.
The in-memory transport connects with default initialization options, so the
list_changed flags are always False regardless of the server's notification behaviour.
"""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
"""Registered only so the tools capability is advertised; never called."""
raise NotImplementedError
async def list_resources(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListResourcesResult:
"""Registered only so the resources capability is advertised; never called."""
raise NotImplementedError
async def subscribe_resource(ctx: ServerRequestContext, params: types.SubscribeRequestParams) -> types.EmptyResult:
"""Registered only so the subscribe sub-capability is advertised; never called."""
raise NotImplementedError
async def list_prompts(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListPromptsResult:
"""Registered only so the prompts capability is advertised; never called."""
raise NotImplementedError
async def set_logging_level(ctx: ServerRequestContext, params: types.SetLevelRequestParams) -> types.EmptyResult:
"""Registered only so the logging capability is advertised; never called."""
raise NotImplementedError
async def completion(ctx: ServerRequestContext, params: types.CompleteRequestParams) -> types.CompleteResult:
"""Registered only so the completions capability is advertised; never called."""
raise NotImplementedError
server = Server( # pyright: ignore[reportDeprecated]
"full",
on_list_tools=list_tools,
on_list_resources=list_resources,
on_subscribe_resource=subscribe_resource,
on_list_prompts=list_prompts,
on_set_logging_level=set_logging_level,
on_completion=completion,
)
async with connect(server) as client:
capabilities = client.server_capabilities
assert capabilities == snapshot(
ServerCapabilities(
experimental={},
logging=LoggingCapability(),
prompts=PromptsCapability(list_changed=False),
resources=ResourcesCapability(subscribe=True, list_changed=False),
tools=ToolsCapability(list_changed=False),
completions=CompletionsCapability(),
)
)
@requirement("lifecycle:initialize:capabilities:minimal")
async def test_initialize_minimal_server_advertises_no_capabilities(connect: Connect) -> None:
"""A server with no feature handlers advertises no feature capabilities."""
async with connect(Server("bare")) as client:
capabilities = client.server_capabilities
assert capabilities == snapshot(ServerCapabilities(experimental={}))
@requirement("lifecycle:initialize:client-info")
async def test_initialize_server_sees_client_info(connect: Connect) -> None:
"""The client identity supplied to Client is visible to server handlers after initialization."""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[types.Tool(name="whoami", description="Report the caller.", input_schema={"type": "object"})]
)
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "whoami"
assert ctx.session.client_params is not None
client_info = ctx.session.client_params.client_info
return CallToolResult(content=[TextContent(text=f"{client_info.name} {client_info.version}")])
server = Server("introspector", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server, client_info=Implementation(name="acme-agent", version="9.9.9")) as client:
result = await client.call_tool("whoami", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="acme-agent 9.9.9")]))
@requirement("lifecycle:initialize:client-capabilities")
async def test_initialize_server_sees_client_capabilities(connect: Connect) -> None:
"""The client capabilities visible to the server reflect which callbacks the client configured."""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(
tools=[types.Tool(name="abilities", description="Report capabilities.", input_schema={"type": "object"})]
)
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
assert params.name == "abilities"
assert ctx.session.client_params is not None
capabilities = ctx.session.client_params.capabilities
declared = [
name
for name, value in (
("sampling", capabilities.sampling),
("elicitation", capabilities.elicitation),
)
if value is not None
]
if capabilities.roots is not None:
declared.append(f"roots(list_changed={capabilities.roots.list_changed})")
return CallToolResult(content=[TextContent(text=",".join(declared) or "none")])
async def list_roots(context: ClientRequestContext) -> types.ListRootsResult:
"""Registered only so the client declares the roots capability; never called."""
raise NotImplementedError
server = Server("introspector", on_list_tools=list_tools, on_call_tool=call_tool)
async with connect(server) as client:
result = await client.call_tool("abilities", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="none")]))
async with connect(server, list_roots_callback=list_roots) as client:
result = await client.call_tool("abilities", {})
assert result == snapshot(CallToolResult(content=[TextContent(text="roots(list_changed=True)")]))
@requirement("lifecycle:requests-before-initialized")
async def test_request_before_initialization_is_rejected() -> None:
"""A feature request sent before the handshake completes is rejected; ping is exempt.
Client always initializes on entry, so this drives a bare ClientSession that never sends
initialize. The server's stated reason for the rejection never reaches the client: the error
is reported as a generic invalid-params failure.
"""
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
"""Registered so the request is routed to a real handler; never reached."""
raise NotImplementedError
server = Server("strict", on_list_tools=list_tools)
async with (
InMemoryTransport(server) as (read_stream, write_stream),
ClientSession(read_stream, write_stream) as session,
):
with anyio.fail_after(5):
with pytest.raises(MCPError) as exc_info:
await session.send_request(ListToolsRequest(), ListToolsResult)
# Ping is explicitly permitted before initialization completes.
pong = await session.send_ping()
assert exc_info.value.error == snapshot(
ErrorData(code=INVALID_PARAMS, message="Invalid request parameters", data="")
)
assert pong == snapshot(EmptyResult())
@requirement("lifecycle:version:match")
@requirement("lifecycle:version:server-fallback-latest")
async def test_initialize_negotiates_protocol_version() -> None:
"""The server echoes a supported requested version and answers an unsupported one with its latest.
Client always requests the latest version, so each half hand-builds an InitializeRequest on a
bare ClientSession to control the requested version.
"""
server = Server("negotiator")
def initialize_request(protocol_version: str) -> InitializeRequest:
return InitializeRequest(
params=InitializeRequestParams(
protocol_version=protocol_version,
capabilities=ClientCapabilities(),
client_info=Implementation(name="time-traveller", version="0.0.1"),
)
)
async with (
InMemoryTransport(server) as (read_stream, write_stream),
ClientSession(read_stream, write_stream) as session,
):
with anyio.fail_after(5):
result = await session.send_request(initialize_request("2025-03-26"), InitializeResult)
assert result.protocol_version == snapshot("2025-03-26")
async with (
InMemoryTransport(server) as (read_stream, write_stream),
ClientSession(read_stream, write_stream) as session,
):
with anyio.fail_after(5):
result = await session.send_request(initialize_request("1999-01-01"), InitializeResult)
assert result.protocol_version == snapshot("2025-11-25")
@requirement("lifecycle:version:reject-unsupported")
async def test_unsupported_server_protocol_version_fails_initialization() -> None:
"""An initialize response carrying a protocol version the client does not support fails initialization.
A real Server only ever answers with a version it supports, so this test alone plays the
server's side of the wire by hand: it reads the initialize request off the raw stream and
answers it with a hand-built result. Reserve this pattern for behaviour no real server can
be made to produce.
"""
async def scripted_server(streams: MessageStream) -> None:
server_read, server_write = streams
message = await server_read.receive()
assert isinstance(message, SessionMessage)
request = message.message
assert isinstance(request, JSONRPCRequest)
assert request.method == "initialize"
result = InitializeResult(
protocol_version="1991-08-06",
capabilities=ServerCapabilities(),
server_info=Implementation(name="relic", version="0.0.1"),
)
await server_write.send(
SessionMessage(
JSONRPCResponse(
jsonrpc="2.0",
id=request.id,
# Serialized exactly as a real server serializes results onto the wire.
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
)
)
)
async with (
create_client_server_memory_streams() as ((client_read, client_write), server_streams),
anyio.create_task_group() as tg,
ClientSession(client_read, client_write) as session,
):
tg.start_soon(scripted_server, server_streams)
with anyio.fail_after(5):
with pytest.raises(RuntimeError) as exc_info:
await session.initialize()
assert str(exc_info.value) == snapshot("Unsupported protocol version from the server: 1991-08-06")
@requirement("lifecycle:version:downgrade")
async def test_an_older_supported_protocol_version_from_the_server_is_accepted() -> None:
"""An initialize response carrying an older supported protocol version completes the handshake at that version.
A real Server answers with the version the client requested (or its own latest), so this test
plays the server's side of the wire by hand to return a fixed older version regardless of what
was requested. Reserve this pattern for behaviour no real server can be made to produce.
"""
async def scripted_server(streams: MessageStream) -> None:
server_read, server_write = streams
message = await server_read.receive()
assert isinstance(message, SessionMessage)
request = message.message
assert isinstance(request, JSONRPCRequest)
assert request.method == "initialize"
result = InitializeResult(
protocol_version="2025-06-18",
capabilities=ServerCapabilities(),
server_info=Implementation(name="conservative", version="0.0.1"),
)
await server_write.send(
SessionMessage(
JSONRPCResponse(
jsonrpc="2.0",
id=request.id,
# Serialized exactly as a real server serializes results onto the wire.
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
)
)
)
async with (
create_client_server_memory_streams() as ((client_read, client_write), server_streams),
anyio.create_task_group() as tg,
ClientSession(client_read, client_write) as session,
):
tg.start_soon(scripted_server, server_streams)
with anyio.fail_after(5):
initialize_result = await session.initialize()
assert initialize_result.protocol_version == snapshot("2025-06-18")