mcp-server-0.2.0.1: test/Spec/Subscriptions.hs
{-# LANGUAGE OverloadedStrings #-}
-- | Coverage for the change-notification machinery: the notifier, the
-- subscriptions/listen wire shapes, and era-aware capability advertisement.
module Spec.Subscriptions (spec) where
import Control.Concurrent.STM (atomically, readTChan)
import Data.Aeson
import qualified Data.Aeson.KeyMap as KM
import Data.Text (Text)
import MCP.Server
import MCP.Server.Handlers (handleMcpMessage)
import MCP.Server.JsonRpc
import MCP.Server.Notifications
import Test.Hspec
subId :: RequestId
subId = RequestIdNumber 7
fullFilterParams :: Maybe Value
fullFilterParams = Just $ object
[ "notifications" .= object
[ "toolsListChanged" .= True
, "resourceSubscriptions" .= (["resource://info"] :: [Text])
]
]
paramsOf :: JsonRpcNotification -> Object
paramsOf n = case notificationParams n of
Just (Object o) -> o
other -> error $ "notification params not an object: " ++ show other
metaSubId :: Object -> Maybe Value
metaSubId o = do
Object m <- KM.lookup "_meta" o
KM.lookup "io.modelcontextprotocol/subscriptionId" m
spec :: Spec
spec = describe "Change notifications" $ do
describe "Filter parsing" $ do
it "parses the requested notification types" $ do
let f = parseNotificationFilter fullFilterParams
filterTools f `shouldBe` True
filterPrompts f `shouldBe` False
filterResources f `shouldBe` False
filterResourceSubs f `shouldBe` ["resource://info"]
it "treats missing filters as subscribed-to-nothing" $ do
let f = parseNotificationFilter Nothing
filterIsEmpty f `shouldBe` True
describe "Filter semantics" $ do
it "delivers only opted-in types" $ do
let f = parseNotificationFilter fullFilterParams
filterAccepts f ToolsListChangedEvent `shouldBe` True
filterAccepts f PromptsListChangedEvent `shouldBe` False
filterAccepts f ResourcesListChangedEvent `shouldBe` False
it "matches resource updates by watched URI" $ do
let f = parseNotificationFilter fullFilterParams
filterAccepts f (ResourceUpdatedEvent "resource://info") `shouldBe` True
filterAccepts f (ResourceUpdatedEvent "resource://other") `shouldBe` False
describe "Wire shapes" $ do
it "acknowledges with the subscription id and the honored subset" $ do
let ack = acknowledgedNotification subId (parseNotificationFilter fullFilterParams)
notificationMethod ack `shouldBe` "notifications/subscriptions/acknowledged"
let o = paramsOf ack
metaSubId o `shouldBe` Just (Number 7)
KM.lookup "notifications" o `shouldBe` Just (object
[ "toolsListChanged" .= True
, "resourceSubscriptions" .= (["resource://info"] :: [Text])
])
it "tags event notifications with the subscription id" $ do
let n = eventNotification subId (ResourceUpdatedEvent "resource://info")
notificationMethod n `shouldBe` "notifications/resources/updated"
let o = paramsOf n
metaSubId o `shouldBe` Just (Number 7)
KM.lookup "uri" o `shouldBe` Just (String "resource://info")
it "uses the standard list_changed methods" $ do
notificationMethod (eventNotification subId ToolsListChangedEvent)
`shouldBe` "notifications/tools/list_changed"
notificationMethod (eventNotification subId PromptsListChangedEvent)
`shouldBe` "notifications/prompts/list_changed"
notificationMethod (eventNotification subId ResourcesListChangedEvent)
`shouldBe` "notifications/resources/list_changed"
it "legacy notifications are untagged" $ do
let n = legacyEventNotification ToolsListChangedEvent
notificationMethod n `shouldBe` "notifications/tools/list_changed"
notificationParams n `shouldBe` Nothing
it "closure responses carry resultType, the subscription id and the server identity" $ do
let r = closureResponse (McpServerInfo "S" "2.0" "") subId
responseResult r `shouldBe` Just (object
[ "resultType" .= ("complete" :: Text)
, "_meta" .= object
[ "io.modelcontextprotocol/subscriptionId" .= (7 :: Int)
, "io.modelcontextprotocol/serverInfo" .= object
[ "name" .= ("S" :: Text), "version" .= ("2.0" :: Text) ]
]
])
describe "Notifier plumbing" $ do
it "delivers published events to subscribers" $ do
(notifier, source) <- newMcpNotifier
chan <- atomically $ subscribeEvents source
notifyToolsListChanged notifier
event <- atomically $ readTChan chan
event `shouldBe` ToolsListChangedEvent
describe "Era-aware capability advertisement" $ do
let toolsServer = noHandlers
{ tools = Just (\_ -> pure [], \_ n _ -> pure (Left (UnknownTool n)))
, resources = Just (\_ -> pure [], \_ _ -> pure (Left (ResourceNotFound "x")))
}
run support method params = handleMcpMessage
(McpServerInfo "T" "1" "")
defaultCacheHints
support
(\_ -> pure ())
toolsServer
anonymousContext
(JsonRpcMessageRequest (JsonRpcRequest "2.0" (RequestIdNumber 1) method params))
capsOf resp = case resp of
Just (JsonRpcMessageResponse r)
| Just (Object o) <- responseResult r
, Just (Object caps) <- KM.lookup "capabilities" o -> pure caps
other -> error $ "no capabilities in: " ++ show other
initParams = Just $ object
[ "protocolVersion" .= ("2025-11-25" :: Text)
, "capabilities" .= object []
, "clientInfo" .= object ["name" .= ("c" :: Text), "version" .= ("1" :: Text)]
]
it "legacy initialize advertises listChanged only with legacy push" $ do
caps <- capsOf =<< run (NotificationSupport True True) "initialize" initParams
KM.lookup "tools" caps `shouldBe` Just (object ["listChanged" .= True])
-- legacy subscribe (the removed resources/subscribe RPC) is never advertised
KM.lookup "resources" caps `shouldBe` Just (object ["listChanged" .= True])
it "legacy initialize over a push-less transport advertises neither" $ do
caps <- capsOf =<< run (NotificationSupport False True) "initialize" initParams
KM.lookup "tools" caps `shouldBe` Just (object [])
KM.lookup "resources" caps `shouldBe` Just (object [])
it "modern discover advertises listChanged and subscribe when listen is served" $ do
caps <- capsOf =<< run (NotificationSupport False True) "server/discover" Nothing
KM.lookup "tools" caps `shouldBe` Just (object ["listChanged" .= True])
KM.lookup "resources" caps `shouldBe`
Just (object ["subscribe" .= True, "listChanged" .= True])
it "no support means nothing extra is advertised" $ do
caps <- capsOf =<< run noNotificationSupport "server/discover" Nothing
KM.lookup "tools" caps `shouldBe` Just (object [])