Skip to content

Commit a4ed427

Browse files
committed
Added possibility to set custom ServerCapabilities.
Implemented Application tests example
1 parent 345b94d commit a4ed427

File tree

12 files changed

+433
-11
lines changed

12 files changed

+433
-11
lines changed

examples/stdio-explicit-registration/server.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
chdir(__DIR__);
1515

1616
use Mcp\Example\StdioExplicitRegistration\SimpleHandlers;
17+
use Mcp\Schema\ServerCapabilities;
1718
use Mcp\Server;
1819
use Mcp\Server\Transport\StdioTransport;
1920

@@ -27,6 +28,17 @@
2728
->addResource([SimpleHandlers::class, 'getAppVersion'], 'app://version', 'application_version', mimeType: 'text/plain')
2829
->addPrompt([SimpleHandlers::class, 'greetingPrompt'], 'personalized_greeting')
2930
->addResourceTemplate([SimpleHandlers::class, 'getItemDetails'], 'item://{itemId}/details', 'get_item_details', mimeType: 'application/json')
31+
->setServerCapabilities(new ServerCapabilities(
32+
tools: true,
33+
toolsListChanged: false,
34+
resources: true,
35+
resourcesSubscribe: false,
36+
resourcesListChanged: false,
37+
prompts: true,
38+
promptsListChanged: false,
39+
logging: false,
40+
completions: false,
41+
))
3042
->build();
3143

3244
$transport = new StdioTransport(logger: logger());

phpunit.xml.dist

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
<testsuite name="unit">
1313
<directory>tests/Unit</directory>
1414
</testsuite>
15+
<testsuite name="application">
16+
<directory>tests/Application</directory>
17+
</testsuite>
1518
<testsuite name="inspector">
1619
<directory>tests/Inspector</directory>
1720
</testsuite>

src/Capability/Registry.php

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ final class Registry implements ReferenceProviderInterface, ReferenceRegistryInt
6363
*/
6464
private array $resourceTemplates = [];
6565

66+
private ServerCapabilities $serverCapabilities;
67+
6668
public function __construct(
6769
private readonly ?EventDispatcherInterface $eventDispatcher = null,
6870
private readonly LoggerInterface $logger = new NullLogger(),
@@ -74,18 +76,11 @@ public function getCapabilities(): ServerCapabilities
7476
if (!$this->hasElements()) {
7577
$this->logger->info('No capabilities registered on server.');
7678
}
79+
if (!isset($this->serverCapabilities)) {
80+
$this->setServerCapabilities(null);
81+
}
7782

78-
return new ServerCapabilities(
79-
tools: [] !== $this->tools,
80-
toolsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface,
81-
resources: [] !== $this->resources || [] !== $this->resourceTemplates,
82-
resourcesSubscribe: false,
83-
resourcesListChanged: $this->eventDispatcher instanceof EventDispatcherInterface,
84-
prompts: [] !== $this->prompts,
85-
promptsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface,
86-
logging: false,
87-
completions: true,
88-
);
83+
return $this->serverCapabilities;
8984
}
9085

9186
public function registerTool(Tool $tool, callable|array|string $handler, bool $isManual = false): void
@@ -453,4 +448,23 @@ private function paginateResults(array $items, int $limit, ?string $cursor = nul
453448

454449
return array_values(\array_slice($items, $offset, $limit));
455450
}
451+
452+
public function setServerCapabilities(?ServerCapabilities $serverCapabilities): void
453+
{
454+
if ($serverCapabilities) {
455+
$this->serverCapabilities = $serverCapabilities;
456+
} else {
457+
$this->serverCapabilities = new ServerCapabilities(
458+
tools: [] !== $this->tools,
459+
toolsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface,
460+
resources: [] !== $this->resources || [] !== $this->resourceTemplates,
461+
resourcesSubscribe: false,
462+
resourcesListChanged: $this->eventDispatcher instanceof EventDispatcherInterface,
463+
prompts: [] !== $this->prompts,
464+
promptsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface,
465+
logging: false,
466+
completions: true,
467+
);
468+
}
469+
}
456470
}

src/Server/Builder.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
use Mcp\Schema\PromptArgument;
3636
use Mcp\Schema\Resource;
3737
use Mcp\Schema\ResourceTemplate;
38+
use Mcp\Schema\ServerCapabilities;
3839
use Mcp\Schema\Tool;
3940
use Mcp\Schema\ToolAnnotations;
4041
use Mcp\Server;
@@ -128,6 +129,8 @@ final class Builder
128129
*/
129130
private array $discoveryExcludeDirs = [];
130131

132+
private ?ServerCapabilities $serverCapabilities = null;
133+
131134
/**
132135
* Sets the server's identity. Required.
133136
*/
@@ -237,6 +240,13 @@ public function setDiscovery(
237240
return $this;
238241
}
239242

243+
public function setServerCapabilities(ServerCapabilities $serverCapabilities): self
244+
{
245+
$this->serverCapabilities = $serverCapabilities;
246+
247+
return $this;
248+
}
249+
240250
/**
241251
* Manually registers a tool handler.
242252
*/
@@ -318,6 +328,7 @@ public function build(): Server
318328
$promptGetter = $this->promptGetter ??= new PromptGetter($registry, $referenceHandler, $logger);
319329

