packages feed

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

{-# LANGUAGE OverloadedStrings #-}

-- | Drive the Streamable HTTP transport in-process through the exported
-- WAI application, without Warp or a socket. The handler-level specs pin
-- what 'handleMcpMessage' answers; these pin what actually reaches an
-- HTTP client, which is the layer that used to lose a throwing handler
-- (a bare Warp 500 for single-JSON responses, a dropped connection for
-- SSE ones).
module Spec.HttpTransport (spec) where

import Control.Exception (ErrorCall (..), throwIO)
import Data.Aeson
import qualified Data.Aeson.KeyMap as KM
import qualified Data.ByteString as BS
import qualified Data.ByteString.Builder as B
import qualified Data.ByteString.Lazy as BSL
import Data.IORef
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import MCP.Server
import qualified Network.HTTP.Types as HTTP
import qualified Network.Wai as Wai
import Network.Wai.Internal (ResponseReceived (..))
import Test.Hspec
import TestHelpers (testStateCodec)
import qualified Data.Map as Map

server :: McpServerHandlers
server = noHandlers
  { tools = Just
      ( \_ -> pure []
      , \ctx name _ -> case name of
          "boom" -> throwIO (ErrorCall "tool exploded")
          "ok"   -> pure $ Right $ Complete $ toToolResult ("fine" :: Text)
          "ask"  -> pure $ Right $ case Map.lookup "q" (clientInputResponses ctx) of
            Nothing -> InputRequired
              (Map.singleton "q" (ElicitForm "Sure?" (schema (SchemaObject [] []))))
              (Just "asked")
            Just _ -> Complete $ toToolResult
              ("answered after " <> maybe "nothing" id (clientRequestState ctx))
          _      -> pure $ Left $ UnknownTool name
      )
  }

app :: IO Wai.Application
app = mcpApplication defaultHttpConfig
        { httpAllowedOrigins = Nothing
        , httpStateCodec = Just testStateCodec  -- shared across 'post's
        }
        (McpServerInfo "T" "1" "") server

-- | POST a JSON body to /mcp and collect the full response, whether it was
-- sent as a single body or streamed (SSE).
post :: [HTTP.Header] -> Value -> IO (HTTP.Status, [HTTP.Header], BS.ByteString)
post extraHeaders body = do
  chunks <- newIORef (BSL.toChunks (encode body))
  let nextChunk = atomicModifyIORef' chunks $ \cs -> case cs of
        []     -> ([], BS.empty)
        (c:cs') -> (cs', c)
      req = Wai.setRequestBodyChunks nextChunk Wai.defaultRequest
        { Wai.requestMethod = "POST"
        , Wai.rawPathInfo = "/mcp"
        , Wai.pathInfo = ["mcp"]
        , Wai.requestHeaders =
            ("Content-Type", "application/json")
              : ("Accept", "application/json, text/event-stream")
              : extraHeaders
        }
  out <- newIORef mempty
  result <- newIORef Nothing
  application <- app
  _ <- application req $ \resp -> do
    let (status, headers, withBody) = Wai.responseToStream resp
    withBody $ \streamingBody ->
      streamingBody (\b -> modifyIORef' out (<> b)) (pure ())
    writeIORef result (Just (status, headers))
    pure ResponseReceived
  Just (status, headers) <- readIORef result
  bytes <- BSL.toStrict . B.toLazyByteString <$> readIORef out
  pure (status, headers, bytes)

toolCall :: Text -> [(Key, Value)] -> Value
toolCall name meta = object
  [ "jsonrpc" .= ("2.0" :: Text)
  , "id" .= (7 :: Int)
  , "method" .= ("tools/call" :: Text)
  , "params" .= object
      ([ "name" .= name, "arguments" .= object [] ]
        ++ [ "_meta" .= object meta | not (null meta) ])
  ]

-- | The JSON-RPC error (code, message) in a response body, or in the
-- last SSE data event of a streamed one.
errorIn :: BS.ByteString -> Maybe (Int, Text)
errorIn raw = do
  let payload = case [ BS.drop 6 l | l <- BS.split 10 raw, "data: " `BS.isPrefixOf` l ] of
        [] -> raw
        ls -> last ls
  Object o <- decodeStrict payload
  Object e <- KM.lookup "error" o
  Number c <- KM.lookup "code" e
  String m <- KM.lookup "message" e
  pure (round c, m)

-- | A modern tools/call of the "ask" tool, with extra params.
askCall :: [(Key, Value)] -> Value
askCall extra = object
  [ "jsonrpc" .= ("2.0" :: Text)
  , "id" .= (8 :: Int)
  , "method" .= ("tools/call" :: Text)
  , "params" .= object (
      [ "name" .= ("ask" :: Text)
      , "_meta" .= object
          [ "io.modelcontextprotocol/protocolVersion" .= ("2026-07-28" :: Text)
          , "io.modelcontextprotocol/clientCapabilities" .= object ["elicitation" .= object []]
          ]
      ] ++ extra)
  ]

modernHeaders :: Text -> [HTTP.Header]
modernHeaders name =
  [ ("MCP-Protocol-Version", "2026-07-28")
  , ("Mcp-Method", "tools/call")
  , ("Mcp-Name", TE.encodeUtf8 name)
  ]

resultIn :: BS.ByteString -> Maybe Object
resultIn raw = do
  Object o <- decodeStrict raw
  Object r <- KM.lookup "result" o
  pure r

spec :: Spec
spec = describe "HTTP transport (in-process WAI)" $ do
  it "carries a multi round-trip request across two POSTs" $ do
    (status1, _, body1) <- post (modernHeaders "ask") (askCall [])
    HTTP.statusCode status1 `shouldBe` 200
    let first = resultIn body1
    (first >>= KM.lookup "resultType") `shouldBe` Just (String "input_required")
    Just (String sealed) <- pure (first >>= KM.lookup "requestState")
    (status2, _, body2) <- post (modernHeaders "ask") $ askCall
      [ "inputResponses" .= object ["q" .= object ["action" .= ("accept" :: Text), "content" .= object []]]
      , "requestState" .= sealed
      ]
    HTTP.statusCode status2 `shouldBe` 200
    body2 `shouldSatisfy` ("answered after asked" `BS.isInfixOf`)

  it "answers a normal tool call with 200 and a result" $ do
    (status, _, body) <- post [] (toolCall "ok" [])
    HTTP.statusCode status `shouldBe` 200
    body `shouldSatisfy` ("\"fine\"" `BS.isInfixOf`)

  it "answers a throwing handler with 200 and a -32603 body (legacy, single-JSON)" $ do
    (status, headers, body) <- post [] (toolCall "boom" [])
    HTTP.statusCode status `shouldBe` 200
    lookup "Content-Type" headers `shouldBe` Just "application/json"
    fmap fst (errorIn body) `shouldBe` Just (-32603)
    fmap snd (errorIn body) `shouldSatisfy` maybe False (T.isInfixOf "tool exploded")

  it "answers a throwing handler inside the SSE stream (legacy, progressToken)" $ do
    (status, headers, body) <- post [] (toolCall "boom" ["progressToken" .= ("t1" :: Text)])
    HTTP.statusCode status `shouldBe` 200
    lookup "Content-Type" headers `shouldBe` Just "text/event-stream"
    body `shouldSatisfy` ("data: " `BS.isPrefixOf`)
    fmap fst (errorIn body) `shouldBe` Just (-32603)

  it "answers a throwing handler with a -32603 body (modern 2026-07-28)" $ do
    (status, _, body) <- post
      [ ("MCP-Protocol-Version", "2026-07-28")
      , ("Mcp-Method", "tools/call")
      , ("Mcp-Name", "boom")
      ]
      (toolCall "boom" ["io.modelcontextprotocol/protocolVersion" .= ("2026-07-28" :: Text)])
    HTTP.statusCode status `shouldBe` 200
    fmap fst (errorIn body) `shouldBe` Just (-32603)
    -- an error carries no result envelope
    body `shouldSatisfy` (not . ("resultType" `BS.isInfixOf`))