Skip to content

Commit 83ddc47

Browse files
bigdevlarrychr-hertel
authored andcommitted
Add missing handler for resource subscribe and unsubscribe
1 parent 02e8637 commit 83ddc47

File tree

12 files changed

+781
-9
lines changed

12 files changed

+781
-9
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
66
-----
77

88
* Rename `Mcp\Server\Session\Psr16StoreSession` to `Mcp\Server\Session\Psr16SessionStore`
9+
* Add missing handlers for resource subscribe/unsubscribe and persist subscriptions via session
910

1011
0.3.0
1112
-----

src/Capability/Registry.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,7 @@ public function getDiscoveryState(): DiscoveryState
352352
}
353353

354354
/**
355-
* Set discovery state, replacing all discovered elements.
355+
* Set the discovery state, replacing all discovered elements.
356356
* Manual elements are preserved.
357357
*/
358358
public function setDiscoveryState(DiscoveryState $state): void

src/Server/Builder.php

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
use Mcp\Server;
3434
use Mcp\Server\Handler\Notification\NotificationHandlerInterface;
3535
use Mcp\Server\Handler\Request\RequestHandlerInterface;
36+
use Mcp\Server\Resource\SessionSubscriptionManager;
37+
use Mcp\Server\Resource\SubscriptionManagerInterface;
3638
use Mcp\Server\Session\InMemorySessionStore;
3739
use Mcp\Server\Session\SessionFactory;
3840
use Mcp\Server\Session\SessionFactoryInterface;
@@ -54,6 +56,8 @@ final class Builder
5456

5557
private RegistryInterface $registry;
5658

59+
private ?SubscriptionManagerInterface $subscriptionManager = null;
60+
5761
private ?LoggerInterface $logger = null;
5862

5963
private ?CacheInterface $discoveryCache = null;
@@ -309,6 +313,13 @@ public function setDiscoverer(DiscovererInterface $discoverer): self
309313
return $this;
310314
}
311315

