packages feed

mcp-0.3.0.1: test/MCP/StdioIntegration.hs

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

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

Integration tests for the MCP stdio transport.
Tests serveStdio by creating in-process pipes and exercising
the full protocol over them.
-}
module MCP.StdioIntegration where

import Control.Concurrent (forkIO, killThread, threadDelay)
import Control.Exception (bracket)
import Data.Aeson (FromJSON, toJSON)
import Data.Aeson qualified as Aeson
import Data.ByteString.Char8 qualified as BS
import Data.ByteString.Lazy qualified as BSL
import Data.Map qualified as Map
import Data.Text (Text)
import MCP.Protocol
import MCP.Server (
    MCPServerState (..),
    serveStdio,
 )
import MCP.TestServer (
    availableResourceTemplates,
    completionValues,
    csvTestData,
    initializeTestState,
    mb_handler_finalize,
    processHandlers,
 )
import MCP.TestUtils
import MCP.Types
import System.IO (BufferMode (..), Handle, hClose, hFlush, hSetBuffering)
import System.Process (createPipe)
import Test.Hspec

-- * Stdio Integration Tests

-- | Complete stdio integration test suite
stdioIntegrationSpec :: Spec
stdioIntegrationSpec = describe "Stdio Transport" $ do
    initializationSpec
    pingSpec
    toolSpec
    resourceSpec
    resourceTemplateStdioSpec
    promptSpec
    completionStdioSpec
    loggingSpec
    errorSpec

-- ** Test Helpers

-- | Create initial server state for stdio tests (no JWT/user)
createStdioServerState :: MCPServerState
createStdioServerState =
    MCPServerState
        { mcp_server_initialized = False
        , mcp_handler_state = initializeTestState
        , mcp_handler_init = Nothing -- No user in stdio mode
        , mcp_handler_finalize = mb_handler_finalize
        , mcp_client_capabilities = Nothing
        , mcp_log_level = Nothing
        , mcp_pending_responses = mempty
        , mcp_pending_responses_next = 1
        , mcp_server_capabilities =
            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
                }
        , mcp_implementation = Implementation "test-server-stdio" "1.0.0" Nothing
        , mcp_instructions = Nothing
        , mcp_process_handlers = processHandlers
        }

{- | Bracket that creates pipes, starts the stdio server in a background
thread, and yields the client-side handles for reading/writing.
-}
withStdioServer ::
    ((Handle, Handle) -> IO a) ->
    IO a
withStdioServer f = do
    -- Pipe for server reading (client writes here)
    (client_to_server_read, client_to_server_write) <- createPipe
    -- Pipe for server writing (client reads here)
    (server_to_client_read, server_to_client_write) <- createPipe

    hSetBuffering client_to_server_write LineBuffering
    hSetBuffering server_to_client_read LineBuffering

    bracket
        ( do
            tid <- forkIO $ serveStdio client_to_server_read server_to_client_write createStdioServerState
            -- Small delay to let the server start its loop
            threadDelay 10000
            return tid
        )
        ( \tid -> do
            killThread tid
            hClose client_to_server_write
            hClose client_to_server_read
            hClose server_to_client_write
            hClose server_to_client_read
        )
        (\_ -> f (server_to_client_read, client_to_server_write))

-- | Send a JSON-RPC message to the server via the write handle
sendMsg :: (Aeson.ToJSON a) => Handle -> a -> IO ()
sendMsg h msg = do
    BSL.hPut h (Aeson.encode msg)
    BSL.hPut h "\n"
    hFlush h

-- | Read a JSON-RPC response from the server via the read handle
recvResponse :: Handle -> IO JSONRPCResponse
recvResponse h = do
    line <- BS.hGetLine h
    case Aeson.eitherDecodeStrict' line of
        Right resp -> return resp
        Left err -> fail $ "Failed to parse response: " <> err

-- | Read a JSON-RPC error from the server via the read handle
recvError :: Handle -> IO JSONRPCError
recvError h = do
    line <- BS.hGetLine h
    case Aeson.eitherDecodeStrict' line of
        Right err -> return err
        Left parse_err -> fail $ "Failed to parse error response: " <> parse_err

-- | Read a JSON-RPC response and extract a typed result
recvTypedResponse :: (FromJSON a) => Handle -> IO (RequestId, a)
recvTypedResponse h = do
    JSONRPCResponse _rpc req_id res_val <- recvResponse h
    case Aeson.fromJSON res_val of
        Aeson.Success val -> return (req_id, val)
        Aeson.Error err -> fail $ "Failed to parse typed result: " <> err

