Skip to content

Commit 05a41e1

Browse files
committed
test: tighten manifest wording and assertion conventions from review pass
- _provider.py docstrings now describe the provider, not its authoring history - eight manifest behavior/divergence notes scoped to what the spec/SDK actually state (logging field-shape only, sse endpoint-event spec-only, scope-selection AS-fallback step, sampling result-balance ValueError, hosting:session:delete doesn't remove transport, low-level elicitation validation/restriction scope, progress no-token second clause) - client-transport:http:reconnect-get deferred reason no longer claims the feature is unimplemented - rename the sampling mixed-content rejection test to match what it asserts - pagination cursor pass-through asserted by identity per the suite convention - mcpserver:prompt:unknown-name docstring acknowledges the code-0 divergence - test_bridge cancel_on_close test bounds the transport-close wait - drop now-stale no-branch pragma at shared/auth.py:94
1 parent 171a01f commit 05a41e1

7 files changed

Lines changed: 56 additions & 49 deletions

File tree

src/mcp/shared/auth.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ def validate_scope(self, requested_scope: str | None) -> list[str] | None:
9191
requested_scopes = requested_scope.split(" ")
9292
allowed_scopes = [] if self.scope is None else self.scope.split(" ")
9393
for scope in requested_scopes:
94-
if scope not in allowed_scopes: # pragma: no branch
94+
if scope not in allowed_scopes:
9595
raise InvalidScopeError(f"Client was not registered with scope {scope}")
9696
return requested_scopes
9797

tests/interaction/_requirements.py

Lines changed: 23 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -424,10 +424,7 @@ def __post_init__(self) -> None:
424424
),
425425
"protocol:progress:no-token": Requirement(
426426
source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow",
427-
behavior=(
428-
"Without a progress callback no token is attached, and a handler that reports progress anyway "
429-
"sends nothing."
430-
),
427+
behavior="Without a progress callback the request carries no progress token.",
431428
),
432429
"protocol:progress:client-to-server": Requirement(
433430
source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow",
@@ -1079,7 +1076,7 @@ def __post_init__(self) -> None:
10791076
source=f"{SPEC_BASE_URL}/server/utilities/logging#log-message-notifications",
10801077
behavior=(
10811078
"A log message sent by a server handler is delivered to the client's logging callback with its "
1082-
"severity level, logger name, and data, in the order the server sent them."
1079+
"severity level, logger name, and data."
10831080
),
10841081
),
10851082
"logging:message:filtered": Requirement(
@@ -1219,7 +1216,8 @@ def __post_init__(self) -> None:
12191216
source=f"{SPEC_BASE_URL}/client/sampling#tool-use-and-result-balance",
12201217
behavior=(
12211218
"Every assistant tool_use block in a sampling request must be matched by a tool_result with "
1222-
"the same id in the following user message; an unmatched tool_use is rejected with Invalid params."
1219+
"the same id in the following user message; an unmatched tool_use is rejected with a ValueError "
1220+
"before the request is sent."
12231221
),
12241222
),
12251223
"sampling:tools:server-gated-by-capability": Requirement(
@@ -1331,8 +1329,9 @@ def __post_init__(self) -> None:
13311329
),
13321330
divergence=Divergence(
13331331
note=(
1334-
"Nothing restricts or validates the requested-schema shape on the sending side; a server "
1335-
"can send nested or non-primitive schemas and the SDK forwards them unchanged."
1332+
"ServerSession.elicit_form forwards an arbitrary dict[str, Any] schema unchanged; no shape "
1333+
"validation at the low-level session layer (the high-level Context.elicit / "
1334+
"elicit_with_validation helper enforces primitive-only fields before generating the schema)."
13361335
),
13371336
),
13381337
),
@@ -1343,7 +1342,11 @@ def __post_init__(self) -> None:
13431342
"the response before sending and the server validates the content it receives."
13441343
),
13451344
divergence=Divergence(
1346-
note="Accepted elicitation content passes through unvalidated on both sides.",
1345+
note=(
1346+
"The client never validates outbound content; ServerSession.elicit_form returns received "
1347+
"content unvalidated (the high-level Context.elicit / elicit_with_validation helper "
1348+
"validates server-side, but the low-level session API does not)."
1349+
),
13471350
),
13481351
),
13491352
"elicitation:url:action:accept-no-content": Requirement(
@@ -1788,18 +1791,15 @@ def __post_init__(self) -> None:
17881791
),
17891792
"transport:sse:endpoint-event": Requirement(
17901793
source=f"{SPEC_BASE_URL}/basic/transports#backwards-compatibility",
1791-
behavior=(
1792-
"Opening the SSE stream delivers an `endpoint` event naming the message-POST URL and a fresh "
1793-
"session identifier; the server registers the session before the event is sent and releases it "
1794-
"when the stream disconnects."
1795-
),
1794+
behavior="Opening the SSE stream delivers an `endpoint` event naming the message-POST URL as the first event.",
17961795
transports=("sse",),
17971796
),
17981797
"transport:sse:post:session-routing": Requirement(
17991798
source="sdk",
18001799
behavior=(
1801-
"A POST to the SSE message endpoint that names no session id, a malformed session id, or an "
1802-
"unknown session id is rejected (400/400/404) instead of being forwarded."
1800+
"The endpoint URL carries a fresh session identifier; the server registers the session before "
1801+
"the endpoint event is sent and releases it when the stream disconnects, and a POST that names "
1802+
"no session id, a malformed session id, or an unknown session id is rejected (400/400/404)."
18031803
),
18041804
transports=("sse",),
18051805
),
@@ -1830,7 +1830,7 @@ def __post_init__(self) -> None:
18301830
),
18311831
"hosting:session:delete": Requirement(
18321832
source=f"{SPEC_BASE_URL}/basic/transports#session-management",
1833-
behavior="DELETE with a valid Mcp-Session-Id terminates the session and removes its transport.",
1833+
behavior="DELETE with a valid Mcp-Session-Id terminates the session.",
18341834
transports=("streamable-http",),
18351835
),
18361836
"hosting:session:id-charset": Requirement(
@@ -2333,10 +2333,10 @@ def __post_init__(self) -> None:
23332333
),
23342334
transports=("streamable-http",),
23352335
deferred=(
2336-
"Not implemented in the SDK: the server's standalone GET stream emits no priming event or "
2337-
"retry hint, so the client's reconnection path always sleeps the hard-coded 1 s default; a "
2338-
"deterministic in-process test would require accepting that real-time wait. The POST-stream "
2339-
"reconnection path is covered by client-transport:http:reconnect-post-priming."
2336+
"The server's standalone GET stream emits no priming event or retry hint, so the client's "
2337+
"reconnection path always sleeps the hard-coded 1 s default; a deterministic in-process test "
2338+
"would require accepting that real-time wait. The POST-stream reconnection path is covered "
2339+
"by client-transport:http:reconnect-post-priming."
23402340
),
23412341
),
23422342
"client-transport:http:reconnect-post-priming": Requirement(
@@ -2586,7 +2586,8 @@ def __post_init__(self) -> None:
25862586
source=f"{SPEC_BASE_URL}/basic/authorization#scope-selection-strategy",
25872587
behavior=(
25882588
"The client selects the requested scope from WWW-Authenticate when present, then from the "
2589-
"protected-resource metadata, and otherwise omits scope."
2589+
"protected-resource metadata, then (as an SDK addition beyond the spec's chain) from the "
2590+
"AS metadata's scopes_supported, and otherwise omits scope."
25902591
),
25912592
transports=("streamable-http",),
25922593
),

