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', '