-- | Initialize the server (send initialize + initialized notification)
initializeServer :: Handle -> Handle -> IO ()
initializeServer h_read h_write = do
    -- Send initialize request
    sendMsg h_write (toJSON createInitializeRequest)
    (_, InitializeResult{protocolVersion = pv}) <- recvTypedResponse h_read
    pv `shouldBe` pROTOCOL_VERSION

    -- Send initialized notification
    sendMsg h_write (toJSON createInitializedNotification)

-- ** Test Specs

initializationSpec :: Spec
initializationSpec = describe "Initialization" $ do
    it "completes the initialization handshake" $ do
        withStdioServer $ \(h_read, h_write) -> do
            sendMsg h_write (toJSON createInitializeRequest)
            (req_id, InitializeResult{protocolVersion = pv}) <- recvTypedResponse h_read
            req_id `shouldBe` toRequestId (1 :: Int)
            pv `shouldBe` pROTOCOL_VERSION

            -- Send initialized notification (no response expected)
            sendMsg h_write (toJSON createInitializedNotification)

pingSpec :: Spec
pingSpec = describe "Ping" $ do
    it "handles ping after initialization" $ do
        withStdioServer $ \(h_read, h_write) -> do
            initializeServer h_read h_write
            sendMsg h_write (toJSON createPingRequest)
            resp <- recvResponse h_read
            let JSONRPCResponse _ _ res_val = resp
            res_val `shouldBe` Aeson.Object mempty

toolSpec :: Spec
toolSpec = describe "Tools" $ do
    it "lists available tools" $ do
        withStdioServer $ \(h_read, h_write) -> do
            initializeServer h_read h_write
            sendMsg h_write (toJSON $ createListToolsRequest 2)
            (_, ListToolsResult{tools = ts}) <- recvTypedResponse h_read
            let tool_names = fmap (\Tool{name = n} -> n) ts
            tool_names `shouldMatchList` ["addition-tool", "constant-msg-tool"]

    it "calls the addition tool" $ do
        withStdioServer $ \(h_read, h_write) -> do
            initializeServer h_read h_write
            sendMsg h_write (toJSON $ createCallToolRequest 2 "addition-tool" [("arg1", toJSON (5 :: Int)), ("arg2", toJSON (7 :: Int))])
            (_, CallToolResult{structuredContent = structured}) <- recvTypedResponse h_read
            case structured of
                Just m -> Map.lookup "result" m `shouldBe` Just (toJSON (12 :: Int))
                Nothing -> expectationFailure "Expected structured content"

    it "calls the constant message tool" $ do
        withStdioServer $ \(h_read, h_write) -> do
            initializeServer h_read h_write
            sendMsg h_write (toJSON $ createCallToolRequest 2 "constant-msg-tool" [])
            (_, CallToolResult{structuredContent = structured}) <- recvTypedResponse h_read
            case structured of
                Just m -> Map.lookup "result" m `shouldBe` Just (toJSON ("Hello, World!" :: Text))
                Nothing -> expectationFailure "Expected structured content"

    it "returns error for non-existent tool" $ do
        withStdioServer $ \(h_read, h_write) -> do
            initializeServer h_read h_write
            sendMsg h_write (toJSON $ createCallToolRequest 2 "nonexistent-tool" [])
            JSONRPCError _ _ err_info <- recvError h_read
            code err_info `shouldBe` 404

resourceSpec :: Spec
resourceSpec = describe "Resources" $ do
    it "lists available resources" $ do
        withStdioServer $ \(h_read, h_write) -> do
            initializeServer h_read h_write
            sendMsg h_write (toJSON $ createListResourcesRequest 2)
            (_, ListResourcesResult{resources = rs}) <- recvTypedResponse h_read
            let resource_uris = fmap (\Resource{uri = u} -> u) rs
            resource_uris `shouldMatchList` ["resource://example/document", "resource://example/data", "resource://example/image"]

    it "reads a text resource" $ do
        withStdioServer $ \(h_read, h_write) -> do
            initializeServer h_read h_write
            sendMsg h_write (toJSON $ createReadResourceRequest 2 "resource://example/document")
            (_, ReadResourceResult{contents = cs}) <- recvTypedResponse h_read
            case cs of
                [TextResource (TextResourceContents{text = t})] -> t `shouldBe` csvTestData
                _ -> expectationFailure "Expected single text resource"

    it "returns error for non-existent resource" $ do
        withStdioServer $ \(h_read, h_write) -> do
            initializeServer h_read h_write
            sendMsg h_write (toJSON $ createReadResourceRequest 2 "resource://nonexistent")
            JSONRPCError _ _ err_info <- recvError h_read
            code err_info `shouldBe` 404

resourceTemplateStdioSpec :: Spec
resourceTemplateStdioSpec = describe "Resource Templates" $ do
    it "lists available resource templates" $ do
        withStdioServer $ \(h_read, h_write) -> do
            initializeServer h_read h_write
            sendMsg h_write (toJSON $ createListResourceTemplatesRequest 2)
            (_, ListResourceTemplatesResult{resourceTemplates = ts}) <- recvTypedResponse h_read
            length ts `shouldBe` length availableResourceTemplates

