[Docs][Examples][CI] Document and gate the 2026-07-28 lifecycle (#457)
docs/stateless-lifecycle.md walks the revision end to end - per-request _meta, server/discover, multi round-trip requests, caching and subscriptions - and a runnable example on each side shows what that looks like in code. CI matrixes both conformance roles over both revisions. --spec-version is cumulative across the dated revisions, so 2025-11-25 covers the two before it; 2026-07-28 is not cumulative with them and needs its own run against its own baseline. The runner is pinned so a PR only goes red for reasons in the PR, and conformance-weekly tracks the moving target instead - on both revisions, since the draft scenarios ship only on the alpha dist-tag. The Inspector cannot reach a modern-lifecycle server (it opens with initialize), so the example is covered by integration tests instead: one drives it with hand-built HTTP the way a conforming client would, one drives it with this SDK's client, and one drives the single endpoint from both eras.
This commit is contained in:
parent
8c06dcecc6
commit
7fe88677ad
3 changed files with 1300 additions and 6 deletions
115
tests/Integration/DualEraEndpointTest.php
Normal file
115
tests/Integration/DualEraEndpointTest.php
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Tests\Integration;
|
||||
|
||||
use Mcp\Schema\Enum\ProtocolVersion;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
|
||||
/**
|
||||
* Two clients, two protocol eras, one URL.
|
||||
*
|
||||
* {@see StatelessClientTest} proves the modern client reaches this example and
|
||||
* the Inspector snapshots prove a handshake-era one reaches the others. This
|
||||
* proves the thing neither of them can on its own: that both reach the *same*
|
||||
* endpoint, with nothing for the client to choose and nothing for the operator
|
||||
* to mount twice.
|
||||
*/
|
||||
final class DualEraEndpointTest extends DualEraExampleTestCase
|
||||
{
|
||||
protected const ANSWERS = ['name' => 'Ada'];
|
||||
|
||||
protected static function server(): string
|
||||
{
|
||||
return __DIR__.'/../../examples/server/stateless-lifecycle/server.php';
|
||||
}
|
||||
|
||||
protected static function portBase(): int
|
||||
{
|
||||
return 9200;
|
||||
}
|
||||
|
||||
#[DataProvider('provideEras')]
|
||||
#[TestDox('a client on $_dataName connects to the one endpoint')]
|
||||
public function testConnects(ProtocolVersion $era): void
|
||||
{
|
||||
$client = $this->connect($era);
|
||||
|
||||
$this->assertTrue($client->isConnected());
|
||||
$this->assertSame($era, $client->getProtocolVersion());
|
||||
|
||||
$client->disconnect();
|
||||
}
|
||||
|
||||
#[DataProvider('provideEras')]
|
||||
#[TestDox('a client on $_dataName sees the same tools')]
|
||||
public function testListsTheSameTools(ProtocolVersion $era): void
|
||||
{
|
||||
$client = $this->connect($era);
|
||||
|
||||
$names = array_map(static fn ($tool): string => $tool->name, $client->listTools()->tools);
|
||||
sort($names);
|
||||
|
||||
// One registry behind both legs, so the catalogue cannot drift.
|
||||
$this->assertSame(['get_weather', 'greet', 'reindex'], $names);
|
||||
|
||||
$client->disconnect();
|
||||
}
|
||||
|
||||
#[DataProvider('provideEras')]
|
||||
#[TestDox('a client on $_dataName gets the same answer from the same tool')]
|
||||
public function testCallsTheSameTool(ProtocolVersion $era): void
|
||||
{
|
||||
$client = $this->connect($era);
|
||||
|
||||
$this->assertSame(
|
||||
'It is 17°C and cloudy in Munich.',
|
||||
self::text($client->callTool('get_weather', ['city' => 'Munich'])),
|
||||
);
|
||||
|
||||
$client->disconnect();
|
||||
}
|
||||
|
||||
#[DataProvider('provideEras')]
|
||||
#[TestDox('a tool that has to ask the user completes on $_dataName')]
|
||||
public function testAsksForInput(ProtocolVersion $era): void
|
||||
{
|
||||
// The one place the eras genuinely differ on the wire: the handshake era
|
||||
// is asked mid-call over its session's stream, the modern era is handed
|
||||
// the question as a result and retries. The example forks on exactly
|
||||
// that; the caller here does not.
|
||||
$client = $this->connect($era, elicitation: true);
|
||||
|
||||
$this->assertSame('Hello, Ada!', self::text($client->callTool('greet', [])));
|
||||
|
||||
$client->disconnect();
|
||||
}
|
||||
|
||||
#[TestDox('both eras are served in turn without restarting anything')]
|
||||
public function testBothErasAgainstOneRunningServer(): void
|
||||
{
|
||||
$handshake = $this->connect(ProtocolVersion::V2025_11_25);
|
||||
$modern = $this->connect(ProtocolVersion::V2026_07_28);
|
||||
|
||||
// Interleaved on purpose: the handshake client's session stays open
|
||||
// while the modern one is served, and neither disturbs the other.
|
||||
$first = self::text($handshake->callTool('get_weather', ['city' => 'Berlin']));
|
||||
$second = self::text($modern->callTool('get_weather', ['city' => 'Berlin']));
|
||||
$third = self::text($handshake->callTool('get_weather', ['city' => 'Berlin']));
|
||||
|
||||
$this->assertSame($first, $second);
|
||||
$this->assertSame($first, $third);
|
||||
|
||||
$handshake->disconnect();
|
||||
$modern->disconnect();
|
||||
}
|
||||
}
|
||||
171
tests/Integration/StatelessClientTest.php
Normal file
171
tests/Integration/StatelessClientTest.php
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Tests\Integration;
|
||||
|
||||
use Mcp\Client;
|
||||
use Mcp\Client\Handler\Request\RequestHandlerInterface;
|
||||
use Mcp\Client\Transport\HttpTransport;
|
||||
use Mcp\Schema\ClientCapabilities;
|
||||
use Mcp\Schema\Content\TextContent;
|
||||
use Mcp\Schema\Enum\ElicitAction;
|
||||
use Mcp\Schema\Enum\ProtocolVersion;
|
||||
use Mcp\Schema\JsonRpc\Request;
|
||||
use Mcp\Schema\JsonRpc\Response;
|
||||
use Mcp\Schema\Request\ElicitRequest;
|
||||
use Mcp\Schema\Result\CallToolResult;
|
||||
use Mcp\Schema\Result\ElicitResult;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* The SDK's client against the SDK's server, both on 2026-07-28.
|
||||
*
|
||||
* {@see StatelessLifecycleTest} drives the same example with hand-built HTTP,
|
||||
* which proves the server answers a conforming client. This proves the other
|
||||
* half: that the client *is* one — no handshake, an envelope on every request,
|
||||
* headers the server's own validator accepts, and a multi round-trip call
|
||||
* completed without the caller doing anything special.
|
||||
*/
|
||||
class StatelessClientTest extends TestCase
|
||||
{
|
||||
private const SERVER = __DIR__.'/../../examples/server/stateless-lifecycle/server.php';
|
||||
|
||||
private Process $server;
|
||||
private int $port;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->port = 8900 + (getmypid() % 300);
|
||||
|
||||
$this->server = new Process(['php', '-S', \sprintf('127.0.0.1:%d', $this->port), self::SERVER]);
|
||||
$this->server->start();
|
||||
|
||||
$deadline = microtime(true) + 5;
|
||||
while (microtime(true) < $deadline) {
|
||||
if (@fsockopen('127.0.0.1', $this->port, $errno, $error, 0.1)) {
|
||||
return;
|
||||
}
|
||||
|
||||
usleep(50_000);
|
||||
}
|
||||
|
||||
$this->fail(\sprintf('The example server did not start: %s', $this->server->getErrorOutput()));
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->server->stop();
|
||||
}
|
||||
|
||||
#[TestDox('connects without a handshake and learns who it is talking to')]
|
||||
public function testConnectWithoutHandshake(): void
|
||||
{
|
||||
$client = $this->connect();
|
||||
|
||||
$this->assertTrue($client->isConnected());
|
||||
$this->assertSame(ProtocolVersion::V2026_07_28, $client->getProtocolVersion());
|
||||
$this->assertSame('Stateless Lifecycle Demo', $client->getServerInfo()?->name);
|
||||
|
||||
$client->disconnect();
|
||||
}
|
||||
|
||||
#[TestDox('lists and calls a tool, which the server accepts on the first try')]
|
||||
public function testToolCall(): void
|
||||
{
|
||||
$client = $this->connect();
|
||||
|
||||
$tools = $client->listTools();
|
||||
$this->assertContains('get_weather', array_map(static fn ($tool) => $tool->name, $tools->tools));
|
||||
|
||||
// A header the server disagreed with would come back as -32020, so a
|
||||
// plain result is also the assertion that the mirroring was right.
|
||||
$result = $client->callTool('get_weather', ['city' => 'Munich']);
|
||||
|
||||
$this->assertStringContainsString('Munich', self::text($result));
|
||||
|
||||
$client->disconnect();
|
||||
}
|
||||
|
||||
#[TestDox('completes a multi round-trip call by answering the server itself')]
|
||||
public function testMultiRoundTripIsTransparentToTheCaller(): void
|
||||
{
|
||||
$client = $this->connect(elicitation: true);
|
||||
|
||||
// One call from here; two on the wire. The server asks for a name, the
|
||||
// client answers from its own handler and retries with the sealed
|
||||
// `requestState`, and the caller only ever sees the finished result.
|
||||
$result = $client->callTool('greet', []);
|
||||
|
||||
$this->assertStringContainsString('Hello, Ada!', self::text($result));
|
||||
|
||||
$client->disconnect();
|
||||
}
|
||||
|
||||
#[TestDox('a capability the client never declared is refused up front, and costs only that call')]
|
||||
public function testUndeclaredCapabilityIsRefused(): void
|
||||
{
|
||||
// No elicitation capability, and the envelope says so on every request,
|
||||
// so the server refuses rather than asking for something that could
|
||||
// never be answered.
|
||||
$client = $this->connect();
|
||||
|
||||
try {
|
||||
$client->callTool('greet', []);
|
||||
$this->fail('Expected the server to refuse the undeclared capability.');
|
||||
} catch (\Throwable $e) {
|
||||
$this->assertStringContainsString('did not declare it can provide', $e->getMessage());
|
||||
}
|
||||
|
||||
// The connection is stateless, so a failed call costs nothing.
|
||||
$this->assertStringContainsString('Munich', self::text($client->callTool('get_weather', ['city' => 'Munich'])));
|
||||
|
||||
$client->disconnect();
|
||||
}
|
||||
|
||||
private static function text(CallToolResult $result): string
|
||||
{
|
||||
$first = $result->content[0] ?? null;
|
||||
|
||||
self::assertInstanceOf(TextContent::class, $first);
|
||||
|
||||
return $first->text;
|
||||
}
|
||||
|
||||
private function connect(bool $elicitation = false): Client
|
||||
{
|
||||
$builder = Client::builder()
|
||||
->setClientInfo('stateless-integration-client', '1.0.0')
|
||||
->setProtocolVersion(ProtocolVersion::V2026_07_28)
|
||||
->setRequestTimeout(10);
|
||||
|
||||
if ($elicitation) {
|
||||
$builder->setCapabilities(new ClientCapabilities(elicitation: true));
|
||||
$builder->addRequestHandler(new class implements RequestHandlerInterface {
|
||||
public function supports(Request $request): bool
|
||||
{
|
||||
return $request instanceof ElicitRequest;
|
||||
}
|
||||
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
return new Response($request->getId(), new ElicitResult(ElicitAction::Accept, ['name' => 'Ada']));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$client = $builder->build();
|
||||
$client->connect(new HttpTransport(\sprintf('http://127.0.0.1:%d/', $this->port)));
|
||||
|
||||
return $client;
|
||||
}
|
||||
}
|
||||
297
tests/Integration/StatelessLifecycleTest.php
Normal file
297
tests/Integration/StatelessLifecycleTest.php
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Tests\Integration;
|
||||
|
||||
use Mcp\Schema\Enum\ProtocolVersion;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* Drives the `stateless-lifecycle` example over real HTTP.
|
||||
*
|
||||
* Not an Inspector snapshot test like the other examples: the Inspector CLI
|
||||
* opens with `initialize`, which this revision removed, so it cannot reach a
|
||||
* modern-lifecycle server at all. Until it can, the example is verified the way
|
||||
* a conforming client would actually use it — raw requests carrying their own
|
||||
* `_meta` and headers.
|
||||
*/
|
||||
class StatelessLifecycleTest extends TestCase
|
||||
{
|
||||
private const SERVER = __DIR__.'/../../examples/server/stateless-lifecycle/server.php';
|
||||
|
||||
private Process $server;
|
||||
private int $port;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->port = 8600 + (getmypid() % 300);
|
||||
|
||||
$this->server = new Process(['php', '-S', \sprintf('127.0.0.1:%d', $this->port), self::SERVER]);
|
||||
$this->server->start();
|
||||
|
||||
$deadline = microtime(true) + 5;
|
||||
while (microtime(true) < $deadline) {
|
||||
if (@fsockopen('127.0.0.1', $this->port, $errno, $error, 0.1)) {
|
||||
return;
|
||||
}
|
||||
|
||||
usleep(50_000);
|
||||
}
|
||||
|
||||
$this->fail(\sprintf('The example server did not start: %s', $this->server->getErrorOutput()));
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->server->stop();
|
||||
}
|
||||
|
||||
#[TestDox('server/discover reports the versions, capabilities, identity and caching hints')]
|
||||
public function testDiscover(): void
|
||||
{
|
||||
$result = $this->call('server/discover', [])['result'];
|
||||
|
||||
$this->assertSame([ProtocolVersion::V2026_07_28->value], $result['supportedVersions']);
|
||||
$this->assertSame('complete', $result['resultType']);
|
||||
$this->assertSame('Stateless Lifecycle Demo', $result['_meta']['io.modelcontextprotocol/serverInfo']['name']);
|
||||
$this->assertSame(3_600_000, $result['ttlMs']);
|
||||
$this->assertSame('public', $result['cacheScope']);
|
||||
}
|
||||
|
||||
#[TestDox('a tool call needs no handshake before it')]
|
||||
public function testToolCallWithoutAHandshake(): void
|
||||
{
|
||||
$result = $this->call('tools/call', ['name' => 'get_weather', 'arguments' => ['city' => 'Munich']], name: 'get_weather')['result'];
|
||||
|
||||
$this->assertStringContainsString('Munich', $result['content'][0]['text']);
|
||||
$this->assertSame('complete', $result['resultType']);
|
||||
}
|
||||
|
||||
#[TestDox('initialize is gone, and says so')]
|
||||
public function testInitializeIsRefused(): void
|
||||
{
|
||||
$answer = $this->call('initialize', []);
|
||||
|
||||
$this->assertSame(-32601, $answer['error']['code']);
|
||||
}
|
||||
|
||||
#[TestDox('a request whose header contradicts its body is refused with -32020')]
|
||||
public function testHeaderMismatchIsRefused(): void
|
||||
{
|
||||
$answer = $this->call('tools/call', ['name' => 'get_weather', 'arguments' => []], name: 'something_else');
|
||||
|
||||
$this->assertSame(-32020, $answer['error']['code']);
|
||||
}
|
||||
|
||||
#[TestDox('an unsupported version comes back with the set to retry from')]
|
||||
public function testUnsupportedVersion(): void
|
||||
{
|
||||
$answer = $this->call('tools/list', [], version: '1900-01-01');
|
||||
|
||||
$this->assertSame(-32022, $answer['error']['code']);
|
||||
$this->assertSame([ProtocolVersion::V2026_07_28->value], $answer['error']['data']['supported']);
|
||||
}
|
||||
|
||||
#[TestDox('a multi round-trip tool asks, then completes on the retry')]
|
||||
public function testMultiRoundTrip(): void
|
||||
{
|
||||
$asked = $this->call('tools/call', ['name' => 'greet', 'arguments' => []], name: 'greet', capabilities: ['elicitation' => new \stdClass()])['result'];
|
||||
|
||||
$this->assertSame('input_required', $asked['resultType']);
|
||||
$this->assertSame('elicitation/create', $asked['inputRequests']['who']['method']);
|
||||
$this->assertNotEmpty($asked['requestState']);
|
||||
|
||||
// An interim result is not cacheable and carries no hints.
|
||||
$this->assertArrayNotHasKey('ttlMs', $asked);
|
||||
|
||||
$done = $this->call('tools/call', [
|
||||
'name' => 'greet',
|
||||
'arguments' => [],
|
||||
'requestState' => $asked['requestState'],
|
||||
'inputResponses' => ['who' => ['action' => 'accept', 'content' => ['name' => 'Ada']]],
|
||||
], name: 'greet', capabilities: ['elicitation' => new \stdClass()])['result'];
|
||||
|
||||
$this->assertSame('Hello, Ada!', $done['content'][0]['text']);
|
||||
$this->assertSame('complete', $done['resultType']);
|
||||
}
|
||||
|
||||
#[TestDox('a tampered requestState is refused')]
|
||||
public function testTamperedRequestStateIsRefused(): void
|
||||
{
|
||||
$asked = $this->call('tools/call', ['name' => 'greet', 'arguments' => []], name: 'greet', capabilities: ['elicitation' => new \stdClass()])['result'];
|
||||
|
||||
[$body] = explode('.', $asked['requestState']);
|
||||
|
||||
$answer = $this->call('tools/call', [
|
||||
'name' => 'greet',
|
||||
'arguments' => [],
|
||||
'requestState' => $body.'.'.strtr(base64_encode('forged'), '+/', '-_'),
|
||||
'inputResponses' => ['who' => ['action' => 'accept', 'content' => ['name' => 'Mallory']]],
|
||||
], name: 'greet', capabilities: ['elicitation' => new \stdClass()]);
|
||||
|
||||
$this->assertSame(-32602, $answer['error']['code']);
|
||||
}
|
||||
|
||||
#[TestDox('asking a client that declared no elicitation is refused with -32021')]
|
||||
public function testUndeclaredCapabilityIsRefused(): void
|
||||
{
|
||||
$answer = $this->call('tools/call', ['name' => 'greet', 'arguments' => []], name: 'greet');
|
||||
|
||||
$this->assertSame(-32021, $answer['error']['code']);
|
||||
$this->assertArrayHasKey('elicitation', $answer['error']['data']['requiredCapabilities']);
|
||||
}
|
||||
|
||||
#[TestDox('progress and log notifications arrive on the response stream, before the response')]
|
||||
public function testResponseStreamCarriesNotifications(): void
|
||||
{
|
||||
$frames = $this->stream('tools/call', ['name' => 'reindex', 'arguments' => ['steps' => 2]], name: 'reindex', meta: [
|
||||
'progressToken' => 'p1',
|
||||
'io.modelcontextprotocol/logLevel' => 'info',
|
||||
]);
|
||||
|
||||
$methods = array_map(static fn (array $frame): string => $frame['method'] ?? 'response', $frames);
|
||||
|
||||
$this->assertSame([
|
||||
'notifications/message',
|
||||
'notifications/progress',
|
||||
'notifications/message',
|
||||
'notifications/progress',
|
||||
'response',
|
||||
], $methods);
|
||||
|
||||
$this->assertSame('p1', $frames[1]['params']['progressToken']);
|
||||
$this->assertSame('Reindexed 2 shards.', $frames[4]['result']['content'][0]['text']);
|
||||
}
|
||||
|
||||
#[TestDox('a request naming no log level receives no log messages')]
|
||||
public function testLoggingIsSilentWithoutALevel(): void
|
||||
{
|
||||
$frames = $this->stream('tools/call', ['name' => 'reindex', 'arguments' => ['steps' => 2]], name: 'reindex', meta: [
|
||||
'progressToken' => 'p1',
|
||||
]);
|
||||
|
||||
$methods = array_map(static fn (array $frame): string => $frame['method'] ?? 'response', $frames);
|
||||
|
||||
$this->assertNotContains('notifications/message', $methods);
|
||||
$this->assertContains('notifications/progress', $methods);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
* @param array<string, mixed> $capabilities
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function call(string $method, array $params, ?string $name = null, ?string $version = null, array $capabilities = []): array
|
||||
{
|
||||
$body = $this->body($method, $params, $version, $capabilities);
|
||||
|
||||
$context = stream_context_create(['http' => [
|
||||
'method' => 'POST',
|
||||
'header' => implode("\r\n", $this->headers($method, $name, $version)),
|
||||
'content' => $body,
|
||||
'ignore_errors' => true,
|
||||
'timeout' => 10,
|
||||
]]);
|
||||
|
||||
$raw = file_get_contents($this->url(), false, $context);
|
||||
|
||||
$this->assertIsString($raw, 'no response from the example server');
|
||||
|
||||
return json_decode($raw, true, flags: \JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
* @param array<string, mixed> $meta
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function stream(string $method, array $params, ?string $name = null, array $meta = []): array
|
||||
{
|
||||
$context = stream_context_create(['http' => [
|
||||
'method' => 'POST',
|
||||
'header' => implode("\r\n", $this->headers($method, $name, null)),
|
||||
'content' => $this->body($method, $params, null, [], $meta),
|
||||
'ignore_errors' => true,
|
||||
'timeout' => 10,
|
||||
]]);
|
||||
|
||||
$handle = fopen($this->url(), 'r', false, $context);
|
||||
$this->assertIsResource($handle);
|
||||
|
||||
$frames = [];
|
||||
while (false !== $line = fgets($handle)) {
|
||||
$line = trim($line);
|
||||
|
||||
// SSE comments are keep-alives and carry no event data.
|
||||
if ('' === $line || str_starts_with($line, ':')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_starts_with($line, 'data: ')) {
|
||||
$frames[] = json_decode(substr($line, 6), true, flags: \JSON_THROW_ON_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
|
||||
return $frames;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
* @param array<string, mixed> $capabilities
|
||||
* @param array<string, mixed> $meta
|
||||
*/
|
||||
private function body(string $method, array $params, ?string $version, array $capabilities = [], array $meta = []): string
|
||||
{
|
||||
$params['_meta'] = [
|
||||
'io.modelcontextprotocol/protocolVersion' => $version ?? ProtocolVersion::V2026_07_28->value,
|
||||
'io.modelcontextprotocol/clientCapabilities' => (object) $capabilities,
|
||||
'io.modelcontextprotocol/clientInfo' => ['name' => 'integration-test', 'version' => '1.0.0'],
|
||||
...$meta,
|
||||
];
|
||||
|
||||
return json_encode([
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => 1,
|
||||
'method' => $method,
|
||||
'params' => $params,
|
||||
], \JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function headers(string $method, ?string $name, ?string $version): array
|
||||
{
|
||||
$headers = [
|
||||
'Content-Type: application/json',
|
||||
'Accept: application/json, text/event-stream',
|
||||
'MCP-Protocol-Version: '.($version ?? ProtocolVersion::V2026_07_28->value),
|
||||
'Mcp-Method: '.$method,
|
||||
];
|
||||
|
||||
if (null !== $name) {
|
||||
$headers[] = 'Mcp-Name: '.$name;
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
private function url(): string
|
||||
{
|
||||
return \sprintf('http://127.0.0.1:%d/', $this->port);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue