[Server] Serve an extension's methods under the modern lifecycle (#454)
* [Server] Serve an extension's methods under the modern lifecycle The modern dispatcher takes the method-to-extension map the builder collects, so a method belonging to an extension this server does not serve is answered -32601 naming the extension instead of a bare "no handler found". It is still an unknown method - the server genuinely does not implement it - but the caller can now act on the answer. * [Server] Reject two extensions claiming the same RPC method The message factory resolves a contested method to whichever class registered first, while extensionMethods kept the last one — so error messages could name the wrong extension. Also fixes a test that claimed to prove a method gets named by its extension while asserting the opposite; the case it meant to cover (an enabled extension with no handler for one of its methods) had no coverage at all.
This commit is contained in:
parent
7e38c94954
commit
b97389325f
1 changed files with 165 additions and 4 deletions
|
|
@ -34,6 +34,8 @@ use Mcp\Server\Stateless\StatelessProtocol;
|
|||
use Mcp\Server\Stateless\StatelessResult;
|
||||
use Mcp\Server\Subscription\InMemoryNotificationBus;
|
||||
use Mcp\Server\Wire\CachePolicy;
|
||||
use Mcp\Tests\Unit\Server\Extension\ThingExtension;
|
||||
use Mcp\Tests\Unit\Server\Extension\UnservedThingExtension;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
|
@ -791,6 +793,74 @@ class StatelessProtocolTest extends TestCase
|
|||
$this->assertSame(['toolsListChanged' => true], (array) $first['params']['notifications']);
|
||||
}
|
||||
|
||||
#[TestDox('an extension method is served by the extension that claims it')]
|
||||
public function testExtensionMethodIsServed(): void
|
||||
{
|
||||
$protocol = Server::builder()
|
||||
->setServerInfo('test-server', '1.0.0')
|
||||
->enableExtension(new ThingExtension())
|
||||
->buildStateless([ProtocolVersion::V2026_07_28]);
|
||||
|
||||
$answer = self::callWithHeaders($protocol, 'com.example/things.list', [], [
|
||||
'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value,
|
||||
'Mcp-Method' => 'com.example/things.list',
|
||||
]);
|
||||
|
||||
$this->assertSame(200, $answer['status']);
|
||||
$this->assertSame(['a', 'b'], $answer['body']['result']['things']);
|
||||
}
|
||||
|
||||
#[TestDox('the extension is advertised under capabilities.extensions')]
|
||||
public function testExtensionIsAdvertised(): void
|
||||
{
|
||||
$protocol = Server::builder()
|
||||
->setServerInfo('test-server', '1.0.0')
|
||||
->enableExtension(new ThingExtension())
|
||||
->buildStateless([ProtocolVersion::V2026_07_28]);
|
||||
|
||||
$answer = self::call($protocol, 'server/discover');
|
||||
|
||||
$this->assertSame(['flavour' => 'vanilla'], (array) $answer['body']['result']['capabilities']['extensions']['com.example/things']);
|
||||
}
|
||||
|
||||
#[TestDox('a method of an extension this server has never heard of stays generic')]
|
||||
public function testUnknownExtensionMethodStaysGeneric(): void
|
||||
{
|
||||
$protocol = Server::builder()
|
||||
->setServerInfo('test-server', '1.0.0')
|
||||
->buildStateless([ProtocolVersion::V2026_07_28]);
|
||||
|
||||
$answer = self::callWithHeaders($protocol, 'com.example/things.list', [], [
|
||||
'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value,
|
||||
'Mcp-Method' => 'com.example/things.list',
|
||||
]);
|
||||
|
||||
$this->assertSame(404, $answer['status']);
|
||||
$this->assertSame(Error::METHOD_NOT_FOUND, $answer['body']['error']['code']);
|
||||
// The extension was never enabled, so it never entered the method map
|
||||
// — there is nothing to name it by.
|
||||
$this->assertStringContainsString('com.example/things.list', $answer['body']['error']['message']);
|
||||
$this->assertStringNotContainsString('extension', $answer['body']['error']['message']);
|
||||
}
|
||||
|
||||
#[TestDox('a method of an extension this server does not serve says so by name')]
|
||||
public function testUnservedExtensionMethodNamesItsExtension(): void
|
||||
{
|
||||
$protocol = Server::builder()
|
||||
->setServerInfo('test-server', '1.0.0')
|
||||
->enableExtension(new UnservedThingExtension())
|
||||
->buildStateless([ProtocolVersion::V2026_07_28]);
|
||||
|
||||
$answer = self::callWithHeaders($protocol, 'com.example/things.list', [], [
|
||||
'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value,
|
||||
'Mcp-Method' => 'com.example/things.list',
|
||||
]);
|
||||
|
||||
$this->assertSame(404, $answer['status']);
|
||||
$this->assertSame(Error::METHOD_NOT_FOUND, $answer['body']['error']['code']);
|
||||
$this->assertStringContainsString('com.example/unserved-things', $answer['body']['error']['message']);
|
||||
}
|
||||
|
||||
#[TestDox('a notification is acknowledged with no body, never answered')]
|
||||
public function testNotificationIsAcknowledged(): void
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue