packages feed

mcp-0.3.1.0: test/MCP/SimpleHTTPIntegration.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE ImportQualifiedPost #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeFamilies #-}

{- |
Module:      MCP.SimpleHTTPIntegration
License:     MPL-2.0
Maintainer:  <matti@dpella.io>, <lobo@dpella.io>

Integration tests for the SimpleHTTP transport.
-}
module MCP.SimpleHTTPIntegration where

import Control.Concurrent.MVar (MVar, newMVar)
import Control.Monad.IO.Class (liftIO)
import Data.Aeson (FromJSON, toJSON)
import Data.Aeson qualified as Aeson
import Data.Attoparsec.ByteString.Char8 as C
import Data.Attoparsec.ByteString.Lazy as P
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as LBS
import Data.IntMap qualified as IM
import Data.Map qualified as Map
import MCP.Protocol
import MCP.Server.Common
import MCP.Server.HTTP (simpleHttpApp)
import MCP.TestServer (
    availablePrompts,
    availableResourceTemplates,
    availableResources,
    availableTools,
    initializeTestState,
    mb_handler_finalize,
    processHandlers,
 )
import MCP.TestUtils
import Network.HTTP.Types qualified as HTTP
import Network.Wai (Application)
import Network.Wai.Test (SResponse (..))
import Test.Hspec
import Test.Hspec.Wai (
    WaiSession,
    get,
    shouldRespondWith,
    withState,
 )

-- ---------------------------------------------------------------------------
-- Test application setup
-- ---------------------------------------------------------------------------

-- | Create a test application using SimpleHTTP (no auth)
createTestApp :: IO ((), Application)
createTestApp = do
    state_var <- createSimpleHTTPTestState
    let app = simpleHttpApp state_var
    return ((), app)

-- | Create test server state for SimpleHTTP (no handler_init, same as Stdio)
createSimpleHTTPTestState :: IO (MVar MCPServerState)
createSimpleHTTPTestState = do
    let impl = Implementation "test-server" "1.0.0" Nothing
    let server_caps =
            ServerCapabilities
                { logging = Just LoggingCapability
                , prompts = Just (PromptsCapability{listChanged = Nothing})
                , resources =
                    Just
                        (ResourcesCapability{listChanged = Nothing, subscribe = Nothing})
                , tools = Just (ToolsCapability{listChanged = Just True})
                , completions = Just CompletionsCapability
                , experimental = Nothing
                }
    newMVar
        MCPServerState
            { mcp_server_initialized = False
            , mcp_handler_state = initializeTestState
            , mcp_handler_init = Nothing -- no user type in SimpleHTTP
            , mcp_handler_finalize = mb_handler_finalize
            , mcp_client_capabilities = Nothing
            , mcp_log_level = Just Info
            , mcp_pending_responses = IM.empty
            , mcp_pending_responses_next = 1
            , mcp_server_capabilities = server_caps
            , mcp_implementation = impl
            , mcp_instructions = Nothing
            , mcp_process_handlers = processHandlers
            }

-- ---------------------------------------------------------------------------
-- Test specifications
-- ---------------------------------------------------------------------------

-- | Complete SimpleHTTP integration test suite
simpleHTTPIntegrationSpec :: Spec
simpleHTTPIntegrationSpec = describe "SimpleHTTP Integration Tests" $ do
    withState createTestApp $ do
        protocolFlowSpec
        endpointsSpec
        preInitializationSpec

-- ---------------------------------------------------------------------------
-- Protocol flow tests
-- ---------------------------------------------------------------------------

protocolFlowSpec :: SpecWith ((), Application)
protocolFlowSpec = describe "Protocol Flow" $ do
    it "initialization handshake works" $ do
        let init_req = toJSON createInitializeRequest
        resp_init <- mcpPostRequest headers init_req
        withValidSimpleHTTPResponse resp_init 1 validateInitializationResponse

        let notify_request = toJSON createInitializedNotification
        mcpPostRequestOk headers notify_request

    it "rejects request with wrong jsonrpc version" $ do
        let invalid_request =
                toJSON $
                    createJSONRPCRequest (Just "1.0") (1 :: Int) "ping" Aeson.Null
        mcpPostRequestExpects headers invalid_request 400

    it "accepts GET /mcp" $
        get "/mcp" `shouldRespondWith` 200

-- ---------------------------------------------------------------------------
-- Endpoint tests
-- ---------------------------------------------------------------------------

endpointsSpec :: SpecWith ((), Application)
endpointsSpec = describe "Endpoints" $ do
    it "handles tools/list request" $
        withInitializedSimpleHTTP $ do
            let req = toJSON $ createListToolsRequest 2
            resp <- mcpPostRequest headers req
            withValidSimpleHTTPResponse resp 2 $ \(ListToolsResult{tools = ls_tools}) ->
                length ls_tools `shouldBe` length availableTools

    it "handles tools/call request" $
        withInitializedSimpleHTTP $ do
            let req = toJSON $ createCallToolRequest 3 "addition-tool" [("arg1", toJSON (5 :: Int)), ("arg2", toJSON (7 :: Int))]
            resp <- mcpPostRequest headers req
            withValidSimpleHTTPResponse resp 3 $ \(CallToolResult{structuredContent = structured}) ->
                case structured of
                    Just map_results ->
                        Map.lookup "result" map_results `shouldBe` Just (toJSON (12 :: Int))
                    Nothing -> expectationFailure "Expected structured content"

    it "handles resources/list request" $
        withInitializedSimpleHTTP $ do
            let req = toJSON $ createListResourcesRequest 4
            resp <- mcpPostRequest headers req
            withValidSimpleHTTPResponse resp 4 $ \(ListResourcesResult{resources = ls_resources}) ->
                length ls_resources `shouldBe` length availableResources

    it "handles prompts/list request" $
        withInitializedSimpleHTTP $ do
            let req = toJSON $ createPromptListRequest 5
            resp <- mcpPostRequest headers req
            withValidSimpleHTTPResponse resp 5 $ \(ListPromptsResult{prompts = ls_prompts}) ->
                length ls_prompts `shouldBe` length availablePrompts

    it "handles resources/templates/list request" $
        withInitializedSimpleHTTP $ do
            let req = toJSON $ createListResourceTemplatesRequest 6
            resp <- mcpPostRequest headers req
            withValidSimpleHTTPResponse resp 6 $ \(ListResourceTemplatesResult{resourceTemplates = templates}) ->
                length templates `shouldBe` length availableResourceTemplates

    it "handles ping after initialization" $
        withInitializedSimpleHTTP $ do
            let req = toJSON createPingRequest
            mcpPostRequestOk headers req