tests/interaction/auth/_provider.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@
55
values are unique without being predictable. The behaviour mirrors what the SDK's authorization
66
handlers expect: `authorize` immediately mints a code and returns the redirect, `exchange_*`
77
issue and rotate tokens, and `load_*` are simple lookups. Only the parts the auth interaction
8-
suite drives are implemented; methods the tests do not yet reach raise `NotImplementedError`
9-
and are filled in by the chunk that first exercises them.
8+
suite drives are implemented; methods the suite does not exercise raise `NotImplementedError`.
109
"""
1110

1211
import secrets
@@ -183,5 +182,5 @@ async def exchange_refresh_token(
183182
)
184183

185184
async def revoke_token(self, token: AccessToken | RefreshToken) -> None:
186-
"""Implemented when the bearer/lifecycle tests first exercise revocation."""
185+
"""Not exercised by this suite; revocation is out of scope for the interaction tests."""
187186
raise NotImplementedError

tests/interaction/lowlevel/test_pagination.py

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ async def test_next_cursor_round_trips_through_the_client(connect: Connect) -> N
3232
"""The next_cursor a list handler returns reaches the client, and the cursor the client sends
3333
back on the following call reaches the handler verbatim.
3434
"""
35+
cursor = "page-2"
3536
seen_cursors: list[str | None] = []
3637

3738
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
@@ -40,21 +41,20 @@ async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestPa
4041
if params.cursor is None:
4142
return ListToolsResult(
4243
tools=[Tool(name="alpha", input_schema={"type": "object"})],
43-
next_cursor="page-2",
44+
next_cursor=cursor,
4445
)
4546
return ListToolsResult(tools=[Tool(name="beta", input_schema={"type": "object"})])
4647

4748
server = Server("paginated", on_list_tools=list_tools)
4849

4950
async with connect(server) as client:
5051
first_page = await client.list_tools()
51-
second_page = await client.list_tools(cursor="page-2")
52+
second_page = await client.list_tools(cursor=first_page.next_cursor)
5253

53-
assert first_page == snapshot(
54-
ListToolsResult(tools=[Tool(name="alpha", input_schema={"type": "object"})], next_cursor="page-2")
55-
)
54+
assert first_page.next_cursor == cursor
55+
assert seen_cursors == [None, cursor]
56+
assert [tool.name for tool in first_page.tools] == ["alpha"]
5657
assert second_page == snapshot(ListToolsResult(tools=[Tool(name="beta", input_schema={"type": "object"})]))
57-
assert seen_cursors == snapshot([None, "page-2"])
5858

5959

6060
@requirement("pagination:exhaustion")
@@ -158,6 +158,7 @@ async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestPa
158158
@requirement("resources:list:pagination")
159159
async def test_resources_list_supports_cursor_pagination(connect: Connect) -> None:
160160
"""resources/list round-trips the cursor like every other list operation."""
161+
cursor = "page-2"
161162
seen_cursors: list[str | None] = []
162163

163164
async def list_resources(
@@ -166,25 +167,26 @@ async def list_resources(
166167
assert params is not None
167168
seen_cursors.append(params.cursor)
168169
if params.cursor is None:
169-
return ListResourcesResult(resources=[Resource(uri="memo://1", name="first")], next_cursor="page-2")
170+
return ListResourcesResult(resources=[Resource(uri="memo://1", name="first")], next_cursor=cursor)
170171
return ListResourcesResult(resources=[Resource(uri="memo://2", name="second")])
171172

172173
server = Server("paginated", on_list_resources=list_resources)
173174

174175
async with connect(server) as client:
175176
first_page = await client.list_resources()
176-
second_page = await client.list_resources(cursor="page-2")
177+
second_page = await client.list_resources(cursor=first_page.next_cursor)
177178

178-
assert seen_cursors == snapshot([None, "page-2"])
179+
assert first_page.next_cursor == cursor
180+
assert seen_cursors == [None, cursor]
179181
assert [resource.name for resource in first_page.resources] == ["first"]
180-
assert first_page.next_cursor == "page-2"
181182
assert [resource.name for resource in second_page.resources] == ["second"]
182183
assert second_page.next_cursor is None
183184

184185

185186
@requirement("resources:templates:pagination")
186187
async def test_resource_templates_list_supports_cursor_pagination(connect: Connect) -> None:
187188
"""resources/templates/list round-trips the cursor like every other list operation."""
189+
cursor = "page-2"
188190
seen_cursors: list[str | None] = []
189191

190192
async def list_resource_templates(
@@ -195,7 +197,7 @@ async def list_resource_templates(
195197
if params.cursor is None:
196198
return ListResourceTemplatesResult(
197199
resource_templates=[ResourceTemplate(name="first", uri_template="users://{id}")],
198-
next_cursor="page-2",
200+
next_cursor=cursor,
199201
)
200202
return ListResourceTemplatesResult(
201203
resource_templates=[ResourceTemplate(name="second", uri_template="teams://{id}")]
@@ -205,35 +207,36 @@ async def list_resource_templates(
205207

206208
async with connect(server) as client:
207209
first_page = await client.list_resource_templates()
208-
second_page = await client.list_resource_templates(cursor="page-2")
210+
second_page = await client.list_resource_templates(cursor=first_page.next_cursor)
209211

210-
assert seen_cursors == snapshot([None, "page-2"])
212+
assert first_page.next_cursor == cursor
213+
assert seen_cursors == [None, cursor]
211214
assert [template.name for template in first_page.resource_templates] == ["first"]
212-
assert first_page.next_cursor == "page-2"
213215
assert [template.name for template in second_page.resource_templates] == ["second"]
214216
assert second_page.next_cursor is None
215217

216218

217219
@requirement("prompts:list:pagination")
218220
async def test_prompts_list_supports_cursor_pagination(connect: Connect) -> None:
219221
"""prompts/list round-trips the cursor like every other list operation."""
222+
cursor = "page-2"
220223
seen_cursors: list[str | None] = []
221224

222225
async def list_prompts(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListPromptsResult:
223226
assert params is not None
224227
seen_cursors.append(params.cursor)
225228
if params.cursor is None:
226-
return ListPromptsResult(prompts=[Prompt(name="first")], next_cursor="page-2")
229+
return ListPromptsResult(prompts=[Prompt(name="first")], next_cursor=cursor)
227230
return ListPromptsResult(prompts=[Prompt(name="second")])
228231

229232
server = Server("paginated", on_list_prompts=list_prompts)
230233

231234
async with connect(server) as client:
232235
first_page = await client.list_prompts()
233-
second_page = await client.list_prompts(cursor="page-2")
236+
second_page = await client.list_prompts(cursor=first_page.next_cursor)
234237

235-
assert seen_cursors == snapshot([None, "page-2"])
238+
assert first_page.next_cursor == cursor
239+
assert seen_cursors == [None, cursor]
236240
assert [prompt.name for prompt in first_page.prompts] == ["first"]
237-
assert first_page.next_cursor == "page-2"
238241
assert [prompt.name for prompt in second_page.prompts] == ["second"]
239242
assert second_page.next_cursor is None

tests/interaction/lowlevel/test_sampling.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -350,8 +350,8 @@ async def sampling_callback(
350350

351351

352352
@requirement("sampling:tool-result:no-mixed-content")
353-
async def test_create_message_with_unbalanced_tool_messages_is_rejected(connect: Connect) -> None:
354-
"""A sampling request whose messages mix tool results with other content never leaves the server.
353+
async def test_create_message_with_mixed_tool_result_content_is_rejected(connect: Connect) -> None:
354+
"""A sampling request whose user message mixes tool_result with other content never leaves the server.
355355
356356
The message-structure validation runs inside create_message before the request is sent, even
357357
when no tools are passed, so the client callback is never invoked and the handler observes the

tests/interaction/mcpserver/test_prompts.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,11 @@ def greet(name: str) -> str:
7575

7676
@requirement("mcpserver:prompt:unknown-name")
7777
async def test_get_unknown_prompt_is_error(connect: Connect) -> None:
78-
"""Getting a prompt name that was never registered fails with a JSON-RPC error."""
78+
"""Getting a prompt name that was never registered fails with a JSON-RPC error.
79+
80+
The spec reserves -32602 for this case; the SDK reports code 0 (see the divergence note on
81+
the requirement).
82+
"""
7983
mcp = MCPServer("prompter")
8084

8185
@mcp.prompt()

tests/interaction/transports/test_bridge.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,8 @@ async def lingering_app(scope: Scope, receive: Receive, send: Send) -> None:
8686
cleanup_ran.set()
8787

8888
transport = StreamingASGITransport(lingering_app, cancel_on_close=False)
89-
async with httpx.AsyncClient(transport=transport, base_url="http://bridge") as http:
90-
with anyio.fail_after(5):
89+
with anyio.fail_after(5):
90+
async with httpx.AsyncClient(transport=transport, base_url="http://bridge") as http:
9191
async with http.stream("GET", "/linger") as response:
9292
assert response.status_code == 200
9393
assert not cleanup_ran.is_set()

0 commit comments

Comments
 (0)