316+
public function setResourceSubscriptionManager(SubscriptionManagerInterface $subscriptionManager): self
317+
{
318+
$this->subscriptionManager = $subscriptionManager;
319+
320+
return $this;
321+
}
322+
312323
public function setSession(
313324
SessionStoreInterface $sessionStore,
314325
SessionFactoryInterface $sessionFactory = new SessionFactory(),
@@ -489,12 +500,16 @@ public function build(): Server
489500
$logger = $this->logger ?? new NullLogger();
490501
$container = $this->container ?? new Container();
491502
$registry = $this->registry ?? new Registry($this->eventDispatcher, $logger);
492-
503+
$subscriptionManager = $this->subscriptionManager ?? new SessionSubscriptionManager($logger);
493504
$loaders = [
494505
...$this->loaders,
495506
new ArrayLoader($this->tools, $this->resources, $this->resourceTemplates, $this->prompts, $logger, $this->schemaGenerator),
496507
];
497508

509+
$sessionTtl = $this->sessionTtl ?? 3600;
510+
$sessionFactory = $this->sessionFactory ?? new SessionFactory();
511+
$sessionStore = $this->sessionStore ?? new InMemorySessionStore($sessionTtl);
512+
498513
if (null !== $this->discoveryBasePath) {
499514
$discoverer = $this->discoverer ?? $this->createDiscoverer($logger);
500515
$loaders[] = new DiscoveryLoader($this->discoveryBasePath, $this->discoveryScanDirs, $this->discoveryExcludeDirs, $discoverer);
@@ -504,16 +519,13 @@ public function build(): Server
504519
$loader->load($registry);
505520
}
506521

507-
$sessionTtl = $this->sessionTtl ?? 3600;
508-
$sessionFactory = $this->sessionFactory ?? new SessionFactory();
509-
$sessionStore = $this->sessionStore ?? new InMemorySessionStore($sessionTtl);
510522
$messageFactory = MessageFactory::make();
511523

512524
$capabilities = $this->serverCapabilities ?? new ServerCapabilities(
513525
tools: $registry->hasTools(),
514526
toolsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface,
515527
resources: $registry->hasResources() || $registry->hasResourceTemplates(),
516-
resourcesSubscribe: false,
528+
resourcesSubscribe: $registry->hasResources() || $registry->hasResourceTemplates(),
517529
resourcesListChanged: $this->eventDispatcher instanceof EventDispatcherInterface,
518530
prompts: $registry->hasPrompts(),
519531
promptsListChanged: $this->eventDispatcher instanceof EventDispatcherInterface,
@@ -536,6 +548,8 @@ public function build(): Server
536548
new Handler\Request\ListToolsHandler($registry, $this->paginationLimit),
537549
new Handler\Request\PingHandler(),
538550
new Handler\Request\ReadResourceHandler($registry, $referenceHandler, $logger),
551+
new Handler\Request\ResourceSubscribeHandler($registry, $subscriptionManager, $logger),
552+
new Handler\Request\ResourceUnsubscribeHandler($registry, $subscriptionManager, $logger),
539553
new Handler\Request\SetLogLevelHandler(),
540554
]);
541555

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Server\Handler\Request;
13+
14+
use Mcp\Capability\RegistryInterface;
15+
use Mcp\Exception\ResourceNotFoundException;
16+
use Mcp\Schema\JsonRpc\Error;
17+
use Mcp\Schema\JsonRpc\Request;
18+
use Mcp\Schema\JsonRpc\Response;
19+
use Mcp\Schema\Request\ResourceSubscribeRequest;
20+
use Mcp\Schema\Result\EmptyResult;
21+
use Mcp\Server\Resource\SubscriptionManagerInterface;
22+
use Mcp\Server\Session\SessionInterface;
23+
use Psr\Log\LoggerInterface;
24+
use Psr\Log\NullLogger;
25+
use Psr\SimpleCache\InvalidArgumentException;
26+
27+
/**
28+
* @implements RequestHandlerInterface<EmptyResult>
29+
*
30+
* @author Larry Sule-balogun <suleabimbola@gmail.com>
31+
*/
32+
final class ResourceSubscribeHandler implements RequestHandlerInterface
33+
{
34+
public function __construct(
35+
private readonly RegistryInterface $registry,
36+
private readonly SubscriptionManagerInterface $subscriptionManager,
37+
private readonly LoggerInterface $logger = new NullLogger(),
38+
) {
39+
}
40+
41+
public function supports(Request $request): bool
42+
{
43+
return $request instanceof ResourceSubscribeRequest;
44+
}
45+
46+
/**
47+
* @throws InvalidArgumentException
48+
*/
49+
public function handle(Request $request, SessionInterface $session): Response|Error
50+
{
51+
\assert($request instanceof ResourceSubscribeRequest);
52+
53+
$uri = $request->uri;
54+
55+
try {
56+
$this->registry->getResource($uri);
57+
} catch (ResourceNotFoundException $e) {
58+
$this->logger->error('Resource not found', ['uri' => $uri]);
59+
60+
return Error::forResourceNotFound($e->getMessage(), $request->getId());
61+
}
62+
63+
$this->logger->debug('Subscribing to resource', ['uri' => $uri]);
64+
65+
$this->subscriptionManager->subscribe($session, $uri);
66+
67+
return new Response(
68+
$request->getId(),
69+
new EmptyResult(),
70+
);
71+
}
72+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Server\Handler\Request;
13+
14+
use Mcp\Capability\RegistryInterface;
15+
use Mcp\Exception\ResourceNotFoundException;
16+
use Mcp\Schema\JsonRpc\Error;
17+
use Mcp\Schema\JsonRpc\Request;
18+
use Mcp\Schema\JsonRpc\Response;
19+
use Mcp\Schema\Request\ResourceUnsubscribeRequest;
20+
use Mcp\Schema\Result\EmptyResult;
21+
use Mcp\Server\Resource\SubscriptionManagerInterface;
22+
use Mcp\Server\Session\SessionInterface;
23+
use Psr\Log\LoggerInterface;
24+
use Psr\Log\NullLogger;
25+
use Psr\SimpleCache\InvalidArgumentException;
26+
27+
/**
28+
* @implements RequestHandlerInterface<EmptyResult>
29+
*
30+
* @author Larry Sule-balogun <suleabimbola@gmail.com>
31+
*/
32+
final class ResourceUnsubscribeHandler implements RequestHandlerInterface
33+
{
34+
public function __construct(
35+
private readonly RegistryInterface $registry,
36+
private readonly SubscriptionManagerInterface $subscriptionManager,
37+
private readonly LoggerInterface $logger = new NullLogger(),
38+
) {
39+
}
40+
41+
public function supports(Request $request): bool
42+
{
43+
return $request instanceof ResourceUnsubscribeRequest;
44+
}
45+
46+
/**
47+
* @throws InvalidArgumentException
48+
*/
49+
public function handle(Request $request, SessionInterface $session): Response|Error
50+
{
51+
\assert($request instanceof ResourceUnsubscribeRequest);
52+
53+
$uri = $request->uri;
54+
55+
try {
56+
$this->registry->getResource($uri);
57+
} catch (ResourceNotFoundException $e) {
58+
$this->logger->error('Resource not found', ['uri' => $uri]);
59+
60+
return Error::forResourceNotFound($e->getMessage(), $request->getId());
61+
}
62+
63+
$this->logger->debug('Unsubscribing from resource', ['uri' => $uri]);
64+
65+
$this->subscriptionManager->unsubscribe($session, $uri);
66+
67+
return new Response(
68+
$request->getId(),
69+
new EmptyResult(),
70+
);
71+
}
72+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Server\Resource;
13+
14+
use Mcp\Schema\Notification\ResourceUpdatedNotification;
15+
use Mcp\Server\Protocol;
16+
use Mcp\Server\Session\SessionInterface;
17+
use Psr\Log\LoggerInterface;
18+
use Psr\Log\NullLogger;
19+
use Psr\SimpleCache\InvalidArgumentException;
20+
21+
/**
22+
* The default Subscription manager implementation manages subscriptions per session only.
23+
* It is in-memory and does not support cross-session or cross-client subscriptions.
24+
*
25+
* The SDK allows injecting alternative SubscriptionManagerInterface
26+
* implementations via Builder::setResourceSubscriptionManager().
27+
*
28+
* @author Larry Sule-balogun <suleabimbola@gmail.com>
29+
*/
30+
final class SessionSubscriptionManager implements SubscriptionManagerInterface
31+
{
32+
public function __construct(
33+
private readonly LoggerInterface $logger = new NullLogger(),
34+
) {
35+
}
36+
37+
/**
38+
* @throws InvalidArgumentException
39+
*/
40+
public function subscribe(SessionInterface $session, string $uri): void
41+
{
42+
$subscriptions = $session->get('resource_subscriptions', []);
43+
$subscriptions[$uri] = true;
44+
$session->set('resource_subscriptions', $subscriptions);
45+
$session->save();
46+
}
47+
48+
/**
49+
* @throws InvalidArgumentException
50+
*/
51+
public function unsubscribe(SessionInterface $session, string $uri): void
52+
{
53+
$subscriptions = $session->get('resource_subscriptions', []);
54+
unset($subscriptions[$uri]);
55+
$session->set('resource_subscriptions', $subscriptions);
56+
$session->save();
57+
}
58+
59+
/**
60+
* @throws InvalidArgumentException
61+
*/
62+
public function isSubscribed(SessionInterface $session, string $uri): bool
63+
{
64+
$subscriptions = $session->get('resource_subscriptions', []);
65+
66+
return isset($subscriptions[$uri]);
67+
}
68+
69+
/**
70+
* @throws InvalidArgumentException
71+
*/
72+
public function notifyResourceChanged(Protocol $protocol, SessionInterface $session, string $uri): void
73+
{
74+
$activeSession = $this->isSubscribed($session, $uri);
75+
if (!$activeSession) {
76+
return;
77+
}
78+
79+
try {
80+
$protocol->sendNotification(
81+
new ResourceUpdatedNotification($uri),
82+
$session
83+
);
84+
} catch (InvalidArgumentException $e) {
85+
$this->logger->error('Error sending resource notification to session', [
86+
'session_id' => $session->getId()->toRfc4122(),
87+
'uri' => $uri,
88+
'exception' => $e,
89+
]);
90+
91+
throw $e;
92+
}
93+
}
94+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Server\Resource;
13+
14+
use Mcp\Server\Protocol;
15+
use Mcp\Server\Session\SessionInterface;
16+
use Psr\SimpleCache\InvalidArgumentException;
17+
18+
/**
19+
* Resource subscription interface.
20+
*
21+
* @author Larry Sule-balogun <suleabimbola@gmail.com>
22+
*/
23+
interface SubscriptionManagerInterface
24+
{
25+
/**
26+
* Subscribes a session to a specific resource URI.
27+
*
28+
* @throws InvalidArgumentException
29+
*/
30+
public function subscribe(SessionInterface $session, string $uri): void;
31+
32+
/**
33+
* Unsubscribes a session from a specific resource URI.
34+
*
35+
* @throws InvalidArgumentException
36+
*/
37+
public function unsubscribe(SessionInterface $session, string $uri): void;
38+
39+
/**
40+
* Check if a session is subscribed to a resource URI.
41+
*
42+
* @throws InvalidArgumentException
43+
*/
44+
public function isSubscribed(SessionInterface $session, string $uri): bool;
45+
46+
/**
47+
* Notifies all sessions subscribed to the given resource URI that the
48+
* resource has changed. Sends a ResourceUpdatedNotification for each subscriber.
49+
*
50+
* @throws InvalidArgumentException
51+
*/
52+
public function notifyResourceChanged(Protocol $protocol, SessionInterface $session, string $uri): void;
53+
}

tests/Conformance/conformance-baseline.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,4 @@ server:
22
- tools-call-elicitation
33
- elicitation-sep1034-defaults
44
- elicitation-sep1330-enums
5-
- resources-subscribe
6-
- resources-unsubscribe
75
- dns-rebinding-protection

0 commit comments

Comments
 (0)