promptSpec :: Spec
promptSpec = describe "Prompts" $ do
    it "lists available prompts" $ do
        withStdioServer $ \(h_read, h_write) -> do
            initializeServer h_read h_write
            sendMsg h_write (toJSON $ createPromptListRequest 2)
            (_, ListPromptsResult{prompts = ps}) <- recvTypedResponse h_read
            let prompt_names = fmap (\Prompt{name = n} -> n) ps
            prompt_names `shouldMatchList` ["code-review"]

    it "gets a prompt with arguments" $ do
        withStdioServer $ \(h_read, h_write) -> do
            initializeServer h_read h_write
            let args = Map.fromList [("code", "def foo():\n    return 42")]
            sendMsg h_write (toJSON $ createGetPromptRequest 2 "code-review" (Just args))
            (_, GetPromptResult{description = desc, messages = msgs}) <- recvTypedResponse h_read
            desc `shouldBe` Just "A prompt to request a code review from the LLM"
            length msgs `shouldBe` 2
            fmap (\PromptMessage{role = r} -> r) msgs `shouldBe` [User, Assistant]

    it "returns error for non-existent prompt" $ do
        withStdioServer $ \(h_read, h_write) -> do
            initializeServer h_read h_write
            sendMsg h_write (toJSON $ createGetPromptRequest 2 "nonexistent" Nothing)
            JSONRPCError _ _ err_info <- recvError h_read
            code err_info `shouldBe` 404

completionStdioSpec :: Spec
completionStdioSpec = describe "Completions" $ do
    it "returns completions for known prompt argument" $ do
        withStdioServer $ \(h_read, h_write) -> do
            initializeServer h_read h_write
            sendMsg h_write (toJSON $ createCompleteRequest 2 "code-review" "code" "")
            (_, CompleteResult{completion = CompletionResult{values = vals}}) <- recvTypedResponse h_read
            vals `shouldBe` completionValues

    it "returns filtered completions with prefix" $ do
        withStdioServer $ \(h_read, h_write) -> do
            initializeServer h_read h_write
            sendMsg h_write (toJSON $ createCompleteRequest 2 "code-review" "code" "def")
            (_, CompleteResult{completion = CompletionResult{values = vals}}) <- recvTypedResponse h_read
            vals `shouldBe` ["def foo():"]

    it "returns empty completions for unknown prompt" $ do
        withStdioServer $ \(h_read, h_write) -> do
            initializeServer h_read h_write
            sendMsg h_write (toJSON $ createCompleteRequest 2 "nonexistent" "arg" "")
            (_, CompleteResult{completion = CompletionResult{values = vals}}) <- recvTypedResponse h_read
            vals `shouldBe` []

loggingSpec :: Spec
loggingSpec = describe "Logging" $ do
    it "handles logging/setLevel" $ do
        withStdioServer $ \(h_read, h_write) -> do
            initializeServer h_read h_write
            sendMsg h_write (toJSON $ createSetLevelRequest 2 Debug)
            resp <- recvResponse h_read
            let JSONRPCResponse _ _ res_val = resp
            res_val `shouldBe` Aeson.Null

errorSpec :: Spec
errorSpec = describe "Error Handling" $ do
    it "rejects tools/list before initialization" $ do
        withStdioServer $ \(h_read, h_write) -> do
            sendMsg h_write (toJSON $ createListToolsRequest 1)
            JSONRPCError _ _ err_info <- recvError h_read
            code err_info `shouldBe` sERVER_NOT_INITIALIZED

    it "returns method_not_found for unknown methods" $ do
        withStdioServer $ \(h_read, h_write) -> do
            initializeServer h_read h_write
            sendMsg h_write (toJSON $ createJSONRPCRequest Nothing (2 :: Int) "unknown/method" (Aeson.object []))
            JSONRPCError _ _ err_info <- recvError h_read
            code err_info `shouldBe` mETHOD_NOT_FOUND

    it "returns parse error for malformed JSON" $ do
        withStdioServer $ \(h_read, h_write) -> do
            BS.hPut h_write "this is not json\n"
            hFlush h_write
            JSONRPCError _ _ err_info <- recvError h_read
            code err_info `shouldBe` pARSE_ERROR

    it "handles EOF gracefully" $ do
        withStdioServer $ \(h_read, h_write) -> do
            initializeServer h_read h_write
            sendMsg h_write (toJSON createPingRequest)
            _ <- recvResponse h_read
            -- Closing the write handle signals EOF to the server
            hClose h_write
            -- Server should exit cleanly (no exception)
            -- Wait a bit for the server to process EOF
            threadDelay 50000
            return ()