packages feed

mcp-server-0.3.0.1: test/AskTypes.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TemplateHaskell   #-}

-- | Forms and handlers for the derived-interface tests of 'Ask' (ADR 0009,
-- phase 3). Splices that derive handlers live in "Spec.DerivedAsk".
module AskTypes where

import           Control.Monad.IO.Class (liftIO)
import           Data.Text              (Text)
import qualified Data.Text              as T
import           Language.Haskell.TH    (recover)
import           MCP.Server
import           MCP.Server.Derive

data Environment = Staging | Production
  deriving (Show, Eq)

data Tag = Urgent | Routine
  deriving (Show, Eq)

newtype Ticket = Ticket Text
  deriving (Show, Eq)

data DeployTarget = DeployTarget
  { environment :: Environment
  , reason      :: Text
  , notify      :: Maybe Bool
  , replicas    :: Int
  , tags        :: [Tag]
  , ticket      :: Maybe Ticket
  } deriving (Show, Eq)

newtype Confirm = Confirm { proceed :: Bool }
  deriving (Show, Eq)

data Styled = Styled { size :: Double, label :: Text, mode :: Environment }
  deriving (Show, Eq)

data Counted = Counted { count :: Int, greeting :: Text }
  deriving (Show, Eq)

$(deriveFormWithDescription ''DeployTarget [("reason", "Why deploy now?")])
$(deriveForm ''Confirm)
$(deriveFormWithOptions ''Styled
    [ ("size", defaultFormFieldOptions { formFieldTitle = Just "Size", formFieldDefault = Just (Number 1.5) })
    , ("mode", defaultFormFieldOptions { formFieldDefault = Just "production", formFieldDescription = Just "Where" })
    ])

$(deriveFormWithOptions ''Counted
    [ ("count", defaultFormFieldOptions { formFieldDefault = Just (Number 3) })
    , ("greeting", defaultFormFieldOptions { formFieldDefault = Just "café 東京" })
    ])

-- Types a form cannot hold, for the compile-time rejection tests.
data Inner = Inner { innerName :: Text } deriving (Show, Eq)
data Nested = Nested { inner :: Inner } deriving (Show, Eq)
data TextList = TextList { names :: [Text] } deriving (Show, Eq)
data Unit = Unit deriving (Show, Eq)

-- | Which derivations were rejected at compile time.
rejectedNested, rejectedTextList, rejectedUnit, rejectedUnknownField, rejectedBadDefault, rejectedBadChoice,
  rejectedFractionalInt, rejectedOutOfRangeInt :: Bool
rejectedNested = $(recover [| True |] (deriveForm ''Nested >> [| False |]))
rejectedTextList = $(recover [| True |] (deriveForm ''TextList >> [| False |]))
rejectedUnit = $(recover [| True |] (deriveForm ''Unit >> [| False |]))
rejectedUnknownField = $(recover [| True |]
  (deriveFormWithDescription ''Confirm [("procede", "typo")] >> [| False |]))
rejectedBadDefault = $(recover [| True |]
  (deriveFormWithOptions ''Confirm [("proceed", defaultFormFieldOptions { formFieldDefault = Just "yes" })] >> [| False |]))
rejectedFractionalInt = $(recover [| True |]
  (deriveFormWithOptions ''Counted [("count", defaultFormFieldOptions { formFieldDefault = Just (Number 1.5) })] >> [| False |]))
rejectedOutOfRangeInt = $(recover [| True |]
  (deriveFormWithOptions ''Counted [("count", defaultFormFieldOptions { formFieldDefault = Just (Number 1e30) })] >> [| False |]))
rejectedBadChoice = $(recover [| True |]
  (deriveFormWithOptions ''Styled [("mode", defaultFormFieldOptions { formFieldDefault = Just "prod" })] >> [| False |]))

-- Tools: one asks, one never does.
data OpsTool
  = Deploy { service :: Text }
  | Status { service :: Text }
  deriving (Show, Eq)

handleOps :: ClientContext -> OpsTool -> Ask Content
handleOps _ (Deploy svc) = do
  target <- elicit ("Where should " <> svc <> " deploy to?")
  case target of
    Accepted t -> do
      plan <- step (pure (T.pack (show (environment t)) <> " x" <> T.pack (show (replicas t))))
      ok <- elicit ("Apply " <> plan <> "?")
      pure $ ContentText $ case ok of
        Accepted (Confirm True) -> "deployed " <> svc <> ": " <> plan
        _                       -> "cancelled"
    other -> pure (ContentText ("no target: " <> T.pack (show (() <$ other))))
handleOps _ (Status svc) = ContentText <$> liftIO (pure (svc <> " is up"))

-- An IO handler still derives unchanged.
handleOpsIO :: ClientContext -> OpsTool -> IO Content
handleOpsIO _ (Deploy svc) = pure (ContentText ("deployed " <> svc))
handleOpsIO _ (Status svc) = pure (ContentText (svc <> " is up"))

-- Structured output from an asking handler.
newtype DeployReport = DeployReport { deployed :: Bool }

handleOpsOutput :: ClientContext -> OpsTool -> Ask (ToolOutput DeployReport)
handleOpsOutput _ _ = do
  ok <- elicit "Go?"
  pure $ ToolOutput $ DeployReport $ case ok of
    Accepted (Confirm b) -> b
    _                    -> False

-- Derived here, where only 'Value' is imported from aeson: the generated
-- serializer must not rely on names from the user's imports.
$(pure [])

outputTools :: (ToolListHandler, ToolCallHandler)
outputTools = $(deriveToolHandlerWithOutput ''OpsTool 'handleOpsOutput ''DeployReport)

-- Prompts and resources can ask too.
newtype OpsPrompt = Briefing { team :: Text }

handlePrompt :: ClientContext -> OpsPrompt -> Ask Content
handlePrompt _ (Briefing t) = do
  ok <- elicit ("Include incidents for " <> t <> "?")
  pure $ ContentText $ case ok of
    Accepted (Confirm True) -> "briefing with incidents"
    _                       -> "briefing"

data OpsResource = Secrets

handleResource :: ClientContext -> URI -> OpsResource -> Ask ResourceContent
handleResource _ uri Secrets = do
  ok <- elicit "Reveal secrets?"
  pure $ ResourceText uri "text/plain" $ case ok of
    Accepted (Confirm True) -> "s3cret"
    _                       -> "hidden"