Skip to content

Bug: notifications/tools/list_changed not sent to MCP clients #303

@butschster

Description

@butschster

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:

  1. 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
  2. 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

  1. Notifications should be broadcast to all initialized sessions, not just subscribers
  2. 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

  1. Create an MCP server using llm/mcp-server
  2. Connect with MCP Inspector
  3. Call $registry->emit('list_changed', ['tools'])
  4. Observe: No notification appears in client

Related Issues

Metadata

Metadata

Assignees

No one assigned

    Labels

    mcpMCP server componentsmcp:toolsMCP server tools

    Type

    Projects

    Status

    No status

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions