packages feed

mcp-server-0.2.0.0: src/MCP/Server/Notifications.hs

{-# LANGUAGE OverloadedStrings #-}

-- | Server-initiated change notifications.
--
-- Create a notifier with 'newMcpNotifier', hand the 'NotificationSource' to
-- a transport via its configuration ('MCP.Server.Transport.Stdio.StdioConfig'
-- \/ 'MCP.Server.Transport.Http.HttpConfig'), keep the 'McpNotifier', and
-- call its actions whenever your tool\/prompt\/resource lists (or a specific
-- resource) change:
--
-- > (notifier, source) <- newMcpNotifier
-- > let config = defaultStdioConfig { stdioNotifications = Just source }
-- > _ <- forkIO $ appLogic notifier
-- > runMcpServerStdioWithConfig config serverInfo handlers
--
-- Delivery is transport- and era-specific: modern (2026-07-28) clients open
-- a @subscriptions\/listen@ stream and receive only the notification types
-- they opted into, tagged with their subscription id; legacy stdio clients
-- receive untagged notifications spontaneously after @initialize@. Legacy
-- HTTP has no delivery channel (this library dropped the deprecated GET SSE
-- stream), so the @listChanged@ capabilities are not advertised there.
module MCP.Server.Notifications
  ( -- * Notifier
    McpNotifier(..)
  , NotificationSource
  , newMcpNotifier
  , ChangeEvent(..)
  , subscribeEvents

    -- * Subscription wire format (used by transports)
  , NotificationFilter(..)
  , parseNotificationFilter
  , filterAccepts
  , filterIsEmpty
  , acknowledgedNotification
  , eventNotification
  , legacyEventNotification
  , closureResponse
  ) where

import           Control.Concurrent.STM (STM, TChan, atomically,
                                         dupTChan, newBroadcastTChanIO,
                                         writeTChan)
import           Data.Aeson
import qualified Data.Aeson.KeyMap      as KM
import           Data.Aeson.Types       (Pair)
import           Data.Text              (Text)
import qualified Data.Text              as T
import           Network.URI            (URI)

import           MCP.Server.JsonRpc
import           MCP.Server.Types       (McpServerInfo (..))

-- | A change an application can announce.
data ChangeEvent
  = ToolsListChangedEvent
  | PromptsListChangedEvent
  | ResourcesListChangedEvent
  | ResourceUpdatedEvent Text  -- ^ the resource URI, rendered
  deriving (Show, Eq)

-- | The application-facing handle: call these when things change.
data McpNotifier = McpNotifier
  { notifyToolsListChanged     :: IO ()
  , notifyPromptsListChanged   :: IO ()
  , notifyResourcesListChanged :: IO ()
  , notifyResourceUpdated      :: URI -> IO ()
  }

-- | The transport-facing end of a notifier: subscribe with
-- 'subscribeEvents'. Backed by a broadcast 'TChan', so events published
-- while nobody listens are dropped rather than retained.
newtype NotificationSource = NotificationSource (TChan ChangeEvent)

-- | Create a notifier and its source.
newMcpNotifier :: IO (McpNotifier, NotificationSource)
newMcpNotifier = do
  chan <- newBroadcastTChanIO
  let publish = atomically . writeTChan chan
  pure
    ( McpNotifier
        { notifyToolsListChanged     = publish ToolsListChangedEvent
        , notifyPromptsListChanged   = publish PromptsListChangedEvent
        , notifyResourcesListChanged = publish ResourcesListChangedEvent
        , notifyResourceUpdated      = publish . ResourceUpdatedEvent . T.pack . show
        }
    , NotificationSource chan
    )

-- | A private read end delivering every event published after the call.
subscribeEvents :: NotificationSource -> STM (TChan ChangeEvent)
subscribeEvents (NotificationSource chan) = dupTChan chan

-- | Which notification types a @subscriptions\/listen@ request opted into.
data NotificationFilter = NotificationFilter
  { filterTools        :: Bool
  , filterPrompts      :: Bool
  , filterResources    :: Bool
  , filterResourceSubs :: [Text]  -- ^ URIs watched for updates
  } deriving (Show, Eq)

-- | Parse the @notifications@ filter out of a @subscriptions\/listen@
-- request's params. Absent fields mean "not subscribed".
parseNotificationFilter :: Maybe Value -> NotificationFilter
parseNotificationFilter params = NotificationFilter
  { filterTools        = boolField "toolsListChanged"
  , filterPrompts      = boolField "promptsListChanged"
  , filterResources    = boolField "resourcesListChanged"
  , filterResourceSubs = subsField
  }
  where
    notifications = do
      Object o <- params
      KM.lookup "notifications" o
    boolField key = case notifications of
      Just (Object n) | Just (Bool b) <- KM.lookup key n -> b
      _ -> False
    subsField = case notifications of
      Just (Object n) | Just (Array xs) <- KM.lookup "resourceSubscriptions" n ->
        [u | String u <- foldr (:) [] xs]
      _ -> []

-- | Whether a subscription with this filter receives the event.
filterAccepts :: NotificationFilter -> ChangeEvent -> Bool
filterAccepts f ToolsListChangedEvent      = filterTools f
filterAccepts f PromptsListChangedEvent    = filterPrompts f
filterAccepts f ResourcesListChangedEvent  = filterResources f
filterAccepts f (ResourceUpdatedEvent uri) = uri `elem` filterResourceSubs f

-- | True when the filter subscribes to nothing at all.
filterIsEmpty :: NotificationFilter -> Bool
filterIsEmpty f =
  not (filterTools f || filterPrompts f || filterResources f)
    && null (filterResourceSubs f)

-- | The @_meta@ object tagging a message with its subscription id (the
-- JSON-RPC id of the @subscriptions\/listen@ request).
subscriptionMeta :: RequestId -> Pair
subscriptionMeta subId =
  "_meta" .= object ["io.modelcontextprotocol/subscriptionId" .= subId]

-- | The mandatory first message of a subscription stream: the honored
-- filter (this library honors every requested type).
acknowledgedNotification :: RequestId -> NotificationFilter -> JsonRpcNotification
acknowledgedNotification subId f =
  makeNotification "notifications/subscriptions/acknowledged" $ Just $ object
    [ subscriptionMeta subId
    , "notifications" .= object (concat
        [ ["toolsListChanged" .= True | filterTools f]
        , ["promptsListChanged" .= True | filterPrompts f]
        , ["resourcesListChanged" .= True | filterResources f]
        , ["resourceSubscriptions" .= filterResourceSubs f | not (null (filterResourceSubs f))]
        ])
    ]

-- | The method and extra params of an event's notification.
eventBody :: ChangeEvent -> (Text, [Pair])
eventBody ToolsListChangedEvent      = ("notifications/tools/list_changed", [])
eventBody PromptsListChangedEvent    = ("notifications/prompts/list_changed", [])
eventBody ResourcesListChangedEvent  = ("notifications/resources/list_changed", [])
eventBody (ResourceUpdatedEvent uri) = ("notifications/resources/updated", ["uri" .= uri])

-- | A change event as a subscription-tagged notification (modern era).
eventNotification :: RequestId -> ChangeEvent -> JsonRpcNotification
eventNotification subId event =
  let (method, extra) = eventBody event
  in makeNotification method $ Just $ object (subscriptionMeta subId : extra)

-- | A change event as an untagged notification (legacy stdio delivery).
legacyEventNotification :: ChangeEvent -> JsonRpcNotification
legacyEventNotification event =
  let (method, extra) = eventBody event
  in makeNotification method $ if null extra then Nothing else Just (object extra)

-- | The graceful-closure response a server sends when it ends a
-- subscription on its own initiative (e.g. shutdown). Like every modern
-- result it carries the server identity alongside the subscription id.
closureResponse :: McpServerInfo -> RequestId -> JsonRpcResponse
closureResponse serverInfo subId = makeSuccessResponse subId $ object
  [ "resultType" .= ("complete" :: Text)
  , "_meta" .= object
      [ "io.modelcontextprotocol/subscriptionId" .= subId
      , "io.modelcontextprotocol/serverInfo" .= object
          [ "name" .= serverName serverInfo
          , "version" .= serverVersion serverInfo
          ]
      ]
  ]