320330
$this->registerCapabilities($registry, $logger);
331+
$registry->setServerCapabilities($this->serverCapabilities);
321332

322333
if (null !== $this->discoveryBasePath) {
323334
$discovery = new Discoverer($registry, $logger);
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the official PHP MCP SDK.
7+
*
8+
* A collaboration between Symfony and the PHP Foundation.
9+
*
10+
* For the full copyright and license information, please view the LICENSE
11+
* file that was distributed with this source code.
12+
*/
13+
14+
namespace Mcp\Tests\Application;
15+
16+
use Mcp\Schema\JsonRpc\MessageInterface;
17+
use PHPUnit\Framework\TestCase;
18+
use Symfony\Component\Process\Process;
19+
20+
abstract class ApplicationTestCase extends TestCase
21+
{
22+
/**
23+
* @param list<string> $messages
24+
* @param array<string,string> $env
25+
*
26+
* @return array<string, array<string, array<string,mixed>>>
27+
*/
28+
protected function runServer(array $messages, float $timeout = 5.0, array $env = []): array
29+
{
30+
if (0 === \count($messages)) {
31+
return [];
32+
}
33+
34+
$process = new Process([
35+
'php',
36+
$this->getServerScript(),
37+
], \dirname(__DIR__, 2), [] === $env ? null : $env, null, $timeout);
38+
39+
$process->setInput($this->formatInput($messages));
40+
$process->mustRun();
41+
42+
return $this->decodeJsonLines($process->getOutput());
43+
}
44+
45+
abstract protected function getServerScript(): string;
46+
47+
/**
48+
* @param mixed[] $params
49+
*
50+
* @throws \JsonException
51+
*/
52+
protected function jsonRequest(string $method, ?array $params = null, ?string $id = null): string
53+
{
54+
$payload = [
55+
'jsonrpc' => MessageInterface::JSONRPC_VERSION,
56+
'id' => $id,
57+
'method' => $method,
58+
];
59+
60+
if (null !== $params) {
61+
$payload['params'] = $params;
62+
}
63+
64+
return (string) json_encode($payload, \JSON_THROW_ON_ERROR);
65+
}
66+
67+
/**
68+
* @param list<string> $messages
69+
*/
70+
private function formatInput(array $messages): string
71+
{
72+
return implode("\n", $messages)."\n";
73+
}
74+
75+
/**
76+
* @return array<string, array<string, array<string,mixed>>>
77+
*/
78+
private function decodeJsonLines(string $output): array
79+
{
80+
$output = trim($output);
81+
$responses = [];
82+
83+
if ('' === $output) {
84+
return $responses;
85+
}
86+
87+
foreach (preg_split('/\R+/', $output) as $line) {
88+
if ('' === $line) {
89+
continue;
90+
}
91+
92+
try {
93+
$decoded = json_decode($line, true, 512, \JSON_THROW_ON_ERROR);
94+
} catch (\JsonException) {
95+
continue;
96+
}
97+
98+
if (!\is_array($decoded)) {
99+
continue;
100+
}
101+
102+
$id = $decoded['id'] ?? null;
103+
104+
if (\is_string($id) || \is_int($id)) {
105+
$responses[(string) $id] = $decoded;
106+
}
107+
}
108+
109+
return $responses;
110+
}
111+
112+
protected function getSnapshotFilePath(string $method): string
113+
{
114+
$className = substr(static::class, strrpos(static::class, '\\') + 1);
115+
116+
return __DIR__.'/snapshots/'.$className.'-'.str_replace('/', '_', $method).'.json';
117+
}
118+
119+
/**
120+
* @return array<string,mixed>
121+
*
122+
* @throws \JsonException
123+
*/
124+
protected function loadSnapshot(string $method): array
125+
{
126+
$path = $this->getSnapshotFilePath($method);
127+
128+
$contents = file_get_contents($path);
129+
$this->assertNotFalse($contents, 'Failed to read snapshot: '.$path);
130+
131+
return json_decode($contents, true, 512, \JSON_THROW_ON_ERROR);
132+
}
133+
134+
/**
135+
* @param array<string, array<string, mixed>> $response
136+
*
137+
* @throws \JsonException
138+
*/
139+
protected function assertResponseMatchesSnapshot(array $response, string $method): void
140+
{
141+
$this->assertArrayHasKey('result', $response);
142+
$actual = $response['result'];
143+
144+
$expected = $this->loadSnapshot($method);
145+
146+
$this->assertEquals($expected, $actual, 'Response payload does not match snapshot '.$this->getSnapshotFilePath($method));
147+
}
148+
149+
/**
150+
* @param array<string, array<string, mixed>> $capabilities
151+
* @param string[] $clientInfo
152+
*
153+
* @throws \JsonException
154+
*/
155+
protected function initializeMessage(
156+
?string $id = null,
157+
string $protocolVersion = MessageInterface::PROTOCOL_VERSION,
158+
array $capabilities = [],
159+
array $clientInfo = [
160+
'name' => 'test-suite',
161+
'version' => '1.0.0',
162+
],
163+
): string {
164+
$id ??= uniqid();
165+
166+
return $this->jsonRequest('initialize', [
167+
'protocolVersion' => $protocolVersion,
168+
'capabilities' => $capabilities,
169+
'clientInfo' => $clientInfo,
170+
], $id);
171+
}
172+
}

0 commit comments

Comments
 (0)