packages feed

mcp-server-0.3.0.1: src/MCP/Server/Ask.hs

{-# LANGUAGE DefaultSignatures #-}
{-# LANGUAGE OverloadedStrings #-}

-- | Handlers that ask the client questions part-way through, written as
-- straight-line code.
--
-- A multi round-trip request ends each round that needs input: the client
-- answers and retries the request, possibly on another server instance.
-- So an t'Ask' handler does not pause at a question. It runs from the start
-- on every round, against a /journal/ of what earlier rounds produced, which
-- the library keeps sealed in the request state:
--
-- * 'elicit' returns the answer recorded for it, or ends the round with the
--   question when there is none yet.
-- * 'step' runs its action once, records the result, and returns the
--   recorded result on later rounds.
--
-- > confirmDelete :: ClientContext -> Text -> Ask ToolResult
-- > confirmDelete _ name = do
-- >   size <- step (fileSize name)
-- >   answer <- elicit ("Delete " <> name <> " (" <> showSize size <> ")?")
-- >   case answer of
-- >     Accepted (Confirm True) -> do
-- >       step (deleteFile name)
-- >       pure (toolResult [ContentText "Deleted"])
-- >     Unavailable -> pure (toolError "Confirmation needed: this client cannot ask")
-- >     _ -> pure (toolResult [ContentText "Kept"])
--
-- Three rules follow from replay:
--
-- * __Ask the same questions in the same order.__ Each 'elicit' and 'step'
--   takes the next position in the journal. If a round asks something
--   different at a position, the request fails with an internal error that
--   names the position, rather than passing an answer to the wrong question.
-- * __'step' is at-least-once.__ If a round's reply is lost and the client
--   retries with an earlier state, the step runs again. Effects that must
--   happen once must be idempotent or guarded on the server.
-- * __'step' results travel to the client and back__ (encrypted) on every
--   later round. Keep them small: an id rather than the object.
--
-- Plain 'liftIO' is not journaled: it runs on every round. Use it for reads
-- that may change between rounds without changing which questions are
-- asked.
--
-- Derived handlers ("MCP.Server.Derive") may return t'Ask' instead of
-- 'IO', and 'MCP.Server.Derive.deriveForm' derives t'Form' instances from
-- records.
module MCP.Server.Ask
  ( -- * Handlers that ask
    Ask
  , runAsk
  , askContext
    -- * Questions
  , Answer(..)
  , Form(..)
  , elicit
  , elicitUrl
    -- * Journaled IO
  , step
    -- * Derived handlers
  , HandlerMonad(..)
  ) where

import           Control.Monad            (ap, liftM)
import           Control.Monad.IO.Class   (MonadIO (..))
import           Data.Aeson
import qualified Data.Aeson.KeyMap        as KM
import           Data.Aeson.Types         (parseEither)
import qualified Data.ByteString.Lazy     as BSL
import           Data.Map                 (Map)
import qualified Data.Map                 as Map
import           Data.Proxy               (Proxy (..))
import           Data.Text                (Text)
import qualified Data.Text                as T
import qualified Data.Text.Encoding       as TE

import           MCP.Server.Internal.Json (jsonDigest)
import           MCP.Server.Types

-- | How the user answered a question.
data Answer a
  = Accepted a
    -- ^ The user submitted the form (for 'elicitUrl': completed the
    --   interaction).
  | Declined
    -- ^ The user explicitly declined.
  | Cancelled
    -- ^ The user dismissed the question without choosing.
  | Unavailable
    -- ^ The client cannot ask: it speaks a protocol revision without
    --   multi round-trip requests, or did not declare the elicitation
    --   mode. No round trip took place.
  deriving (Show, Eq, Functor)

-- | A form to elicit: an object of strings, numbers, booleans and
-- enumerations, as elicitation allows, and how to read what the user
-- submitted. 'formParse' defaults to the type's 'FromJSON' instance.
--
-- > data Confirm = Confirm { proceed :: Bool } deriving Generic
-- > instance FromJSON Confirm
-- > instance Form Confirm where
-- >   formSchema _ = schema $ SchemaObject [("proceed", schema SchemaBoolean)] ["proceed"]
class Form a where
  -- | The requested schema.
  formSchema :: Proxy a -> Schema
  -- | Read the submitted content, or say why it does not fit the form.
  formParse :: Map Text Value -> Either Text a
  default formParse :: FromJSON a => Map Text Value -> Either Text a
  formParse content = either (Left . T.pack) Right (parseEither parseJSON (toJSON content))

-- | A handler that can ask the client for input. Run it with 'runAsk'.
newtype Ask a = Ask (ClientContext -> Run -> IO (Round a))

-- Where a run is in the journal.
data Run = Run
  { runReplay   :: [Entry]     -- entries from earlier rounds not yet reached
  , runPending  :: Maybe Text  -- fingerprint of the question the last round asked
  , runPosition :: Int
  , runWritten  :: [Entry]     -- the journal so far, newest first
  }

data Round a
  = Done a Run
  | Suspended Int Text InputRequest [Entry]  -- position, fingerprint, request, journal
  | Failed Error

instance Functor Ask where
  fmap = liftM

instance Applicative Ask where
  pure a = Ask $ \_ run -> pure (Done a run)
  (<*>) = ap

instance Monad Ask where
  Ask m >>= k = Ask $ \ctx run -> do
    result <- m ctx run
    case result of
      Done a run'                -> let Ask m' = k a in m' ctx run'
      Suspended pos fp req entries -> pure (Suspended pos fp req entries)
      Failed err                 -> pure (Failed err)

-- | Runs on every round; not journaled.
instance MonadIO Ask where
  liftIO io = Ask $ \_ run -> (\a -> Done a run) <$> io

-- | The context of the current round's request.
askContext :: Ask ClientContext
askContext = Ask $ \ctx run -> pure (Done ctx run)

-- | Run a handler for one round: 'Complete' when it finishes, or
-- 'InputRequired' with its next question and its journal as the state.
-- Use it in a low-level call handler; derived handlers are run with it
-- for you (see t'HandlerMonad').
runAsk :: Ask a -> ClientContext -> IO (Either Error (Outcome a))
runAsk (Ask m) ctx = case readJournal (clientRequestState ctx) of
  Left err -> pure (Left (InternalError err))
  Right (Journal entries pending) -> do
    result <- m ctx Run
      { runReplay = entries
      , runPending = pending
      , runPosition = 0
      , runWritten = []
      }
    pure $ case result of
      Done a run
        | null (runReplay run), Nothing <- runPending run -> Right (Complete a)
        | otherwise -> Left $ mismatch (runPosition run)
            "the handler finished before reaching it"
      Suspended pos fp request written -> Right $ InputRequired
        (Map.singleton (positionKey pos) request)
        (Just (writeJournal (Journal written (Just fp))))
      Failed err -> Left err

-- | The monads a derived handler can run in: 'IO' for handlers that never
-- ask, and t'Ask' for handlers that do. The @derive*Handler@ splices run
-- the handler through this class, so either return type works and a handler
-- switches by changing its type alone.
class Monad m => HandlerMonad m where
  -- | Run a handler for one round of a request.
  runHandler :: ClientContext -> m a -> IO (Either Error (Outcome a))

instance HandlerMonad IO where
  runHandler _ action = Right . Complete <$> action

instance HandlerMonad Ask where
  runHandler ctx handler = runAsk handler ctx

-- | Ask the user to fill in the form for @a@, with a message.
elicit :: Form a => Text -> Ask (Answer a)
elicit message = result
  where
    result = question (ElicitForm message (formSchema (proxyOf result))) parse
    parse (Just content) = formParse content
    parse Nothing        = Left "accepted without content"
    proxyOf :: Ask (Answer a) -> Proxy a
    proxyOf _ = Proxy

-- | Send the user to a URL to complete an interaction out of band, with a
-- message. 'Accepted' means the user agreed to go; the interaction's
-- result reaches the server by other means.
elicitUrl :: Text -> URI -> Ask (Answer ())
elicitUrl message url = question (ElicitUrl message url) (const (Right ()))

-- | Run an action once, and replay its result on later rounds. The result
-- is stored in the request state, so it must survive a JSON round trip; it
-- is returned decoded on every round, the first included.
step :: (ToJSON a, FromJSON a) => IO a -> Ask a
step action = Ask $ \_ run -> case (runReplay run, runPending run) of
  (Stepped v : rest, _) -> pure $ case fromJSON v of
    Success a -> Done a (advance (Stepped v) run { runReplay = rest })
    Error err -> Failed $ InternalError $
      "the step result at position " <> showT (runPosition run)
        <> " no longer decodes: " <> T.pack err
  (_ : _, _) -> pure $ Failed $ mismatch (runPosition run) "the handler ran a step instead"
  ([], Just _) -> pure $ Failed $ mismatch (runPosition run) "the handler ran a step instead"
  ([], Nothing) -> do
    v <- toJSON <$> action
    pure $ case fromJSON v of
      Success a -> Done a (advance (Stepped v) run)
      Error err -> Failed $ InternalError $
        "the step result at position " <> showT (runPosition run)
          <> " does not decode from its own JSON: " <> T.pack err

-- A question at the next position: the request, and how to read an
-- accepted answer's content.
question :: InputRequest -> (Maybe (Map Text Value) -> Either Text a) -> Ask (Answer a)
question request parse = Ask $ \ctx run ->
  let pos = runPosition run
      key = positionKey pos
      fp = jsonDigest (toJSON request)
      record recorded = advance (Asked fp recorded) run
      suspend = Suspended pos fp request (reverse (runWritten run))
      supported = clientSupportsInput ctx request
  in pure $ case (runReplay run, runPending run) of
    (Asked fp' recorded : rest, _)
      | fp' == fp -> case answerOf parse recorded of
          Right a  -> Done a (record recorded) { runReplay = rest }
          Left err -> Failed $ InternalError $
            "the answer at position " <> key <> " no longer parses: " <> err
    (_ : _, _) -> Failed $ mismatch pos "the handler asked a different question"
    ([], Just fp')
      | fp' /= fp -> Failed $ mismatch pos "the handler asked a different question"
      | otherwise -> case Map.lookup key (clientInputResponses ctx) of
          Nothing
            | supported -> suspend
            | otherwise -> Done Unavailable (record Unavailable') { runPending = Nothing }
          Just response -> case recordResponse response >>= \r -> (,) r <$> answerOf parse r of
            Right (recorded, a) -> Done a (record recorded) { runPending = Nothing }
            Left err -> Failed $ InvalidParams $ "inputResponses: " <> key <> ": " <> err
    ([], Nothing)
      | supported -> suspend
      | otherwise -> Done Unavailable (record Unavailable')

advance :: Entry -> Run -> Run
advance entry run = run
  { runPosition = runPosition run + 1
  , runWritten = entry : runWritten run
  }

mismatch :: Int -> Text -> Error
mismatch pos what = InternalError $
  "the handler does not match its journal at position " <> showT pos <> ": "
    <> what <> ". Ask handlers must ask the same questions and run the same steps"
    <> " in the same order on every round"

positionKey :: Int -> Text
positionKey = showT

showT :: Show a => a -> Text
showT = T.pack . show

-- The journal ------------------------------------------------------------

data Journal = Journal [Entry] (Maybe Text)

data Entry
  = Asked Text Recorded  -- the question's fingerprint, and its answer
  | Stepped Value

data Recorded
  = Accepted' (Maybe (Map Text Value))
  | Declined'
  | Cancelled'
  | Unavailable'

recordResponse :: InputResponse -> Either Text Recorded
recordResponse (ElicitResponse action content) = Right $ case action of
  ElicitAccept  -> Accepted' content
  ElicitDecline -> Declined'
  ElicitCancel  -> Cancelled'
recordResponse (RawInputResponse _) = Left "expected an elicitation result"

answerOf :: (Maybe (Map Text Value) -> Either Text a) -> Recorded -> Either Text (Answer a)
answerOf parse (Accepted' content) = Accepted <$> parse content
answerOf _ Declined'               = Right Declined
answerOf _ Cancelled'              = Right Cancelled
answerOf _ Unavailable'            = Right Unavailable

instance ToJSON Entry where
  toJSON (Asked fp recorded) = object $ ("q" .= fp) : case recorded of
    Accepted' content -> ["a" .= ("accept" :: Text), "c" .= content]
    Declined'         -> ["a" .= ("decline" :: Text)]
    Cancelled'        -> ["a" .= ("cancel" :: Text)]
    Unavailable'      -> ["a" .= ("unavailable" :: Text)]
  toJSON (Stepped v) = object ["s" .= v]

instance FromJSON Entry where
  parseJSON = withObject "Entry" $ \o -> do
    -- By presence: a step's result may itself be null.
    case KM.lookup "s" o of
      Just v -> pure (Stepped v)
      Nothing -> do
        fp <- o .: "q"
        action <- o .: "a"
        Asked fp <$> case action :: Text of
          "accept"      -> Accepted' <$> o .:? "c"
          "decline"     -> pure Declined'
          "cancel"      -> pure Cancelled'
          "unavailable" -> pure Unavailable'
          _             -> fail "unknown answer"

writeJournal :: Journal -> Text
writeJournal (Journal entries pending) = TE.decodeUtf8 $ BSL.toStrict $ encode $ object
  [ "v" .= (1 :: Int)
  , "j" .= entries
  , "p" .= pending
  ]

readJournal :: Maybe Text -> Either Text Journal
readJournal Nothing = Right (Journal [] Nothing)
readJournal (Just state) =
  either (\err -> Left ("the request state is not an Ask journal: " <> T.pack err)) Right $
    eitherDecodeStrict (TE.encodeUtf8 state) >>= parseEither parser
  where
    parser = withObject "Journal" $ \o -> do
      version <- o .: "v"
      if version /= (1 :: Int)
        then fail "unsupported journal version"
        else Journal <$> o .: "j" <*> o .:? "p"