-- ---------------------------------------------------------------------------
-- Pre-initialization tests
-- ---------------------------------------------------------------------------

preInitializationSpec :: SpecWith ((), Application)
preInitializationSpec = describe "Pre-Initialization Enforcement" $ do
    it "rejects tools/list before initialization" $ do
        let req = toJSON $ createListToolsRequest 1
        resp <- mcpPostRequest headers req
        withValidSimpleHTTPErrorResponse resp 1 $ \err_info ->
            code err_info `shouldBe` sERVER_NOT_INITIALIZED

    it "allows ping before initialization" $ do
        let req = toJSON createPingRequest
        mcpPostRequestOk headers req

-- ---------------------------------------------------------------------------
-- Helpers
-- ---------------------------------------------------------------------------

-- | Standard headers for SimpleHTTP requests (no auth needed)
headers :: [(HTTP.HeaderName, BS.ByteString)]
headers = [("Content-Type", "application/json")]

-- | Initialize server then run action
withInitializedSimpleHTTP :: WaiSession () a -> WaiSession () a
withInitializedSimpleHTTP f = do
    let init_request = toJSON createInitializeRequest
    mcpPostRequestOk headers init_request
    let notify_request = toJSON createInitializedNotification
    mcpPostRequestOk headers notify_request
    f

-- | Parse and validate a SimpleHTTP JSON-RPC response
withValidSimpleHTTPResponse ::
    (FromJSON a) =>
    SResponse ->
    Int ->
    (a -> Expectation) ->
    WaiSession () ()
withValidSimpleHTTPResponse resp expected_id props = do
    liftIO $
        case parseJSONRPCResponse resp of
            Right (JSONRPCResponse rpc_vrs req_id json_result) -> do
                case Aeson.fromJSON json_result of
                    Aeson.Error err -> expectationFailure $ "Failed to parse result: " <> err
                    Aeson.Success val -> do
                        rpc_vrs `shouldBe` rPC_VERSION
                        req_id `shouldBe` toRequestId expected_id
                        props val
            Left err_msg -> expectationFailure err_msg

-- | Parse and validate a SimpleHTTP JSON-RPC error response
withValidSimpleHTTPErrorResponse ::
    SResponse ->
    Int ->
    (JSONRPCErrorInfo -> Expectation) ->
    WaiSession () ()
withValidSimpleHTTPErrorResponse resp expected_id props = do
    liftIO $
        case parseJSONRPCErrorResponse resp of
            Right (JSONRPCError rpc_vrs req_id err_info) -> do
                rpc_vrs `shouldBe` rPC_VERSION
                req_id `shouldBe` toRequestId expected_id
                props err_info
            Left err_msg -> expectationFailure err_msg

-- | Validates initialization response
validateInitializationResponse :: InitializeResult -> Expectation
validateInitializationResponse InitializeResult{protocolVersion = init_protocol_version} = do
    init_protocol_version `shouldBe` pROTOCOL_VERSION

-- ** SSE parsing (same as Integration.hs)

-- | Parse JSON-RPC response from SSE response body
parseJSONRPCResponse :: SResponse -> Either String JSONRPCResponse
parseJSONRPCResponse resp =
    case extractSSEData (simpleBody resp) of
        Just bs_response ->
            case Aeson.decodeStrict bs_response of
                Just json_resp -> Right json_resp
                Nothing -> Left $ "Failed to decode JSON-RPC response from: " <> show bs_response
        Nothing -> Left $ "Failed to extract JSON data from SSE response: " <> show (simpleBody resp)

-- | Parse JSON-RPC error response from SSE response body
parseJSONRPCErrorResponse :: SResponse -> Either String JSONRPCError
parseJSONRPCErrorResponse resp =
    case extractSSEData (simpleBody resp) of
        Just bs_response ->
            case Aeson.decodeStrict bs_response of
                Just json_err -> Right json_err
                Nothing -> Left $ "Failed to decode JSON-RPC error from: " <> show bs_response
        Nothing -> Left $ "Failed to extract JSON data from SSE response: " <> show (simpleBody resp)

-- | Extract JSON data from SSE format
extractSSEData :: LBS.ByteString -> Maybe BS.ByteString
extractSSEData sseBody = snd <$> P.maybeResult (P.parse parseEvent sseBody)
  where
    parseEvent = do
        _ <- P.string "event:"
        msg_type <- P.takeWhileIncluding (not . C.isEndOfLine)
        _ <- C.string "data:"
        json_data <- P.takeWhile (not . C.isEndOfLine)
        return (msg_type, json_data)