packages feed

mcp-server-0.2.0.0: test/Spec/ModernEra.hs

{-# LANGUAGE OverloadedStrings #-}

-- | Coverage for dual-era operation: modern (2026-07-28, per-request _meta)
-- requests get server/discover, the modern result envelope and header
-- validation; legacy (initialize-handshake) requests are served byte-
-- identically to before.
module Spec.ModernEra (spec) where

import Data.Aeson
import qualified Data.Aeson.KeyMap as KM
import Data.Aeson.Types (Pair)
import Data.Text (Text)
import MCP.Server
import MCP.Server.Handlers (handleMcpMessage)
import MCP.Server.JsonRpc
import MCP.Server.Transport.Http (BodyPeek (..), decodeSentinel, peekBody,
                                  peekIsModern, validateRequestHeaders)
import qualified Network.Wai as Wai
import Test.Hspec

testServerInfo :: McpServerInfo
testServerInfo = McpServerInfo
  { serverName = "Test Server"
  , serverVersion = "9.9.9"
  , serverInstructions = "Use the tools."
  }

-- A server that only provides tools; the call handler reports the protocol
-- version it saw in the ClientContext.
testHandlers :: McpServerHandlers
testHandlers = noHandlers
  { tools = Just
      ( \_ctx -> pure []
      , \ctx _name _args -> pure $ Right $ toToolResult
          ("version=" <> maybe "none" id (clientProtocolVersion ctx))
      )
  }

run :: JsonRpcMessage -> IO (Maybe JsonRpcMessage)
run = handleMcpMessage testServerInfo defaultCacheHints noNotificationSupport testHandlers anonymousContext

request :: Text -> Maybe Value -> JsonRpcMessage
request method params = JsonRpcMessageRequest $ JsonRpcRequest
  { requestJsonrpc = "2.0"
  , requestId = RequestIdNumber 1
  , requestMethod = method
  , requestParams = params
  }

-- params carrying the standard modern _meta
modernParams :: [Pair] -> Value
modernParams extra = object $ extra ++
  [ "_meta" .= object
      [ "io.modelcontextprotocol/protocolVersion" .= ("2026-07-28" :: Text)
      , "io.modelcontextprotocol/clientInfo" .= object ["name" .= ("test-client" :: Text)]
      , "io.modelcontextprotocol/clientCapabilities" .= object []
      ]
  ]

resultObject :: Maybe JsonRpcMessage -> IO Object
resultObject (Just (JsonRpcMessageResponse r)) = case responseResult r of
  Just (Object o) -> pure o
  other -> expectationFailure ("Expected object result, got: " ++ show other) >> pure KM.empty
resultObject other =
  expectationFailure ("Expected a response, got: " ++ show other) >> pure KM.empty

errorOf :: Maybe JsonRpcMessage -> IO JsonRpcError
errorOf (Just (JsonRpcMessageResponse r)) = case responseError r of
  Just e -> pure e
  Nothing -> expectationFailure "Expected an error response" >> pure (JsonRpcError 0 "" Nothing)
errorOf other =
  expectationFailure ("Expected a response, got: " ++ show other) >> pure (JsonRpcError 0 "" Nothing)

spec :: Spec
spec = describe "Dual-era protocol support" $ do

  describe "server/discover" $ do
    it "reports all supported revisions, newest first" $ do
      o <- resultObject =<< run (request "server/discover" (Just (modernParams [])))
      KM.lookup "supportedVersions" o `shouldBe`
        Just (toJSON (["2026-07-28", "2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"] :: [Text]))

    it "advertises only capabilities with handlers, plus identity and instructions" $ do
      o <- resultObject =<< run (request "server/discover" (Just (modernParams [])))
      case KM.lookup "capabilities" o of
        Just (Object caps) -> do
          KM.member "tools" caps `shouldBe` True
          KM.member "prompts" caps `shouldBe` False
        other -> expectationFailure $ "capabilities not an object: " ++ show other
      KM.lookup "instructions" o `shouldBe` Just (String "Use the tools.")
      case KM.lookup "_meta" o of
        Just (Object m) ->
          KM.lookup "io.modelcontextprotocol/serverInfo" m `shouldBe`
            Just (object ["name" .= ("Test Server" :: Text), "version" .= ("9.9.9" :: Text)])
        other -> expectationFailure $ "_meta not an object: " ++ show other

    it "carries the modern result envelope even without _meta (probe)" $ do
      o <- resultObject =<< run (request "server/discover" Nothing)
      KM.lookup "resultType" o `shouldBe` Just (String "complete")
      KM.member "ttlMs" o `shouldBe` True
      KM.lookup "cacheScope" o `shouldBe` Just (String "private")

  describe "Modern requests" $ do
    it "stamps resultType and cache fields on cacheable results" $ do
      o <- resultObject =<< run (request "tools/list" (Just (modernParams [])))
      KM.lookup "resultType" o `shouldBe` Just (String "complete")
      KM.lookup "ttlMs" o `shouldBe` Just (Number 0)
      KM.lookup "cacheScope" o `shouldBe` Just (String "private")

    it "stamps resultType but not cache fields on tools/call" $ do
      o <- resultObject =<< run (request "tools/call"
        (Just (modernParams ["name" .= ("t" :: Text), "arguments" .= object []])))
      KM.lookup "resultType" o `shouldBe` Just (String "complete")
      KM.member "ttlMs" o `shouldBe` False

    it "exposes the declared protocol version to handlers via ClientContext" $ do
      o <- resultObject =<< run (request "tools/call"
        (Just (modernParams ["name" .= ("t" :: Text), "arguments" .= object []])))
      KM.lookup "content" o `shouldBe`
        Just (toJSON [object ["type" .= ("text" :: Text), "text" .= ("version=2026-07-28" :: Text)]])

    it "serves initialize and ping as unknown methods when a modern revision is declared" $ do
      e1 <- errorOf =<< run (request "initialize" (Just (modernParams
        [ "protocolVersion" .= ("2026-07-28" :: Text)
        , "capabilities" .= object []
        , "clientInfo" .= object ["name" .= ("c" :: Text), "version" .= ("1" :: Text)]
        ])))
      errorCode e1 `shouldBe` (-32601)
      e2 <- errorOf =<< run (request "ping" (Just (modernParams [])))
      errorCode e2 `shouldBe` (-32601)

    it "rejects undeclared revisions with UnsupportedProtocolVersionError" $ do
      e <- errorOf =<< run (request "tools/list" (Just (object
        [ "_meta" .= object ["io.modelcontextprotocol/protocolVersion" .= ("2099-01-01" :: Text)] ])))
      errorCode e `shouldBe` (-32022)
      case errorData e of
        Just (Object d) -> do
          KM.lookup "requested" d `shouldBe` Just (String "2099-01-01")
          case KM.lookup "supported" d of
            Just (Array _) -> pure ()
            other -> expectationFailure $ "supported not a list: " ++ show other
        other -> expectationFailure $ "no error data: " ++ show other

  describe "Legacy requests" $ do
    it "are served without the modern envelope" $ do
      o <- resultObject =<< run (request "tools/list" Nothing)
      KM.member "resultType" o `shouldBe` False
      KM.member "ttlMs" o `shouldBe` False
      KM.member "_meta" o `shouldBe` False

    it "negotiate down to the newest legacy revision when proposing 2026-07-28 via initialize" $ do
      o <- resultObject =<< run (request "initialize" (Just (object
        [ "protocolVersion" .= ("2026-07-28" :: Text)
        , "capabilities" .= object []
        , "clientInfo" .= object ["name" .= ("c" :: Text), "version" .= ("1" :: Text)]
        ])))
      KM.lookup "protocolVersion" o `shouldBe` Just (String "2025-11-25")

  describe "HTTP request metadata validation" $ do
    let modernBody name = encode $ object
          [ "jsonrpc" .= ("2.0" :: Text)
          , "id" .= (7 :: Int)
          , "method" .= ("tools/call" :: Text)
          , "params" .= modernParams ["name" .= name, "arguments" .= object []]
          ]
        reqWith hs = Wai.defaultRequest { Wai.requestHeaders = hs }

    it "peeks method, name, version and id from the body" $ do
      let peek = peekBody (modernBody ("get_weather" :: Text))
      peekMethod peek `shouldBe` Just "tools/call"
      peekName peek `shouldBe` Just "get_weather"
      peekMetaVersion peek `shouldBe` Just "2026-07-28"
      peekId peek `shouldBe` RequestIdNumber 7
      peekIsModern peek `shouldBe` True

    it "accepts a fully consistent modern request" $ do
      let peek = peekBody (modernBody ("get_weather" :: Text))
      validateRequestHeaders (reqWith
        [ ("MCP-Protocol-Version", "2026-07-28")
        , ("Mcp-Method", "tools/call")
        , ("Mcp-Name", "get_weather")
        ]) peek `shouldBe` Nothing

    it "rejects a missing Mcp-Method header with HeaderMismatch" $ do
      let peek = peekBody (modernBody ("get_weather" :: Text))
      fmap errorCode (validateRequestHeaders (reqWith
        [ ("MCP-Protocol-Version", "2026-07-28")
        , ("Mcp-Name", "get_weather")
        ]) peek) `shouldBe` Just (-32020)

    it "rejects a header/body protocol version mismatch with HeaderMismatch" $ do
      let peek = peekBody (modernBody ("get_weather" :: Text))
      fmap errorCode (validateRequestHeaders (reqWith
        [ ("MCP-Protocol-Version", "2025-11-25")
        , ("Mcp-Method", "tools/call")
        , ("Mcp-Name", "get_weather")
        ]) peek) `shouldBe` Just (-32020)

    it "accepts a base64-sentinel Mcp-Name for non-ASCII names" $ do
      let peek = peekBody (modernBody ("Hello, 世界" :: Text))
      validateRequestHeaders (reqWith
        [ ("MCP-Protocol-Version", "2026-07-28")
        , ("Mcp-Method", "tools/call")
        , ("Mcp-Name", "=?base64?SGVsbG8sIOS4lueVjA==?=")
        ]) peek `shouldBe` Nothing

    it "legacy bodies keep the relaxed rules (no headers required)" $ do
      let legacyBody = encode $ object
            [ "jsonrpc" .= ("2.0" :: Text), "id" .= (1 :: Int)
            , "method" .= ("tools/list" :: Text) ]
      validateRequestHeaders (reqWith []) (peekBody legacyBody) `shouldBe` Nothing

    it "legacy bodies with an unsupported version header are rejected" $ do
      let legacyBody = encode $ object
            [ "jsonrpc" .= ("2.0" :: Text), "id" .= (1 :: Int)
            , "method" .= ("tools/list" :: Text) ]
      fmap errorCode (validateRequestHeaders
        (reqWith [("MCP-Protocol-Version", "1999-01-01")])
        (peekBody legacyBody)) `shouldBe` Just (-32600)

  describe "Sentinel decoding" $ do
    it "passes plain values through" $
      decodeSentinel "us-west1" `shouldBe` "us-west1"
    it "decodes base64 sentinel values" $
      decodeSentinel "=?base64?SGVsbG8sIOS4lueVjA==?=" `shouldBe` "Hello, 世界"