mcp-server-0.2.0.2: 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 MCP.Server
import qualified Network.HTTP.Types as HTTP
import qualified Network.Wai as Wai
import Network.Wai.Internal (ResponseReceived (..))
import Test.Hspec
server :: McpServerHandlers
server = noHandlers
{ tools = Just
( \_ -> pure []
, \_ name _ -> case name of
"boom" -> throwIO (ErrorCall "tool exploded")
"ok" -> pure $ Right $ toToolResult ("fine" :: Text)
_ -> pure $ Left $ UnknownTool name
)
}
app :: Wai.Application
app = mcpApplication defaultHttpConfig { httpAllowedOrigins = Nothing }
(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
_ <- app 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)
spec :: Spec
spec = describe "HTTP transport (in-process WAI)" $ do
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`))