packages feed

mcp-server-0.2.0.1: test/Spec/Cancellation.hs

{-# LANGUAGE OverloadedStrings #-}

-- | Coverage for the cancellation contract (ADR 0008): a request task
-- cancelled mid-handler stops emitting, never produces a response, and
-- releases bracket-acquired resources. The transports build on exactly
-- this shape (an 'async' around 'handleMcpMessage' that 'cancel'
-- interrupts), so the properties verified here are the ones the wire
-- behavior depends on.
module Spec.Cancellation (spec) where

import Control.Concurrent (threadDelay)
import Control.Concurrent.Async (async, cancel, waitCatch)
import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)
import Control.Exception (bracket_)
import Data.Aeson
import Data.IORef
import Data.Text (Text)
import MCP.Server
import MCP.Server.Handlers (handleMcpMessage)
import MCP.Server.JsonRpc
import Test.Hspec

-- A slow tools/call carrying a progressToken (so emissions are live),
-- driven from an async that the test cancels once the handler signals
-- it has started.
runCancelled :: (ClientContext -> IO ()) -> IO [JsonRpcNotification]
runCancelled handlerBody = do
  sink <- newIORef []
  started <- newEmptyMVar
  responded <- newIORef False
  let handlers = noHandlers
        { tools = Just
            ( \_ -> pure []
            , \ctx _ _ -> do
                reportProgress ctx 0 (Just 1) Nothing
                putMVar started ()
                handlerBody ctx
                pure $ Right $ toToolResult ("done" :: Text)
            )
        }
      params = object
        [ "name" .= ("slow" :: Text)
        , "arguments" .= object []
        , "_meta" .= object ["progressToken" .= ("t" :: Text)]
        ]
  task <- async $ do
    resp <- handleMcpMessage (McpServerInfo "T" "1" "") defaultCacheHints
      noNotificationSupport (\n -> modifyIORef' sink (++ [n]))
      handlers anonymousContext
      (JsonRpcMessageRequest (JsonRpcRequest "2.0" (RequestIdNumber 1) "tools/call" (Just params)))
    case resp of
      Just _  -> writeIORef responded True
      Nothing -> pure ()
  takeMVar started
  cancel task  -- waits for the task to finish
  _ <- waitCatch task
  readIORef responded `shouldReturn` False
  readIORef sink

spec :: Spec
spec = describe "Cancellation contract" $ do

  it "a cancelled handler stops emitting and never yields a response" $ do
    lateEmit <- newIORef False
    ns <- runCancelled $ \ctx -> do
      threadDelay 5000000
      writeIORef lateEmit True
      reportProgress ctx 1 (Just 1) Nothing
    -- only the pre-cancellation progress made it out
    map notificationMethod ns `shouldBe` ["notifications/progress"]
    readIORef lateEmit `shouldReturn` False

  it "bracket releases handler resources on cancellation" $ do
    acquired <- newIORef False
    released <- newIORef False
    _ <- runCancelled $ \_ ->
      bracket_ (writeIORef acquired True) (writeIORef released True) $
        threadDelay 5000000
    readIORef acquired `shouldReturn` True
    readIORef released `shouldReturn` True