From 7c2f97486cbbfcebfd34f90fc191eb1cce088615 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 7 Oct 2026 21:55:41 +0200 Subject: [PATCH] [Tests] Add end-to-end client tests, group third-party suites under Interop - Run every client example as a snapshot test (Integration/ClientExamples) - Run the client against the TypeScript reference server over stdio and HTTP, snapshotting the wire and checking the round trip (Interop/Client) - Move the Inspector tests to Interop/Server; install both Node tools from a lockfile - Fix Client::getPrompt() without arguments sending "arguments": [] --- .github/workflows/pipeline.yaml | 23 +- .gitignore | 1 + CHANGELOG.md | 1 + CONTRIBUTING.md | 23 +- Makefile | 7 +- examples/client/README.md | 3 + examples/client/http_client_communication.php | 7 +- examples/client/http_discovery_calculator.php | 7 +- .../client/stateless_lifecycle_client.php | 2 +- .../client/stdio_client_communication.php | 5 + .../client/stdio_discovery_calculator.php | 5 + examples/client/stdio_elicitation.php | 5 + .../server/discovery-calculator/server.php | 1 - phpunit.xml.dist | 5 +- src/Schema/Request/GetPromptRequest.php | 3 +- .../ClientExamples/ClientExampleTestCase.php | 99 + .../Http/HttpClientCommunicationTest.php | 31 + .../Http/HttpClientExampleTestCase.php | 90 + .../Http/HttpDiscoveryCalculatorTest.php | 25 + .../Http/HttpStatelessLifecycleTest.php | 25 + .../snapshots/HttpClientCommunicationTest.txt | 38 + .../snapshots/HttpDiscoveryCalculatorTest.txt | 21 + .../snapshots/HttpStatelessLifecycleTest.txt | 13 + .../Stdio/StdioClientCommunicationTest.php | 20 + .../Stdio/StdioClientExampleTestCase.php | 25 + .../Stdio/StdioDiscoveryCalculatorTest.php | 20 + .../Stdio/StdioElicitationTest.php | 34 + .../ClientExamples/Stdio/StdioRootsTest.php | 20 + .../StdioClientCommunicationTest.txt | 38 + .../StdioDiscoveryCalculatorTest.txt | 28 + .../StdioElicitationTest-answered.txt | 27 + .../StdioElicitationTest-defaults.txt | 27 + .../Stdio/snapshots/StdioRootsTest.txt | 23 + .../Client/EverythingServerTestCase.php | 427 ++ .../Client/HttpEverythingServerTest.php | 65 + tests/Interop/Client/RecordingTransport.php | 103 + .../Client/StdioEverythingServerTest.php | 24 + .../snapshots/completion_complete-prompt.json | 11 + .../completion_complete-resource.json | 11 + .../Interop/Client/snapshots/initialize.json | 34 + tests/Interop/Client/snapshots/ping.json | 3 + .../snapshots/prompts_get-with_arguments.json | 13 + .../prompts_get-without_arguments.json | 13 + .../Client/snapshots/prompts_list.json | 61 + .../Client/snapshots/resources_list.json | 48 + .../snapshots/resources_read-static.json | 11 + .../snapshots/resources_read-template.json | 11 + .../snapshots/resources_templates_list.json | 18 + .../Client/snapshots/tools_call-echo.json | 10 + .../snapshots/tools_call-elicitation.json | 184 + .../tools_call-get_annotated_message.json | 28 + .../tools_call-get_resource_links.json | 24 + .../tools_call-get_resource_reference.json | 22 + .../tools_call-get_structured_content.json | 15 + .../Client/snapshots/tools_call-get_sum.json | 10 + .../snapshots/tools_call-get_tiny_image.json | 19 + .../tools_call-long_running_operation.json | 20 + .../Client/snapshots/tools_call-roots.json | 10 + .../Client/snapshots/tools_call-sampling.json | 29 + .../snapshots/tools_call-unknown_tool.json | 11 + .../Interop/Client/snapshots/tools_list.json | 466 +++ tests/Interop/NodeTool.php | 33 + .../Http/HttpClientCommunicationTest.php | 4 +- .../Http/HttpCombinedRegistrationTest.php | 4 +- .../Http/HttpComplexToolSchemaTest.php | 4 +- .../Http/HttpDiscoveryUserProfileTest.php | 4 +- .../Http/HttpInspectorSnapshotTestCase.php | 4 +- .../Server}/Http/HttpMcpAppsTest.php | 4 +- .../Server}/Http/HttpSchemaShowcaseTest.php | 4 +- ...CombinedRegistrationTest-prompts_list.json | 0 ...mbinedRegistrationTest-resources_list.json | 0 ...onTest-resources_read-config_priority.json | 0 ...strationTest-resources_templates_list.json | 0 ...st-tools_call-discovered_status_check.json | 0 ...ationTest-tools_call-instance_greeter.json | 0 ...trationTest-tools_call-manual_greeter.json | 0 ...tpCombinedRegistrationTest-tools_list.json | 0 ...ttpComplexToolSchemaTest-prompts_list.json | 0 ...pComplexToolSchemaTest-resources_list.json | 0 ...olSchemaTest-resources_templates_list.json | 0 ..._call-schedule_event_all_day_reminder.json | 0 ...ols_call-schedule_event_high_priority.json | 0 ...ools_call-schedule_event_low_priority.json | 0 ...call-schedule_event_meeting_with_time.json | 0 .../HttpComplexToolSchemaTest-tools_list.json | 0 ...eTest-prompts_get-generate_bio_prompt.json | 0 ...DiscoveryUserProfileTest-prompts_list.json | 0 ...scoveryUserProfileTest-resources_list.json | 0 ...Test-resources_read-read_user_id_list.json | 0 ...-resources_read-read_user_profile_101.json | 0 ...-resources_read-read_user_profile_102.json | 0 ...rProfileTest-resources_templates_list.json | 0 ...serProfileTest-tools_call-lookup_user.json | 0 ...Test-tools_call-lookup_user_not_found.json | 0 ...erProfileTest-tools_call-send_welcome.json | 0 ...t-tools_call-test_tool_without_params.json | 0 ...tpDiscoveryUserProfileTest-tools_list.json | 0 .../HttpMcpAppsTest-prompts_list.json | 0 .../HttpMcpAppsTest-resources_list.json | 0 ...cpAppsTest-resources_read-weather_app.json | 0 ...pMcpAppsTest-resources_templates_list.json | 0 ...ttpMcpAppsTest-tools_call-get_weather.json | 0 .../snapshots/HttpMcpAppsTest-tools_list.json | 0 .../HttpSchemaShowcaseTest-prompts_list.json | 0 ...HttpSchemaShowcaseTest-resources_list.json | 0 ...ShowcaseTest-resources_templates_list.json | 0 ...owcaseTest-tools_call-calculate_range.json | 0 ...maShowcaseTest-tools_call-format_text.json | 0 ...owcaseTest-tools_call-generate_config.json | 0 ...maShowcaseTest-tools_call-manage_list.json | 0 ...howcaseTest-tools_call-schedule_event.json | 0 ...wcaseTest-tools_call-validate_profile.json | 0 .../HttpSchemaShowcaseTest-tools_list.json | 0 .../Server}/InspectorSnapshotTestCase.php | 10 +- .../Stdio/StdioCachedDiscoveryTest.php | 6 +- .../Stdio/StdioCustomDependenciesTest.php | 6 +- .../Stdio/StdioDiscoveryCalculatorTest.php | 4 +- .../Server}/Stdio/StdioEnvVariablesTest.php | 6 +- .../Stdio/StdioExplicitRegistrationTest.php | 6 +- .../Stdio/StdioInspectorSnapshotTestCase.php | 4 +- .../Server}/Stdio/StdioMcpAppsTest.php | 6 +- ...StdioCachedDiscoveryTest-prompts_list.json | 0 ...dioCachedDiscoveryTest-resources_list.json | 0 ...iscoveryTest-resources_templates_list.json | 0 ...dDiscoveryTest-tools_call-add_numbers.json | 0 ...yTest-tools_call-add_numbers_negative.json | 0 ...scoveryTest-tools_call-divide_numbers.json | 0 ...est-tools_call-divide_numbers_decimal.json | 0 ...overyTest-tools_call-multiply_numbers.json | 0 ...Test-tools_call-multiply_numbers_zero.json | 0 ...oCachedDiscoveryTest-tools_call-power.json | 0 ...ryTest-tools_call-power_zero_exponent.json | 0 .../StdioCachedDiscoveryTest-tools_list.json | 0 ...ioCustomDependenciesTest-prompts_list.json | 0 ...CustomDependenciesTest-resources_list.json | 0 ...Test-resources_read-read_system_stats.json | 0 ...ndenciesTest-resources_templates_list.json | 0 ...mDependenciesTest-tools_call-add_task.json | 0 ...ndenciesTest-tools_call-complete_task.json | 0 ...enciesTest-tools_call-list_user_tasks.json | 0 ...tdioCustomDependenciesTest-tools_list.json | 0 ...oDiscoveryCalculatorTest-prompts_list.json | 0 ...iscoveryCalculatorTest-resources_list.json | 0 ...ulatorTest-resources_read-read_config.json | 0 ...iscoveryCalculatorTest-resources_read.json | 0 ...lculatorTest-resources_templates_list.json | 0 ...lculatorTest-tools_call-calculate_sum.json | 0 ...culatorTest-tools_call-update_setting.json | 0 ...dioDiscoveryCalculatorTest-tools_call.json | 0 ...dioDiscoveryCalculatorTest-tools_list.json | 0 .../StdioEnvVariablesTest-prompts_list.json | 0 .../StdioEnvVariablesTest-resources_list.json | 0 ...ariablesTest-resources_templates_list.json | 0 ...lesTest-tools_call-process_data_debug.json | 0 ...sTest-tools_call-process_data_default.json | 0 ...st-tools_call-process_data_production.json | 0 .../StdioEnvVariablesTest-tools_list.json | 0 ...ompts_get-personalized_greeting_alice.json | 0 ...prompts_get-personalized_greeting_bob.json | 0 ...ExplicitRegistrationTest-prompts_list.json | 0 ...plicitRegistrationTest-resources_list.json | 0 ...nTest-resources_read-read_app_version.json | 0 ...-resources_read-read_item_123_details.json | 0 ...-resources_read-read_item_ABC_details.json | 0 ...strationTest-resources_templates_list.json | 0 ...RegistrationTest-tools_call-echo_text.json | 0 ...st-tools_call-echo_text_special_chars.json | 0 ...ioExplicitRegistrationTest-tools_list.json | 0 .../StdioMcpAppsTest-prompts_list.json | 0 .../StdioMcpAppsTest-resources_list.json | 0 ...psTest-resources_read-read_weather_ui.json | 0 ...oMcpAppsTest-resources_templates_list.json | 0 ...ppsTest-tools_call-get_weather_london.json | 0 ...AppsTest-tools_call-get_weather_tokyo.json | 0 .../StdioMcpAppsTest-tools_list.json | 0 tests/Interop/package-lock.json | 3590 +++++++++++++++++ tests/Interop/package.json | 8 + tests/Support/ServerProcess.php | 77 + .../Schema/Request/GetPromptRequestTest.php | 40 + 179 files changed, 6166 insertions(+), 61 deletions(-) create mode 100644 tests/Integration/ClientExamples/ClientExampleTestCase.php create mode 100644 tests/Integration/ClientExamples/Http/HttpClientCommunicationTest.php create mode 100644 tests/Integration/ClientExamples/Http/HttpClientExampleTestCase.php create mode 100644 tests/Integration/ClientExamples/Http/HttpDiscoveryCalculatorTest.php create mode 100644 tests/Integration/ClientExamples/Http/HttpStatelessLifecycleTest.php create mode 100644 tests/Integration/ClientExamples/Http/snapshots/HttpClientCommunicationTest.txt create mode 100644 tests/Integration/ClientExamples/Http/snapshots/HttpDiscoveryCalculatorTest.txt create mode 100644 tests/Integration/ClientExamples/Http/snapshots/HttpStatelessLifecycleTest.txt create mode 100644 tests/Integration/ClientExamples/Stdio/StdioClientCommunicationTest.php create mode 100644 tests/Integration/ClientExamples/Stdio/StdioClientExampleTestCase.php create mode 100644 tests/Integration/ClientExamples/Stdio/StdioDiscoveryCalculatorTest.php create mode 100644 tests/Integration/ClientExamples/Stdio/StdioElicitationTest.php create mode 100644 tests/Integration/ClientExamples/Stdio/StdioRootsTest.php create mode 100644 tests/Integration/ClientExamples/Stdio/snapshots/StdioClientCommunicationTest.txt create mode 100644 tests/Integration/ClientExamples/Stdio/snapshots/StdioDiscoveryCalculatorTest.txt create mode 100644 tests/Integration/ClientExamples/Stdio/snapshots/StdioElicitationTest-answered.txt create mode 100644 tests/Integration/ClientExamples/Stdio/snapshots/StdioElicitationTest-defaults.txt create mode 100644 tests/Integration/ClientExamples/Stdio/snapshots/StdioRootsTest.txt create mode 100644 tests/Interop/Client/EverythingServerTestCase.php create mode 100644 tests/Interop/Client/HttpEverythingServerTest.php create mode 100644 tests/Interop/Client/RecordingTransport.php create mode 100644 tests/Interop/Client/StdioEverythingServerTest.php create mode 100644 tests/Interop/Client/snapshots/completion_complete-prompt.json create mode 100644 tests/Interop/Client/snapshots/completion_complete-resource.json create mode 100644 tests/Interop/Client/snapshots/initialize.json create mode 100644 tests/Interop/Client/snapshots/ping.json create mode 100644 tests/Interop/Client/snapshots/prompts_get-with_arguments.json create mode 100644 tests/Interop/Client/snapshots/prompts_get-without_arguments.json create mode 100644 tests/Interop/Client/snapshots/prompts_list.json create mode 100644 tests/Interop/Client/snapshots/resources_list.json create mode 100644 tests/Interop/Client/snapshots/resources_read-static.json create mode 100644 tests/Interop/Client/snapshots/resources_read-template.json create mode 100644 tests/Interop/Client/snapshots/resources_templates_list.json create mode 100644 tests/Interop/Client/snapshots/tools_call-echo.json create mode 100644 tests/Interop/Client/snapshots/tools_call-elicitation.json create mode 100644 tests/Interop/Client/snapshots/tools_call-get_annotated_message.json create mode 100644 tests/Interop/Client/snapshots/tools_call-get_resource_links.json create mode 100644 tests/Interop/Client/snapshots/tools_call-get_resource_reference.json create mode 100644 tests/Interop/Client/snapshots/tools_call-get_structured_content.json create mode 100644 tests/Interop/Client/snapshots/tools_call-get_sum.json create mode 100644 tests/Interop/Client/snapshots/tools_call-get_tiny_image.json create mode 100644 tests/Interop/Client/snapshots/tools_call-long_running_operation.json create mode 100644 tests/Interop/Client/snapshots/tools_call-roots.json create mode 100644 tests/Interop/Client/snapshots/tools_call-sampling.json create mode 100644 tests/Interop/Client/snapshots/tools_call-unknown_tool.json create mode 100644 tests/Interop/Client/snapshots/tools_list.json create mode 100644 tests/Interop/NodeTool.php rename tests/{Inspector => Interop/Server}/Http/HttpClientCommunicationTest.php (95%) rename tests/{Inspector => Interop/Server}/Http/HttpCombinedRegistrationTest.php (94%) rename tests/{Inspector => Interop/Server}/Http/HttpComplexToolSchemaTest.php (96%) rename tests/{Inspector => Interop/Server}/Http/HttpDiscoveryUserProfileTest.php (96%) rename tests/{Inspector => Interop/Server}/Http/HttpInspectorSnapshotTestCase.php (96%) rename tests/{Inspector => Interop/Server}/Http/HttpMcpAppsTest.php (93%) rename tests/{Inspector => Interop/Server}/Http/HttpSchemaShowcaseTest.php (97%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpCombinedRegistrationTest-prompts_list.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpCombinedRegistrationTest-resources_list.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpCombinedRegistrationTest-resources_read-config_priority.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpCombinedRegistrationTest-resources_templates_list.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpCombinedRegistrationTest-tools_call-discovered_status_check.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpCombinedRegistrationTest-tools_call-instance_greeter.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpCombinedRegistrationTest-tools_call-manual_greeter.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpCombinedRegistrationTest-tools_list.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpComplexToolSchemaTest-prompts_list.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpComplexToolSchemaTest-resources_list.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpComplexToolSchemaTest-resources_templates_list.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_all_day_reminder.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_high_priority.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_low_priority.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpComplexToolSchemaTest-tools_call-schedule_event_meeting_with_time.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpComplexToolSchemaTest-tools_list.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpDiscoveryUserProfileTest-prompts_get-generate_bio_prompt.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpDiscoveryUserProfileTest-prompts_list.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpDiscoveryUserProfileTest-resources_list.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpDiscoveryUserProfileTest-resources_read-read_user_id_list.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpDiscoveryUserProfileTest-resources_read-read_user_profile_101.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpDiscoveryUserProfileTest-resources_read-read_user_profile_102.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpDiscoveryUserProfileTest-resources_templates_list.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-lookup_user.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-lookup_user_not_found.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-send_welcome.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpDiscoveryUserProfileTest-tools_call-test_tool_without_params.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpDiscoveryUserProfileTest-tools_list.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpMcpAppsTest-prompts_list.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpMcpAppsTest-resources_list.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpMcpAppsTest-resources_read-weather_app.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpMcpAppsTest-resources_templates_list.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpMcpAppsTest-tools_call-get_weather.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpMcpAppsTest-tools_list.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpSchemaShowcaseTest-prompts_list.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpSchemaShowcaseTest-resources_list.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpSchemaShowcaseTest-resources_templates_list.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpSchemaShowcaseTest-tools_call-calculate_range.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpSchemaShowcaseTest-tools_call-format_text.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpSchemaShowcaseTest-tools_call-generate_config.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpSchemaShowcaseTest-tools_call-manage_list.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpSchemaShowcaseTest-tools_call-schedule_event.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpSchemaShowcaseTest-tools_call-validate_profile.json (100%) rename tests/{Inspector => Interop/Server}/Http/snapshots/HttpSchemaShowcaseTest-tools_list.json (100%) rename tests/{Inspector => Interop/Server}/InspectorSnapshotTestCase.php (95%) rename tests/{Inspector => Interop/Server}/Stdio/StdioCachedDiscoveryTest.php (95%) rename tests/{Inspector => Interop/Server}/Stdio/StdioCustomDependenciesTest.php (94%) rename tests/{Inspector => Interop/Server}/Stdio/StdioDiscoveryCalculatorTest.php (93%) rename tests/{Inspector => Interop/Server}/Stdio/StdioEnvVariablesTest.php (91%) rename tests/{Inspector => Interop/Server}/Stdio/StdioExplicitRegistrationTest.php (94%) rename tests/{Inspector => Interop/Server}/Stdio/StdioInspectorSnapshotTestCase.php (90%) rename tests/{Inspector => Interop/Server}/Stdio/StdioMcpAppsTest.php (90%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioCachedDiscoveryTest-prompts_list.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioCachedDiscoveryTest-resources_list.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioCachedDiscoveryTest-resources_templates_list.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-add_numbers.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-add_numbers_negative.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-divide_numbers.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-divide_numbers_decimal.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-multiply_numbers.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-multiply_numbers_zero.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-power.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioCachedDiscoveryTest-tools_call-power_zero_exponent.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioCachedDiscoveryTest-tools_list.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioCustomDependenciesTest-prompts_list.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioCustomDependenciesTest-resources_list.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioCustomDependenciesTest-resources_read-read_system_stats.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioCustomDependenciesTest-resources_templates_list.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioCustomDependenciesTest-tools_call-add_task.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioCustomDependenciesTest-tools_call-complete_task.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioCustomDependenciesTest-tools_call-list_user_tasks.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioCustomDependenciesTest-tools_list.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioDiscoveryCalculatorTest-prompts_list.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_list.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_read-read_config.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_read.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioDiscoveryCalculatorTest-resources_templates_list.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_call-calculate_sum.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_call-update_setting.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_call.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioDiscoveryCalculatorTest-tools_list.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioEnvVariablesTest-prompts_list.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioEnvVariablesTest-resources_list.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioEnvVariablesTest-resources_templates_list.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioEnvVariablesTest-tools_call-process_data_debug.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioEnvVariablesTest-tools_call-process_data_default.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioEnvVariablesTest-tools_call-process_data_production.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioEnvVariablesTest-tools_list.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioExplicitRegistrationTest-prompts_get-personalized_greeting_alice.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioExplicitRegistrationTest-prompts_get-personalized_greeting_bob.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioExplicitRegistrationTest-prompts_list.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioExplicitRegistrationTest-resources_list.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioExplicitRegistrationTest-resources_read-read_app_version.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioExplicitRegistrationTest-resources_read-read_item_123_details.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioExplicitRegistrationTest-resources_read-read_item_ABC_details.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioExplicitRegistrationTest-resources_templates_list.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioExplicitRegistrationTest-tools_call-echo_text.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioExplicitRegistrationTest-tools_call-echo_text_special_chars.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioExplicitRegistrationTest-tools_list.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioMcpAppsTest-prompts_list.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioMcpAppsTest-resources_list.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioMcpAppsTest-resources_read-read_weather_ui.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioMcpAppsTest-resources_templates_list.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioMcpAppsTest-tools_call-get_weather_london.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioMcpAppsTest-tools_call-get_weather_tokyo.json (100%) rename tests/{Inspector => Interop/Server}/Stdio/snapshots/StdioMcpAppsTest-tools_list.json (100%) create mode 100644 tests/Interop/package-lock.json create mode 100644 tests/Interop/package.json create mode 100644 tests/Support/ServerProcess.php create mode 100644 tests/Unit/Schema/Request/GetPromptRequestTest.php diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index e9c9e83f..58b6ba96 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -75,8 +75,20 @@ jobs: - name: Tests run: vendor/bin/phpunit --testsuite=integration - inspector: + interop: + name: interop / ${{ matrix.side }} runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + # Our server, driven by the MCP Inspector. + - side: server + path: tests/Interop/Server + # Our client, against the TypeScript SDK's reference server. + - side: client + path: tests/Interop/Client + steps: - name: Checkout uses: actions/checkout@v7 @@ -91,12 +103,19 @@ jobs: uses: actions/setup-node@v7 with: node-version: '22' + cache: npm + cache-dependency-path: tests/Interop/package-lock.json - name: Install Composer uses: "ramsey/composer-install@v4" + # The implementations under test, from the lockfile, so their + # dependencies cannot change under the snapshots. + - name: Install Node tools + run: npm ci --prefix tests/Interop --no-audit --no-fund + - name: Tests - run: vendor/bin/phpunit --testsuite=inspector + run: vendor/bin/phpunit ${{ matrix.path }} conformance-server: name: conformance / server (${{ matrix.spec-version }}) diff --git a/.gitignore b/.gitignore index ca3a13f1..72196aa5 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ tests/Conformance/logs/*.log # Documentation site (make docs) /site/ /.cache/ +tests/Interop/node_modules diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a2e82f2..b7dd9ffa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * Add `PassthroughMiddleware` to opt `StreamableHttpTransport` out of its default middleware without the warning an empty `$middleware` list logs. * Add `ElicitationSchema::getDefaults()`, returning the declared `default` of each field to accept a form elicitation with. * [BC Break] `AbstractSchemaDefinition` declares an abstract `getDefault()`, which a custom schema definition has to implement. +* Fix `Client::getPrompt()` without arguments sending `"arguments": []`, which servers validating the spec's object type (e.g. the TypeScript SDK) reject: empty arguments are now omitted. 0.8.0 ----- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c02ee473..e266f02e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,8 +31,8 @@ make ci ``` This runs, in order: `make cs` (PHP CS Fixer, auto-fixes style), `make phpstan` (static analysis), -and `make tests` (unit + inspector tests). All three must pass. If your change touches -protocol-observable behavior, also run: +and `make tests` (all PHPUnit suites; the interop suite needs Node). All three must pass. If your +change touches protocol-observable behavior, also run: ```bash make conformance-tests # requires Docker @@ -48,8 +48,23 @@ See [CLAUDE.md](CLAUDE.md) for a fuller tour of the codebase's architecture and ## Tests New capabilities need unit tests (`tests/Unit/`) covering the core logic, and — for anything -reachable over the wire — inspector tests (`tests/Inspector/`) for end-to-end coverage. If you're -adding a documented pattern, consider adding or updating an example under `examples/`. +reachable over the wire — end-to-end coverage. The suites are split by what they prove: + +- **Integration** (`tests/Integration/`, `make integration-tests`): our client and our server agree + with each other. Fixture servers cover single features; `ClientExamples/` runs every script in + `examples/client/`, one test class per example, piping "user input" to STDIN where an example + asks for it, and snapshots what it prints. +- **Interop** (`tests/Interop/`, `make interop-tests`, needs Node): each half works with an + implementation we did not write. `Server/` drives the example servers with the MCP Inspector, + one test class per example; `Client/` runs our client against the TypeScript SDK's reference + server, `@modelcontextprotocol/server-everything`, over stdio and HTTP. Both are installed from + `tests/Interop/package-lock.json` (`npm ci --prefix tests/Interop`), so a release of one of + their dependencies cannot change what the snapshots see. +- **Conformance** (`tests/Conformance/`, `make conformance-tests`): each half passes the + specification's scripted scenarios. + +Snapshots are written on the first run; delete one to re-record it. If you're adding a documented +pattern, consider adding or updating an example under `examples/` along with its test class. ## Documentation diff --git a/Makefile b/Makefile index f1d78ef9..1a25306f 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: deps-stable deps-low cs phpstan tests unit-tests integration-tests inspector-tests coverage ci ci-stable ci-lowest conformance-tests conformance-server conformance-client conformance-draft conformance-draft-server conformance-draft-client docs docs-guides docs-api docs-serve +.PHONY: deps-stable deps-low cs phpstan tests unit-tests integration-tests interop-tests coverage ci ci-stable ci-lowest conformance-tests conformance-server conformance-client conformance-draft conformance-draft-server conformance-draft-client docs docs-guides docs-api docs-serve # The 2026-07-28 scenarios ship on the `alpha` dist-tag; `latest` (0.1.x) has # none of them. Pinned to the same version CI runs (see @@ -32,8 +32,9 @@ unit-tests: integration-tests: vendor/bin/phpunit --testsuite=integration -inspector-tests: - vendor/bin/phpunit --testsuite=inspector +interop-tests: + npm ci --prefix tests/Interop --no-audit --no-fund + vendor/bin/phpunit --testsuite=interop conformance-tests: conformance-server conformance-client diff --git a/examples/client/README.md b/examples/client/README.md index 0a6dd8ff..4f271f6b 100644 --- a/examples/client/README.md +++ b/examples/client/README.md @@ -54,6 +54,9 @@ php examples/client/stdio_roots.php > mid-call; PHP's built-in web server only does that with worker processes, e.g. > `PHP_CLI_SERVER_WORKERS=2 php -S 127.0.0.1:8000 …`. +The HTTP examples connect to port 8000 on the local machine by default; set `MCP_SERVER_URL` to point +them at a server running elsewhere. + ## Requirements All examples require the server examples to be available. The STDIO examples spawn the server process, while the HTTP examples connect to a running HTTP server. diff --git a/examples/client/http_client_communication.php b/examples/client/http_client_communication.php index 4b6e1661..a1df5fa0 100644 --- a/examples/client/http_client_communication.php +++ b/examples/client/http_client_communication.php @@ -43,7 +43,7 @@ use Mcp\Schema\Request\CreateSamplingMessageRequest; use Mcp\Schema\Result\CreateSamplingMessageResult; -$endpoint = 'http://127.0.0.1:8000'; +$endpoint = getenv('MCP_SERVER_URL') ?: 'http://127.0.0.1:8000'; $loggingNotificationHandler = new LoggingNotificationHandler(static function (LoggingMessageNotification $n) { echo "[LOG {$n->level->value}] {$n->data}\n"; @@ -77,6 +77,8 @@ public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingM $transport = new HttpTransport(endpoint: $endpoint); +$status = 0; + try { echo "Connecting to MCP server at {$endpoint}...\n"; $client->connect($transport); @@ -127,6 +129,9 @@ public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingM } catch (Throwable $e) { echo "Error: {$e->getMessage()}\n"; echo $e->getTraceAsString()."\n"; + $status = 1; } finally { $client->disconnect(); } + +exit($status); diff --git a/examples/client/http_discovery_calculator.php b/examples/client/http_discovery_calculator.php index 06de8797..0e4e05b0 100644 --- a/examples/client/http_discovery_calculator.php +++ b/examples/client/http_discovery_calculator.php @@ -26,7 +26,7 @@ use Mcp\Client; use Mcp\Client\Transport\HttpTransport; -$endpoint = 'http://localhost:8000'; +$endpoint = getenv('MCP_SERVER_URL') ?: 'http://localhost:8000'; $client = Client::builder() ->setClientInfo('HTTP Example Client', '1.0.0') @@ -36,6 +36,8 @@ $transport = new HttpTransport($endpoint); +$status = 0; + try { echo "Connecting to MCP server at {$endpoint}...\n"; $client->connect($transport); @@ -68,8 +70,11 @@ } catch (Throwable $e) { echo "Error: {$e->getMessage()}\n"; echo $e->getTraceAsString()."\n"; + $status = 1; } finally { echo "Disconnecting...\n"; $client->disconnect(); echo "Done.\n"; } + +exit($status); diff --git a/examples/client/stateless_lifecycle_client.php b/examples/client/stateless_lifecycle_client.php index 917028e5..0afa6dc7 100644 --- a/examples/client/stateless_lifecycle_client.php +++ b/examples/client/stateless_lifecycle_client.php @@ -80,7 +80,7 @@ public function handle(Request $request): Response ->addRequestHandler($answerWithAName) ->build(); -$client->connect(new HttpTransport('http://127.0.0.1:8000/')); +$client->connect(new HttpTransport(getenv('MCP_SERVER_URL') ?: 'http://127.0.0.1:8000/')); printf("Connected to %s (revision %s)\n\n", $client->getServerInfo()?->name, $client->getProtocolVersion()?->value); diff --git a/examples/client/stdio_client_communication.php b/examples/client/stdio_client_communication.php index 28e41b6d..453ab6db 100644 --- a/examples/client/stdio_client_communication.php +++ b/examples/client/stdio_client_communication.php @@ -69,6 +69,8 @@ public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingM args: [__DIR__.'/../server/client-communication/server.php'], ); +$status = 0; + try { echo "Connecting to MCP server...\n"; $client->connect($transport); @@ -119,6 +121,9 @@ public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingM } catch (Throwable $e) { echo "Error: {$e->getMessage()}\n"; echo $e->getTraceAsString()."\n"; + $status = 1; } finally { $client->disconnect(); } + +exit($status); diff --git a/examples/client/stdio_discovery_calculator.php b/examples/client/stdio_discovery_calculator.php index 60719735..5b560e64 100644 --- a/examples/client/stdio_discovery_calculator.php +++ b/examples/client/stdio_discovery_calculator.php @@ -36,6 +36,8 @@ args: [__DIR__.'/../server/discovery-calculator/server.php'], ); +$status = 0; + try { echo "Connecting to MCP server...\n"; $client->connect($transport); @@ -80,8 +82,11 @@ } catch (Throwable $e) { echo "Error: {$e->getMessage()}\n"; echo $e->getTraceAsString()."\n"; + $status = 1; } finally { echo "Disconnecting...\n"; $client->disconnect(); echo "Done.\n"; } + +exit($status); diff --git a/examples/client/stdio_elicitation.php b/examples/client/stdio_elicitation.php index 0780a8ec..b3dee306 100644 --- a/examples/client/stdio_elicitation.php +++ b/examples/client/stdio_elicitation.php @@ -104,6 +104,8 @@ private function cast(object $definition, string $input): mixed args: [__DIR__.'/../server/elicitation/server.php'], ); +$status = 0; + try { echo "Connecting to MCP server...\n"; $client->connect($transport); @@ -139,6 +141,9 @@ private function cast(object $definition, string $input): mixed } catch (Throwable $e) { echo "Error: {$e->getMessage()}\n"; echo $e->getTraceAsString()."\n"; + $status = 1; } finally { $client->disconnect(); } + +exit($status); diff --git a/examples/server/discovery-calculator/server.php b/examples/server/discovery-calculator/server.php index c6d75e4d..e8b28ad6 100644 --- a/examples/server/discovery-calculator/server.php +++ b/examples/server/discovery-calculator/server.php @@ -1,4 +1,3 @@ -#!/usr/bin/env php @@ -18,8 +19,8 @@ examples/server/oauth-microsoft/tests - - tests/Inspector + + tests/Interop diff --git a/src/Schema/Request/GetPromptRequest.php b/src/Schema/Request/GetPromptRequest.php index 5d91ff55..a75d71c3 100644 --- a/src/Schema/Request/GetPromptRequest.php +++ b/src/Schema/Request/GetPromptRequest.php @@ -62,7 +62,8 @@ protected function getParams(): array { $params = ['name' => $this->name]; - if (null !== $this->arguments) { + // An empty PHP array encodes as `[]`, which the spec's object type rejects. + if (null !== $this->arguments && [] !== $this->arguments) { $params['arguments'] = $this->arguments; } diff --git a/tests/Integration/ClientExamples/ClientExampleTestCase.php b/tests/Integration/ClientExamples/ClientExampleTestCase.php new file mode 100644 index 00000000..28432b35 --- /dev/null +++ b/tests/Integration/ClientExamples/ClientExampleTestCase.php @@ -0,0 +1,99 @@ + + */ +abstract class ClientExampleTestCase extends TestCase +{ + private const TIMEOUT = 30; + + /** Basename of the script in `examples/client`, without `.php`. */ + abstract protected function getExampleScript(): string; + + /** Directory the snapshots of this transport live in. */ + abstract protected function getSnapshotDirectory(): string; + + /** + * What the user types, one data set per run. Examples that ask nothing + * run once, without input. + * + * @return iterable + */ + public static function provideInputs(): iterable + { + yield 'default' => ['']; + } + + #[DataProvider('provideInputs')] + public function testOutputMatchesSnapshot(string $input): void + { + $process = new Process( + [\PHP_BINARY, \dirname(__DIR__, 3).'/examples/client/'.$this->getExampleScript().'.php'], + env: $this->getEnv(), + input: $input, + timeout: self::TIMEOUT, + ); + $process->run(); + + $this->assertSame(0, $process->getExitCode(), \sprintf( + "Example \"%s\" failed.\n\nSTDOUT:\n%s\nSTDERR:\n%s", + $this->getExampleScript(), + $process->getOutput(), + $process->getErrorOutput(), + )); + + $this->assertMatchesSnapshot($this->normalizeOutput($process->getOutput())); + } + + /** + * @return array added to the example's environment + */ + protected function getEnv(): array + { + return []; + } + + protected function normalizeOutput(string $output): string + { + return $output; + } + + private function assertMatchesSnapshot(string $output): void + { + $className = substr(static::class, strrpos(static::class, '\\') + 1); + $suffix = 'default' === $this->dataName() ? '' : '-'.preg_replace('/\W+/', '_', (string) $this->dataName()); + $file = $this->getSnapshotDirectory().'/snapshots/'.$className.$suffix.'.txt'; + + if (!file_exists($file)) { + @mkdir(\dirname($file), 0777, true); + file_put_contents($file, $output); + $this->markTestIncomplete(\sprintf('Snapshot created at %s, please re-run tests.', $file)); + } + + $this->assertSame(file_get_contents($file), $output, \sprintf('Output does not match snapshot "%s".', $file)); + } +} diff --git a/tests/Integration/ClientExamples/Http/HttpClientCommunicationTest.php b/tests/Integration/ClientExamples/Http/HttpClientCommunicationTest.php new file mode 100644 index 00000000..33c8db32 --- /dev/null +++ b/tests/Integration/ClientExamples/Http/HttpClientCommunicationTest.php @@ -0,0 +1,31 @@ +getServerWorkers() > 1 && \PHP_VERSION_ID < 80200) { + $this->markTestSkipped('php -S does not reliably fork multiple workers on PHP 8.1 (PHP_CLI_SERVER_WORKERS); see php/php-src#9400.'); + } + + $port = ServerProcess::findFreePort(); + + $this->server = new Process( + [\PHP_BINARY, '-S', \sprintf('127.0.0.1:%d', $port), \dirname(__DIR__, 4).'/examples/server/'.$this->getServerExample().'/server.php'], + env: ['PHP_CLI_SERVER_WORKERS' => (string) $this->getServerWorkers()], + ); + $this->server->start(); + + $deadline = microtime(true) + 5; + while (microtime(true) < $deadline) { + if (@fsockopen('127.0.0.1', $port, $errno, $error, 0.1)) { + $this->endpoint = \sprintf('http://127.0.0.1:%d/', $port); + + return; + } + + usleep(50_000); + } + + $this->fail(\sprintf('The example server "%s" did not start: %s', $this->getServerExample(), $this->server->getErrorOutput())); + } + + protected function tearDown(): void + { + ServerProcess::stop($this->server); + $this->server = null; + } + + protected function getEnv(): array + { + return ['MCP_SERVER_URL' => $this->endpoint]; + } + + protected function normalizeOutput(string $output): string + { + return str_replace($this->endpoint, 'http://127.0.0.1:/', $output); + } + + protected function getSnapshotDirectory(): string + { + return __DIR__; + } +} diff --git a/tests/Integration/ClientExamples/Http/HttpDiscoveryCalculatorTest.php b/tests/Integration/ClientExamples/Http/HttpDiscoveryCalculatorTest.php new file mode 100644 index 00000000..b8ef3288 --- /dev/null +++ b/tests/Integration/ClientExamples/Http/HttpDiscoveryCalculatorTest.php @@ -0,0 +1,25 @@ +/... +Connected to: Client Communication Demo + +Available tools: + - run_dataset_quality_checks + - inspect_workspace_roots + - coordinate_incident_response + +Calling 'run_dataset_quality_checks'... + +[LOG info] Running quality checks on dataset "sales_transactions_q4" +[PROGRESS 33%] Validating schema +[PROGRESS 67%] Scanning for anomalies +[PROGRESS 100%] Reviewing statistical summary +[LOG info] Dataset "sales_transactions_q4" passed automated checks. + +Result: +{ + "dataset": "sales_transactions_q4", + "status": "passed", + "notes": "No significant integrity issues detected during automated checks." +} + +Calling 'coordinate_incident_response'... + +[LOG warning] Incident triage started: Database connection pool exhausted +[PROGRESS 33%] Collecting telemetry +[PROGRESS 67%] Assessing scope +[PROGRESS 100%] Coordinating responders +[SAMPLING] Server requested LLM sampling (max 350 tokens) +[LOG info] Incident triage completed for Database connection pool exhausted + +Result: +{ + "incident": "Database connection pool exhausted", + "recommended_actions": "Based on the incident analysis, I recommend: 1) Activate the on-call team, 2) Isolate affected systems, 3) Begin root cause analysis, 4) Prepare stakeholder communication.", + "model": "mock-gpt-4" +} diff --git a/tests/Integration/ClientExamples/Http/snapshots/HttpDiscoveryCalculatorTest.txt b/tests/Integration/ClientExamples/Http/snapshots/HttpDiscoveryCalculatorTest.txt new file mode 100644 index 00000000..173cfc95 --- /dev/null +++ b/tests/Integration/ClientExamples/Http/snapshots/HttpDiscoveryCalculatorTest.txt @@ -0,0 +1,21 @@ +Connecting to MCP server at http://127.0.0.1:/... +Connected! Server info: + Name: Calculator + Version: 1.1.0 + +Available tools: + - calculate: Performs a calculation based on the operation. + +Supports 'add', 'subtract', 'multiply', 'divide'. +Obeys the 'precision' and 'allow_negative' settings from the config resource. + - update_setting: Updates a specific configuration setting. + +Note: This requires more robust validation in a real app. + +Available resources: + - config://calculator/settings: calculator_config + +Available prompts: + +Disconnecting... +Done. diff --git a/tests/Integration/ClientExamples/Http/snapshots/HttpStatelessLifecycleTest.txt b/tests/Integration/ClientExamples/Http/snapshots/HttpStatelessLifecycleTest.txt new file mode 100644 index 00000000..2a5b3683 --- /dev/null +++ b/tests/Integration/ClientExamples/Http/snapshots/HttpStatelessLifecycleTest.txt @@ -0,0 +1,13 @@ +Connected to Stateless Lifecycle Demo (revision 2026-07-28) + +Tools: + get_weather Reports the weather for a city + reindex Reindexes shards, reporting progress as it goes + greet Greets you by name, asking for it first if it has to + +A plain call: + It is 17°C and cloudy in Munich. + +A call the server cannot finish in one round: + server asked: What name should the greeting use? + Hello, Ada! diff --git a/tests/Integration/ClientExamples/Stdio/StdioClientCommunicationTest.php b/tests/Integration/ClientExamples/Stdio/StdioClientCommunicationTest.php new file mode 100644 index 00000000..4deef55e --- /dev/null +++ b/tests/Integration/ClientExamples/Stdio/StdioClientCommunicationTest.php @@ -0,0 +1,20 @@ + ["4\n2026-12-24\nvegan\nyes\n"]; + // An empty line takes the offered default; the date has none that is stable. + yield 'defaults' => ["\n2026-12-24\n\n\n"]; + } + + protected function normalizeOutput(string $output): string + { + // The default offered for the date is today. + return str_replace(date('Y-m-d'), '', $output); + } + + protected function getExampleScript(): string + { + return 'stdio_elicitation'; + } +} diff --git a/tests/Integration/ClientExamples/Stdio/StdioRootsTest.php b/tests/Integration/ClientExamples/Stdio/StdioRootsTest.php new file mode 100644 index 00000000..6c1ad166 --- /dev/null +++ b/tests/Integration/ClientExamples/Stdio/StdioRootsTest.php @@ -0,0 +1,20 @@ +]: Dietary Restrictions [none]: +Result: +{ + "status": "confirmed", + "message": "Reservation confirmed at The Test Kitchen for 4 guests on 2026-12-24.", + "booking": { + "party_size": 4, + "date": "2026-12-24", + "dietary": "vegan" + } +} + +Calling 'confirm_action'... + +[ELICIT] Are you sure you want to: Delete all temporary files? + Confirm [false]: +Result: +{ + "status": "confirmed", + "message": "Action confirmed: Delete all temporary files" +} diff --git a/tests/Integration/ClientExamples/Stdio/snapshots/StdioElicitationTest-defaults.txt b/tests/Integration/ClientExamples/Stdio/snapshots/StdioElicitationTest-defaults.txt new file mode 100644 index 00000000..76f6ec60 --- /dev/null +++ b/tests/Integration/ClientExamples/Stdio/snapshots/StdioElicitationTest-defaults.txt @@ -0,0 +1,27 @@ +Connecting to MCP server... +Connected to: Elicitation Demo + +Calling 'book_restaurant'... + +[ELICIT] Please provide your reservation details for The Test Kitchen: + Party Size [2]: Reservation Date []: Dietary Restrictions [none]: +Result: +{ + "status": "confirmed", + "message": "Reservation confirmed at The Test Kitchen for 2 guests on 2026-12-24.", + "booking": { + "party_size": 2, + "date": "2026-12-24", + "dietary": "none" + } +} + +Calling 'confirm_action'... + +[ELICIT] Are you sure you want to: Delete all temporary files? + Confirm [false]: +Result: +{ + "status": "not_confirmed", + "message": "User did not check the confirmation box." +} diff --git a/tests/Integration/ClientExamples/Stdio/snapshots/StdioRootsTest.txt b/tests/Integration/ClientExamples/Stdio/snapshots/StdioRootsTest.txt new file mode 100644 index 00000000..a4e540b7 --- /dev/null +++ b/tests/Integration/ClientExamples/Stdio/snapshots/StdioRootsTest.txt @@ -0,0 +1,23 @@ +Connecting to MCP server... +Connected to: Client Communication Demo + +Calling 'inspect_workspace_roots'... +[ROOTS] Server requested the client's list of roots + +Result: +{ + "status": "ok", + "message": "Client exposed 2 root(s).", + "roots": [ + { + "uri": "file:///home/user/projects/app", + "name": "Application" + }, + { + "uri": "file:///home/user/projects/library", + "name": "Library" + } + ] +} + +Notifying the server that the roots list changed... diff --git a/tests/Interop/Client/EverythingServerTestCase.php b/tests/Interop/Client/EverythingServerTestCase.php new file mode 100644 index 00000000..f12356f8 --- /dev/null +++ b/tests/Interop/Client/EverythingServerTestCase.php @@ -0,0 +1,427 @@ + + */ +abstract class EverythingServerTestCase extends TestCase +{ + private const TIMEOUT = 30; + + /** + * Fields the client loses when parsing, per scenario, until they are fixed. + * Paths use `*` for list indices. + */ + private const KNOWN_LOSSES = [ + // Tool has no `execution` (2025-11-25 tasks). + 'tools_list' => ['missing result.tools.*.execution'], + // ServerCapabilities has no `tasks` (2025-11-25 tasks). + 'initialize' => ['missing result.capabilities.tasks'], + ]; + + /** + * Fields the client always writes, where the server may leave them out and + * the specification gives the same meaning to their absence. + */ + private const SPEC_DEFAULTS = [ + 'isError' => false, + ]; + + private static ?Client $client = null; + private static ?RecordingTransport $transport = null; + + private static ?\stdClass $initializeResult = null; + + /** + * What the client's handlers were given, by JSON-RPC id. + * + * @var array + */ + private static array $serverRequests = []; + + abstract protected static function transport(): TransportInterface; + + public static function setUpBeforeClass(): void + { + self::$client = Client::builder() + ->setClientInfo('php-sdk-interop', '1.0.0') + ->setInitTimeout(self::TIMEOUT) + ->setRequestTimeout(self::TIMEOUT) + ->setCapabilities(new ClientCapabilities(roots: true, rootsListChanged: true, sampling: true, elicitation: true)) + ->addRequestHandler(new SamplingRequestHandler(new class implements SamplingCallbackInterface { + public function __invoke(CreateSamplingMessageRequest $request): CreateSamplingMessageResult + { + EverythingServerTestCase::record($request); + + return new CreateSamplingMessageResult(Role::Assistant, new TextContent('A sampled answer.'), 'interop-model', 'endTurn'); + } + })) + ->addRequestHandler(new ElicitationRequestHandler(new class implements ElicitationCallbackInterface { + public function __invoke(ElicitRequest $request): ElicitResult + { + EverythingServerTestCase::record($request); + + return new ElicitResult(ElicitAction::Accept, [ + 'name' => 'Ada Lovelace', + 'check' => true, + 'email' => 'ada@example.com', + 'integer' => 42, + 'untitledSingleSelectEnum' => 'Rachel', + ]); + } + })) + ->addRequestHandler(new ListRootsRequestHandler(new class implements RootsCallbackInterface { + public function __invoke(ListRootsRequest $request): ListRootsResult + { + EverythingServerTestCase::record($request); + + return new ListRootsResult([new Root('file:///workspace/app', 'Application')]); + } + })) + ->build(); + + self::$transport = new RecordingTransport(static::transport()); + self::$client->connect(self::$transport); + + foreach (self::$transport->received() as $message) { + if (isset($message->result->serverInfo)) { + self::$initializeResult = $message->result; + } + } + + // The server asks for the client's roots shortly after the handshake, + // outside any request. Wait for it here, so it is not recorded as part + // of whichever scenario happens to run when it arrives. + if (static::receivesUnrelatedServerRequests()) { + $deadline = microtime(true) + 5; + while ([] === self::$serverRequests && microtime(true) < $deadline) { + usleep(100_000); + self::$client->ping(); + } + self::$serverRequests = []; + } + } + + public static function tearDownAfterClass(): void + { + self::$client?->disconnect(); + self::$client = null; + self::$transport = null; + } + + /** + * @internal called by the client's request handlers + */ + public static function record(CreateSamplingMessageRequest|ElicitRequest|ListRootsRequest $request): void + { + $message = self::decode($request); + + self::$serverRequests[json_encode($message->id, \JSON_THROW_ON_ERROR)] = ['method' => $message->method, 'params' => $message->params ?? null]; + } + + /** + * @return iterable + */ + public static function provideScenarios(): iterable + { + foreach ([ + 'initialize', 'ping', + 'tools_list', 'tools_call-echo', 'tools_call-get_sum', 'tools_call-get_structured_content', + 'tools_call-get_tiny_image', 'tools_call-get_annotated_message', 'tools_call-get_resource_links', + 'tools_call-get_resource_reference', 'tools_call-long_running_operation', + 'tools_call-sampling', 'tools_call-elicitation', 'tools_call-roots', 'tools_call-unknown_tool', + 'resources_list', 'resources_templates_list', 'resources_read-static', 'resources_read-template', + 'prompts_list', 'prompts_get-without_arguments', 'prompts_get-with_arguments', + 'completion_complete-prompt', 'completion_complete-resource', + ] as $scenario) { + yield $scenario => [$scenario]; + } + } + + #[DataProvider('provideScenarios')] + public function testScenarioMatchesSnapshot(string $scenario): void + { + $client = self::$client ?? $this->fail('The client did not connect.'); + $transport = self::$transport ?? $this->fail('The client did not connect.'); + + $transport->clear(); + self::$serverRequests = []; + + $parsed = $this->runScenario($scenario, $client); + + $received = $transport->received(); + $wire = $this->wire($scenario, $received); + + $this->assertMatchesSnapshot($scenario, $wire); + $this->assertSame( + self::KNOWN_LOSSES[$scenario] ?? [], + $this->losses($wire, $parsed, $received), + 'What the client parsed differs from what the server sent. Fix the schema class, or, if the loss is known and tracked, list it in KNOWN_LOSSES.', + ); + } + + /** + * What the server sent during the scenario, as it arrived. + * + * @param list<\stdClass> $received + * + * @return array + */ + private function wire(string $scenario, array $received): array + { + $result = 'initialize' === $scenario ? self::$initializeResult : null; + $requests = []; + $progress = []; + + foreach ($received as $message) { + if (isset($message->method, $message->id)) { + $requests[] = ['method' => $message->method, 'params' => $message->params ?? null]; + } elseif ('notifications/progress' === ($message->method ?? null)) { + // The token is the client's own, not something the server said. + unset($message->params->progressToken); + $progress[] = $message->params; + } elseif (property_exists($message, 'result') || property_exists($message, 'error')) { + $result = $message->result ?? (object) ['error' => $message->error]; + } + } + + return array_filter([ + 'result' => $result, + 'serverRequests' => $requests, + 'progress' => $progress, + ], static fn (mixed $value): bool => null !== $value && [] !== $value); + } + + /** + * Where the client's parsed objects, encoded again, differ from the wire. + * + * Server requests are paired by JSON-RPC id, so one no handler recorded + * cannot shift the rest onto the wrong partner. + * + * @param array $wire + * @param list<\stdClass> $received + * + * @return list + */ + private function losses(array $wire, mixed $parsed, array $received): array + { + $losses = []; + + if (null !== $parsed) { + $this->compare($wire['result'] ?? null, self::decode($parsed), 'result', $losses); + } + + foreach ($received as $message) { + if (!isset($message->method, $message->id)) { + continue; + } + + $handled = self::$serverRequests[json_encode($message->id, \JSON_THROW_ON_ERROR)] ?? null; + if (null === $handled) { + $losses[] = 'unrecorded serverRequest '.$message->method; + continue; + } + + $this->compare($message->params ?? null, $handled['params'], 'serverRequests.'.$message->method.'.params', $losses); + } + + $losses = array_values(array_unique(array_map( + static fn (string $loss): string => preg_replace('/\.\d+(?=\.|$)/', '.*', $loss), + $losses, + ))); + sort($losses); + + return $losses; + } + + /** + * Objects and lists are told apart, as are numbers and numeric strings; + * only int and float are interchangeable, as JSON does not tell 1 from 1.0. + * + * @param list $losses + */ + private function compare(mixed $wire, mixed $parsed, string $path, array &$losses): void + { + if ($wire instanceof \stdClass && $parsed instanceof \stdClass) { + $this->compareMembers(get_object_vars($wire), get_object_vars($parsed), $path, $losses, defaults: true); + + return; + } + + if (\is_array($wire) && \is_array($parsed)) { + $this->compareMembers($wire, $parsed, $path, $losses, defaults: false); + + return; + } + + $numbers = (\is_int($wire) || \is_float($wire)) && (\is_int($parsed) || \is_float($parsed)); + if ($numbers ? $wire != $parsed : $wire !== $parsed) { + $losses[] = 'changed '.$path; + } + } + + /** + * @param array $wire + * @param array $parsed + * @param list $losses + */ + private function compareMembers(array $wire, array $parsed, string $path, array &$losses, bool $defaults): void + { + foreach ($wire as $key => $value) { + if (!\array_key_exists($key, $parsed)) { + $losses[] = \sprintf('missing %s.%s', $path, $key); + continue; + } + + $this->compare($value, $parsed[$key], $path.'.'.$key, $losses); + } + + foreach ($parsed as $key => $value) { + $isDefault = $defaults && \array_key_exists($key, self::SPEC_DEFAULTS) && self::SPEC_DEFAULTS[$key] === $value; + if (!\array_key_exists($key, $wire) && !$isDefault) { + $losses[] = \sprintf('added %s.%s', $path, $key); + } + } + } + + /** + * Encoded and decoded again, into objects like the wire messages. + */ + private static function decode(mixed $value): mixed + { + return json_decode(json_encode($value, \JSON_THROW_ON_ERROR), flags: \JSON_THROW_ON_ERROR); + } + + private function runScenario(string $scenario, Client $client): mixed + { + // The server only reports progress to a client asking for it. + $onProgress = static function (): void {}; + + return match ($scenario) { + // The client keeps no more than this of the handshake; parse it the way it does. + 'initialize' => InitializeResult::fromArray(json_decode(json_encode(self::$initializeResult, \JSON_THROW_ON_ERROR), true, flags: \JSON_THROW_ON_ERROR)), + // Nothing to parse. + 'ping' => $client->ping(), + 'tools_list' => $client->listTools(), + 'tools_call-echo' => $client->callTool('echo', ['message' => 'Hello from PHP']), + 'tools_call-get_sum' => $client->callTool('get-sum', ['a' => 2, 'b' => 40]), + 'tools_call-get_structured_content' => $client->callTool('get-structured-content', ['location' => 'Chicago']), + 'tools_call-get_tiny_image' => $client->callTool('get-tiny-image'), + 'tools_call-get_annotated_message' => $client->callTool('get-annotated-message', ['messageType' => 'error', 'includeImage' => true]), + 'tools_call-get_resource_links' => $client->callTool('get-resource-links', ['count' => 2]), + 'tools_call-get_resource_reference' => $client->callTool('get-resource-reference', ['resourceType' => 'Text', 'resourceId' => 3]), + 'tools_call-long_running_operation' => $client->callTool('trigger-long-running-operation', ['duration' => 1, 'steps' => 2], $onProgress), + 'tools_call-sampling' => $client->callTool('trigger-sampling-request', ['prompt' => 'Say hello', 'maxTokens' => 20]), + 'tools_call-elicitation' => $client->callTool('trigger-elicitation-request'), + 'tools_call-roots' => $this->callRootsTool($client), + 'tools_call-unknown_tool' => $client->callTool('does-not-exist'), + 'resources_list' => $client->listResources(), + 'resources_templates_list' => $client->listResourceTemplates(), + 'resources_read-static' => $client->readResource('demo://resource/static/document/architecture.md'), + 'resources_read-template' => $client->readResource('demo://resource/dynamic/text/7'), + 'prompts_list' => $client->listPrompts(), + 'prompts_get-without_arguments' => $client->getPrompt('simple-prompt'), + 'prompts_get-with_arguments' => $client->getPrompt('args-prompt', ['city' => 'Berlin', 'state' => 'Berlin']), + 'completion_complete-prompt' => $client->complete(new PromptReference('completable-prompt'), ['name' => 'department', 'value' => 'E']), + 'completion_complete-resource' => $client->complete(new ResourceReference('demo://resource/dynamic/text/{resourceId}'), ['name' => 'resourceId', 'value' => '1']), + default => throw new \LogicException(\sprintf('Unknown scenario "%s".', $scenario)), + }; + } + + /** + * Whether the server can reach the client with a request that is not part + * of a request the client sent. + */ + protected static function receivesUnrelatedServerRequests(): bool + { + return true; + } + + private function callRootsTool(Client $client): mixed + { + // The server asks for roots outside the tool call it is answering. Over + // Streamable HTTP that request travels on the standalone GET stream, + // which the HTTP transport does not open yet. + if (!static::receivesUnrelatedServerRequests()) { + $this->markTestSkipped('The client transport does not receive server requests sent outside of a client request.'); + } + + return $client->callTool('get-roots-list'); + } + + private function assertMatchesSnapshot(string $scenario, mixed $data): void + { + $json = json_encode($data, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_THROW_ON_ERROR); + + // The dynamic resources stamp the time they were generated into their text. + $json = preg_replace('/\d{1,2}:\d{2}:\d{2}(?:\s?[AP]M)?/u', '