packages feed

mcp-server-0.3.0.1: test/Spec/AskMonad.hs

{-# LANGUAGE DeriveGeneric     #-}
{-# LANGUAGE OverloadedStrings #-}

-- | Coverage for the 'Ask' monad (ADR 0009, phase 2): handlers replayed
-- against a journal across rounds, through the real dispatcher and state
-- codec.
module Spec.AskMonad (spec) where

import           Control.Monad.IO.Class (liftIO)
import           Data.Aeson
import qualified Data.Aeson.KeyMap      as KM
import           Data.Aeson.Types       (Pair)
import           Data.IORef
import           Data.Text              (Text)
import qualified Data.Text              as T
import           GHC.Generics           (Generic)
import           MCP.Server
import           MCP.Server.Handlers    (handleMcpMessage)
import           MCP.Server.JsonRpc
import           Test.Hspec
import           TestHelpers            (testStateCodec)

newtype Target = Target { environment :: Text } deriving (Show, Eq, Generic)
instance FromJSON Target
instance Form Target where
  formSchema _ = schema $ SchemaObject
    [("environment", schema (SchemaString (Just ["staging", "production"])))]
    ["environment"]

newtype Confirm = Confirm { ok :: Bool } deriving (Show, Eq, Generic)
instance FromJSON Confirm
instance Form Confirm where
  formSchema _ = schema $ SchemaObject [("ok", schema SchemaBoolean)] ["ok"]

-- | Counters for what ran: the journaled planning step, and every round.
data Counters = Counters { planRuns :: IORef Int, rounds :: IORef Int }

-- | Ask for a target, plan once, ask for confirmation.
deploy :: Counters -> Ask Text
deploy counters = do
  liftIO (modifyIORef (rounds counters) (+ 1))
  target <- elicit "Where to?"
  case target of
    Accepted (Target env) -> do
      plan <- step $ do
        modifyIORef (planRuns counters) (+ 1)
        pure ("plan-" <> env)
      confirmed <- elicit ("Apply " <> plan <> "?")
      pure $ case confirmed of
        Accepted (Confirm True) -> "applied " <> plan
        other                   -> "not applied: " <> T.pack (show other)
    other -> pure ("no target: " <> T.pack (show other))

-- | Asks a question that changes every round, which replay must refuse.
fickle :: IORef Int -> Ask Text
fickle counter = do
  n <- liftIO (atomicModifyIORef counter (\c -> (c + 1, c)))
  reply <- elicit ("Question " <> T.pack (show n))
  pure (T.pack (show (reply :: Answer Confirm)))

-- | A step whose result is JSON null, a question, and a step after it.
nullStep :: Ask Text
nullStep = do
  nothing <- step (pure (Nothing :: Maybe Text))
  reply <- elicit "Still there?"
  later <- step (pure (2 :: Int))
  pure (T.pack (show (nothing, reply :: Answer Confirm, later)))

handlersFor :: Counters -> IORef Int -> McpServerHandlers
handlersFor counters fickleCounter = noHandlers
  { tools = Just
      ( \_ -> pure []
      , \ctx name _ -> fmap (fmap (\t -> toolResult [ContentText t])) <$>
          runAsk (case name of
                    "fickle" -> fickle fickleCounter
                    "null"   -> nullStep
                    _        -> deploy counters) ctx
      )
  }

newCounters :: IO (Counters, IORef Int)
newCounters = (,) <$> (Counters <$> newIORef 0 <*> newIORef 0) <*> newIORef 0

call :: (Counters, IORef Int) -> Text -> Value -> [Pair] -> IO Value
call (counters, fickleCounter) name caps extra = do
  response <- handleMcpMessage (McpServerInfo "T" "1" "") defaultCacheHints testStateCodec
    noNotificationSupport (\_ -> pure ()) (handlersFor counters fickleCounter) anonymousContext $
      JsonRpcMessageRequest JsonRpcRequest
        { requestJsonrpc = "2.0"
        , requestId = RequestIdNumber 1
        , requestMethod = "tools/call"
        , requestParams = Just $ object $ extra ++
            [ "name" .= name
            , "arguments" .= object []
            , "_meta" .= object
                [ "io.modelcontextprotocol/protocolVersion" .= ("2026-07-28" :: Text)
                , "io.modelcontextprotocol/clientCapabilities" .= caps
                ]
            ]
        }
  case response of
    Just (JsonRpcMessageResponse r) -> pure (toJSON r)
    other -> fail ("expected a response, got " ++ show other)

formCaps, noCaps :: Value
formCaps = object ["elicitation" .= object ["form" .= object []]]
noCaps = object []

at :: [Key] -> Value -> Maybe Value
at [] v = Just v
at (k : ks) (Object o) = KM.lookup k o >>= at ks
at _ _ = Nothing

stateOf :: Value -> Text
stateOf v = case at ["result", "requestState"] v of
  Just (String s) -> s
  _               -> error ("no requestState in " ++ show v)

-- | The keys of an @input_required@ result's requests, and their messages.
questionsOf :: Value -> [(Key, Value)]
questionsOf v = case at ["result", "inputRequests"] v of
  Just (Object rs) -> [(k, m) | (k, r) <- KM.toList rs, Just m <- [at ["params", "message"] r]]
  _                -> []

textOf :: Value -> Maybe Value
textOf v = case at ["result", "content"] v of
  Just (Array xs) | [c] <- foldr (:) [] xs -> at ["text"] c
  _                                       -> Nothing

errorOf :: Value -> Maybe (Int, Text)
errorOf v = do
  Number code <- at ["error", "code"] v
  String msg <- at ["error", "message"] v
  pure (round code, msg)

answer :: Key -> Value -> Pair
answer key response = "inputResponses" .= object [key .= response]

accepted :: [Pair] -> Value
accepted content = object ["action" .= ("accept" :: Text), "content" .= object content]

retrying :: Value -> [Pair]
retrying previous = ["requestState" .= stateOf previous]

spec :: Spec
spec = describe "The Ask monad" $ do
  it "asks, replays answers and steps, and completes" $ do
    env <- newCounters
    r1 <- call env "deploy" formCaps []
    questionsOf r1 `shouldBe` [("0", String "Where to?")]
    r2 <- call env "deploy" formCaps $
      answer "0" (accepted ["environment" .= ("staging" :: Text)]) : retrying r1
    questionsOf r2 `shouldBe` [("2", String "Apply plan-staging?")]
    r3 <- call env "deploy" formCaps $
      answer "2" (accepted ["ok" .= True]) : retrying r2
    textOf r3 `shouldBe` Just (String "applied plan-staging")
    readIORef (planRuns (fst env)) `shouldReturn` 1
    readIORef (rounds (fst env)) `shouldReturn` 3

  it "keeps earlier answers in the journal, not in the retry" $ do
    env <- newCounters
    r1 <- call env "deploy" formCaps []
    r2 <- call env "deploy" formCaps $
      answer "0" (accepted ["environment" .= ("production" :: Text)]) : retrying r1
    -- The last round's answers only: the first answer comes from the state.
    r3 <- call env "deploy" formCaps $ answer "2" (accepted ["ok" .= False]) : retrying r2
    textOf r3 `shouldBe` Just (String "not applied: Accepted (Confirm {ok = False})")

  it "asks again when the retry carries no answer" $ do
    env <- newCounters
    r1 <- call env "deploy" formCaps []
    r2 <- call env "deploy" formCaps (retrying r1)
    questionsOf r2 `shouldBe` [("0", String "Where to?")]

  it "returns a declined or cancelled answer to the handler" $ do
    env <- newCounters
    r1 <- call env "deploy" formCaps []
    declined <- call env "deploy" formCaps $
      answer "0" (object ["action" .= ("decline" :: Text)]) : retrying r1
    textOf declined `shouldBe` Just (String "no target: Declined")
    cancelled <- call env "deploy" formCaps $
      answer "0" (object ["action" .= ("cancel" :: Text)]) : retrying r1
    textOf cancelled `shouldBe` Just (String "no target: Cancelled")

  it "answers Unavailable without a round trip when the client cannot ask" $ do
    env <- newCounters
    r <- call env "deploy" noCaps []
    textOf r `shouldBe` Just (String "no target: Unavailable")
    legacy <- runAsk (deploy (fst env)) anonymousContext
    legacy `shouldBe` Right (Complete "no target: Unavailable")

  it "runs a step again when a round is retried from an earlier state" $ do
    env <- newCounters
    r1 <- call env "deploy" formCaps []
    let staging = answer "0" (accepted ["environment" .= ("staging" :: Text)]) : retrying r1
    _ <- call env "deploy" formCaps staging
    _ <- call env "deploy" formCaps staging
    readIORef (planRuns (fst env)) `shouldReturn` 2

  it "refuses a handler whose questions change between rounds" $ do
    env <- newCounters
    r1 <- call env "fickle" formCaps []
    questionsOf r1 `shouldBe` [("0", String "Question 0")]
    r2 <- call env "fickle" formCaps $ answer "0" (accepted ["ok" .= True]) : retrying r1
    fmap fst (errorOf r2) `shouldBe` Just (-32603)
    fmap (T.isInfixOf "position 0" . snd) (errorOf r2) `shouldBe` Just True

  it "rejects content that does not fit the form" $ do
    env <- newCounters
    r1 <- call env "deploy" formCaps []
    r2 <- call env "deploy" formCaps $
      answer "0" (accepted ["environment" .= (3 :: Int)]) : retrying r1
    fmap fst (errorOf r2) `shouldBe` Just (-32602)

  it "rejects an answer that is not an elicitation result" $ do
    env <- newCounters
    r1 <- call env "deploy" formCaps []
    r2 <- call env "deploy" formCaps $
      answer "0" (object ["roots" .= ([] :: [Value])]) : retrying r1
    fmap fst (errorOf r2) `shouldBe` Just (-32602)

  it "completes without state when nothing is asked" $ do
    result <- runAsk (step (pure (42 :: Int))) anonymousContext
    result `shouldBe` Right (Complete 42)

  it "fails a run whose journal belongs to a different handler" $ do
    env <- newCounters
    Right (InputRequired _ journal) <- runAsk (deploy (fst env)) anonymousContext
      { clientProtocolVersion = Just "2026-07-28"
      , clientCapabilities = Just formCaps
      }
    result <- runAsk (step (pure (1 :: Int))) anonymousContext { clientRequestState = journal }
    case result of
      Left (InternalError msg) -> msg `shouldSatisfy` T.isInfixOf "position 0"
      other -> expectationFailure ("expected a mismatch, got " ++ show other)

  it "keeps the pending question's key stable across unrelated rounds" $ do
    env <- newCounters
    r1 <- call env "deploy" formCaps []
    let unrelated = answer "1" (accepted ["environment" .= ("staging" :: Text)]) : retrying r1
    r2 <- call env "deploy" formCaps unrelated
    questionsOf r2 `shouldBe` [("0", String "Where to?")]

  it "replays a step whose result is null" $ do
    env <- newCounters
    r1 <- call env "null" formCaps []
    questionsOf r1 `shouldBe` [("1", String "Still there?")]
    r2 <- call env "null" formCaps $ answer "1" (accepted ["ok" .= True]) : retrying r1
    textOf r2 `shouldBe` Just (String "(Nothing,Accepted (Confirm {ok = True}),2)")

  it "settles a pending question as Unavailable when the client can no longer ask" $ do
    env <- newCounters
    r1 <- call env "deploy" formCaps []
    r2 <- call env "deploy" noCaps (retrying r1)
    textOf r2 `shouldBe` Just (String "no target: Unavailable")
    r3 <- call env "null" formCaps []
    r4 <- call env "null" noCaps (retrying r3)
    textOf r4 `shouldBe` Just (String "(Nothing,Unavailable,2)")