When using Registry::emit('list_changed', ['tools']) to notify clients about tool changes, the notification is never actually sent to connected clients due to two issues:
- Protocol only sends to subscribers -
Protocol::handleListChanged() only sends notifications to sessions that explicitly subscribed to mcp://changes/tools, but most MCP clients don't subscribe
- Empty params serialized as array - Notification classes serialize empty
params as [] (JSON array) instead of omitting it, causing client-side validation errors
Environment
- Package:
llm/mcp-server
- Schema Package:
php-mcp/schema
Current Behavior
Issue 1: Subscription-based filtering
// Protocol.php - handleListChanged()
$subscribers = $this->subscriptionManager->getSubscribers($listChangeUri);
if (empty($subscribers)) {
return; // <-- Returns early, notification never sent!
}
Most MCP clients (Claude Desktop, Claude Code, MCP Inspector, LibreChat, Gemini CLI) don't automatically subscribe to list change URIs, so $subscribers is always empty.
Issue 2: Invalid JSON-RPC message
When ToolListChangedNotification::make() is called:
// Current code
$params = [];
if ($_meta !== null) {
$params['_meta'] = $_meta;
}
parent::__construct(..., $params); // Passes empty array []
This produces invalid JSON:
{"jsonrpc":"2.0","method":"notifications/tools/list_changed","params":[]}
The params field is a JSON array [] instead of an object {}. MCP clients reject this with validation error:
"params": "Expected object, received array"
Expected Behavior
- Notifications should be broadcast to all initialized sessions, not just subscribers
- When
params is empty, it should be omitted entirely from the JSON output
Expected JSON output:
{"jsonrpc":"2.0","method":"notifications/tools/list_changed"}
Proposed Fixes
Fix 1: llm/mcp-server - Broadcast to all initialized sessions
File: src/Session/SessionManager.php
Add session tracking:
final class SessionManager implements EventEmitterInterface
{
use EventEmitterTrait;
private ?TimerInterface $gcTimer = null;
/** @var array<string, true> Track active session IDs */
private array $activeSessions = [];
// ... existing code ...
public function createSession(string $sessionId): SessionInterface
{
// ... existing code ...
$session->save();
// Track active session
$this->activeSessions[$sessionId] = true;
// ... rest of method ...
}
/**
* Get all active session IDs
* @return string[]
*/
public function getActiveSessionIds(): array
{
return \array_keys($this->activeSessions);
}
public function deleteSession(string $sessionId): bool
{
$success = $this->handler->destroy($sessionId);
// Remove from active sessions tracking
unset($this->activeSessions[$sessionId]);
// ... rest of method ...
}
}
File: src/Protocol.php
Modify handleListChanged() to broadcast:
public function handleListChanged(string $listType): void
{
$notification = match ($listType) {
'resources' => ResourceListChangedNotification::make(),
'tools' => ToolListChangedNotification::make(),
'prompts' => PromptListChangedNotification::make(),
'roots' => RootsListChangedNotification::make(),
default => throw new \InvalidArgumentException("Invalid list type: {$listType}"),
};
if (!$this->canSendNotification($notification->method)) {
return;
}
// Broadcast to ALL initialized sessions (not just subscribers)
$sessionIds = $this->sessionManager->getActiveSessionIds();
$sentCount = 0;
foreach ($sessionIds as $sessionId) {
$session = $this->sessionManager->getSession($sessionId);
if ($session === null) {
continue;
}
// Only send to initialized sessions
if (!$session->get('initialized', false)) {
continue;
}
$this->sendNotification($notification, $sessionId);
$sentCount++;
}
$this->logger->debug("Sent list change notification to all sessions", [
'list_type' => $listType,
'sessions_notified' => $sentCount,
'total_sessions' => \count($sessionIds),
]);
}
Fix 2: php-mcp/schema - Fix empty params serialization
Files to update:
src/Notification/ToolListChangedNotification.php
src/Notification/PromptListChangedNotification.php
src/Notification/ResourceListChangedNotification.php
src/Notification/RootsListChangedNotification.php
Change from:
public function __construct(
public readonly ?array $_meta = null
) {
$params = [];
if ($_meta !== null) {
$params['_meta'] = $_meta;
}
parent::__construct(Constants::JSONRPC_VERSION, 'notifications/tools/list_changed', $params);
}
To:
public function __construct(
public readonly ?array $_meta = null
) {
$params = null;
if ($_meta !== null) {
$params = ['_meta' => $_meta];
}
parent::__construct(Constants::JSONRPC_VERSION, 'notifications/tools/list_changed', $params);
}
MCP Specification Reference
From the [MCP Tools Specification](https://modelcontextprotocol.io/specification/2025-11-25/server/tools):
When the list of available tools changes, servers that declared the listChanged capability SHOULD send a notification
The specification states this notification "may be issued by servers without any previous subscription from the client", supporting the broadcast approach.
Reproduction Steps
- Create an MCP server using
llm/mcp-server
- Connect with MCP Inspector
- Call
$registry->emit('list_changed', ['tools'])
- Observe: No notification appears in client
Related Issues
When using
Registry::emit('list_changed', ['tools'])to notify clients about tool changes, the notification is never actually sent to connected clients due to two issues:Protocol::handleListChanged()only sends notifications to sessions that explicitly subscribed tomcp://changes/tools, but most MCP clients don't subscribeparamsas[](JSON array) instead of omitting it, causing client-side validation errorsEnvironment
llm/mcp-serverphp-mcp/schemaCurrent Behavior
Issue 1: Subscription-based filtering
Most MCP clients (Claude Desktop, Claude Code, MCP Inspector, LibreChat, Gemini CLI) don't automatically subscribe to list change URIs, so
$subscribersis always empty.Issue 2: Invalid JSON-RPC message
When
ToolListChangedNotification::make()is called:This produces invalid JSON:
{"jsonrpc":"2.0","method":"notifications/tools/list_changed","params":[]}The
paramsfield is a JSON array[]instead of an object{}. MCP clients reject this with validation error:Expected Behavior
paramsis empty, it should be omitted entirely from the JSON outputExpected JSON output:
{"jsonrpc":"2.0","method":"notifications/tools/list_changed"}Proposed Fixes
Fix 1:
llm/mcp-server- Broadcast to all initialized sessionsFile:
src/Session/SessionManager.phpAdd session tracking:
File:
src/Protocol.phpModify
handleListChanged()to broadcast:Fix 2:
php-mcp/schema- Fix empty params serializationFiles to update:
src/Notification/ToolListChangedNotification.phpsrc/Notification/PromptListChangedNotification.phpsrc/Notification/ResourceListChangedNotification.phpsrc/Notification/RootsListChangedNotification.phpChange from:
To:
MCP Specification Reference
From the [MCP Tools Specification](https://modelcontextprotocol.io/specification/2025-11-25/server/tools):
The specification states this notification "may be issued by servers without any previous subscription from the client", supporting the broadcast approach.
Reproduction Steps
llm/mcp-server$registry->emit('list_changed', ['tools'])Related Issues
notifications/tools/list_changedgoogle-gemini/gemini-cli#13850)