|
9 | 9 | frommcp.server.lowlevel.serverimportServer |
10 | 10 | frommcp.shared.exceptionsimportMcpError |
11 | 11 | frommcp.shared.memoryimportcreate_client_server_memory_streams, create_connected_server_and_client_session |
| 12 | +frommcp.shared.messageimportSessionMessage |
12 | 13 | frommcp.typesimport ( |
13 | 14 | CancelledNotification, |
14 | 15 | CancelledNotificationParams, |
15 | 16 | ClientNotification, |
16 | 17 | ClientRequest, |
17 | 18 | EmptyResult, |
| 19 | +ErrorData, |
| 20 | +JSONRPCError, |
| 21 | +JSONRPCMessage, |
| 22 | +JSONRPCRequest, |
| 23 | +JSONRPCResponse, |
18 | 24 | TextContent, |
19 | 25 | ) |
20 | 26 |
|
@@ -122,6 +128,171 @@ async def make_request(client_session: ClientSession): |
122 | 128 | awaitev_cancelled.wait() |
123 | 129 |
|
124 | 130 |
|
| 131 | +@pytest.mark.anyio |
| 132 | +asyncdeftest_response_id_type_mismatch_string_to_int(): |
| 133 | +""" |
| 134 | + Test that responses with string IDs are correctly matched to requests sent with |
| 135 | + integer IDs. |
| 136 | +
|
| 137 | + This handles the case where a server returns "id": "0" (string) but the client |
| 138 | + sent "id": 0 (integer). Without ID type normalization, this would cause a timeout. |
| 139 | + """ |
| 140 | +ev_response_received=anyio.Event() |
| 141 | +result_holder: list[types.EmptyResult] = [] |
| 142 | + |
| 143 | +asyncwithcreate_client_server_memory_streams() as (client_streams, server_streams): |
| 144 | +client_read, client_write=client_streams |
| 145 | +server_read, server_write=server_streams |
| 146 | + |
| 147 | +asyncdefmock_server(): |
| 148 | +"""Receive a request and respond with a string ID instead of integer.""" |
| 149 | +message=awaitserver_read.receive() |
| 150 | +assertisinstance(message, SessionMessage) |
| 151 | +root=message.message.root |
| 152 | +assertisinstance(root, JSONRPCRequest) |
| 153 | +# Get the original request ID (which is an integer) |
| 154 | +request_id=root.id |
| 155 | +assertisinstance(request_id, int), f"Expected int, got {type(request_id)}" |
| 156 | + |
| 157 | +# Respond with the ID as a string (simulating a buggy server) |
| 158 | +response=JSONRPCResponse( |
| 159 | +jsonrpc="2.0", |
| 160 | +id=str(request_id), # Convert to string to simulate mismatch |
| 161 | +result={}, |
| 162 | + ) |
| 163 | +awaitserver_write.send(SessionMessage(message=JSONRPCMessage(response))) |
| 164 | + |
| 165 | +asyncdefmake_request(client_session: ClientSession): |
| 166 | +nonlocalresult_holder |
| 167 | +# Send a ping request (uses integer ID internally) |
| 168 | +result=awaitclient_session.send_ping() |
| 169 | +result_holder.append(result) |
| 170 | +ev_response_received.set() |
| 171 | + |
| 172 | +asyncwith ( |
| 173 | +anyio.create_task_group() astg, |
| 174 | +ClientSession(read_stream=client_read, write_stream=client_write) asclient_session, |
| 175 | + ): |
| 176 | +tg.start_soon(mock_server) |
| 177 | +tg.start_soon(make_request, client_session) |
| 178 | + |
| 179 | +withanyio.fail_after(2): |
| 180 | +awaitev_response_received.wait() |
| 181 | + |
| 182 | +assertlen(result_holder) ==1 |
| 183 | +assertisinstance(result_holder[0], EmptyResult) |
| 184 | + |
| 185 | + |
| 186 | +@pytest.mark.anyio |
| 187 | +asyncdeftest_error_response_id_type_mismatch_string_to_int(): |
| 188 | +""" |
| 189 | + Test that error responses with string IDs are correctly matched to requests |
| 190 | + sent with integer IDs. |
| 191 | +
|
| 192 | + This handles the case where a server returns an error with "id": "0" (string) |
| 193 | + but the client sent "id": 0 (integer). |
| 194 | + """ |
| 195 | +ev_error_received=anyio.Event() |
| 196 | +error_holder: list[McpError] = [] |
| 197 | + |
| 198 | +asyncwithcreate_client_server_memory_streams() as (client_streams, server_streams): |
| 199 | +client_read, client_write=client_streams |
| 200 | +server_read, server_write=server_streams |
| 201 | + |
| 202 | +asyncdefmock_server(): |
| 203 | +"""Receive a request and respond with an error using a string ID.""" |
| 204 | +message=awaitserver_read.receive() |
| 205 | +assertisinstance(message, SessionMessage) |
| 206 | +root=message.message.root |
| 207 | +assertisinstance(root, JSONRPCRequest) |
| 208 | +request_id=root.id |
| 209 | +assertisinstance(request_id, int) |
| 210 | + |
| 211 | +# Respond with an error, using the ID as a string |
| 212 | +error_response=JSONRPCError( |
| 213 | +jsonrpc="2.0", |
| 214 | +id=str(request_id), # Convert to string to simulate mismatch |
| 215 | +error=ErrorData(code=-32600, message="Test error"), |
| 216 | + ) |
| 217 | +awaitserver_write.send(SessionMessage(message=JSONRPCMessage(error_response))) |
| 218 | + |
| 219 | +asyncdefmake_request(client_session: ClientSession): |
| 220 | +nonlocalerror_holder |
| 221 | +try: |
| 222 | +awaitclient_session.send_ping() |
| 223 | +pytest.fail("Expected McpError to be raised") # pragma: no cover |
| 224 | +exceptMcpErrorase: |
| 225 | +error_holder.append(e) |
| 226 | +ev_error_received.set() |
| 227 | + |
| 228 | +asyncwith ( |
| 229 | +anyio.create_task_group() astg, |
| 230 | +ClientSession(read_stream=client_read, write_stream=client_write) asclient_session, |
| 231 | + ): |
| 232 | +tg.start_soon(mock_server) |
| 233 | +tg.start_soon(make_request, client_session) |
| 234 | + |
| 235 | +withanyio.fail_after(2): |
| 236 | +awaitev_error_received.wait() |
| 237 | + |
| 238 | +assertlen(error_holder) ==1 |
| 239 | +assert"Test error"instr(error_holder[0]) |
| 240 | + |
| 241 | + |
| 242 | +@pytest.mark.anyio |
| 243 | +asyncdeftest_response_id_non_numeric_string_no_match(): |
| 244 | +""" |
| 245 | + Test that responses with non-numeric string IDs don't incorrectly match |
| 246 | + integer request IDs. |
| 247 | +
|
| 248 | + If a server returns "id": "abc" (non-numeric string), it should not match |
| 249 | + a request sent with "id": 0 (integer). |
| 250 | + """ |
| 251 | +ev_timeout=anyio.Event() |
| 252 | + |
| 253 | +asyncwithcreate_client_server_memory_streams() as (client_streams, server_streams): |
| 254 | +client_read, client_write=client_streams |
| 255 | +server_read, server_write=server_streams |
| 256 | + |
| 257 | +asyncdefmock_server(): |
| 258 | +"""Receive a request and respond with a non-numeric string ID.""" |
| 259 | +message=awaitserver_read.receive() |
| 260 | +assertisinstance(message, SessionMessage) |
| 261 | + |
| 262 | +# Respond with a non-numeric string ID (should not match) |
| 263 | +response=JSONRPCResponse( |
| 264 | +jsonrpc="2.0", |
| 265 | +id="not_a_number", # Non-numeric string |
| 266 | +result={}, |
| 267 | + ) |
| 268 | +awaitserver_write.send(SessionMessage(message=JSONRPCMessage(response))) |
| 269 | + |
| 270 | +asyncdefmake_request(client_session: ClientSession): |
| 271 | +try: |
| 272 | +# Use a short timeout since we expect this to fail |
| 273 | +fromdatetimeimporttimedelta |
| 274 | + |
| 275 | +awaitclient_session.send_request( |
| 276 | +ClientRequest(types.PingRequest()), |
| 277 | +types.EmptyResult, |
| 278 | +request_read_timeout_seconds=timedelta(seconds=0.5), |
| 279 | + ) |
| 280 | +pytest.fail("Expected timeout") # pragma: no cover |
| 281 | +exceptMcpErrorase: |
| 282 | +assert"Timed out"instr(e) |
| 283 | +ev_timeout.set() |
| 284 | + |
| 285 | +asyncwith ( |
| 286 | +anyio.create_task_group() astg, |
| 287 | +ClientSession(read_stream=client_read, write_stream=client_write) asclient_session, |
| 288 | + ): |
| 289 | +tg.start_soon(mock_server) |
| 290 | +tg.start_soon(make_request, client_session) |
| 291 | + |
| 292 | +withanyio.fail_after(2): |
| 293 | +awaitev_timeout.wait() |
| 294 | + |
| 295 | + |
125 | 296 | @pytest.mark.anyio |
126 | 297 | asyncdeftest_connection_closed(): |
127 | 298 | """ |
|
0 commit comments