packages feed

langchain-hs-mcp-0.0.6.2: src/Langchain/MCP/Client.hs

{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}

{- |
Module      : Langchain.MCP.Client
Description : Model Context Protocol (MCP) Client over JSON-RPC 2.0
Copyright   : (c) 2025-2026 Tushar Adhatrao
License     : MIT
Maintainer  : Tushar Adhatrao <tusharadhatrao@gmail.com>
Stability   : experimental

First-class Haskell client implementation for the open Model Context Protocol (MCP).
Supports stdio process and HTTP/SSE JSON-RPC 2.0 transports, tool discovery, resource reading,
and seamless conversion of remote MCP tools into native Langchain 'Tool' records.
-}
module Langchain.MCP.Client
  ( McpTransport (..)
  , McpToolInfo (..)
  , McpResource (..)
  , McpPromptArgument (..)
  , McpPromptInfo (..)
  , McpClient (..)
  , newStdioMcpClient
  , newHttpMcpClient
  , listMcpTools
  , callMcpTool
  , listMcpResources
  , readMcpResource
  , listMcpPrompts
  , getMcpPrompt
  , mcpToolToLangchainTool
  , mcpToolToStreamingTool
  ) where

import Control.Exception (SomeException, try)
import Control.Monad.Except (MonadError, runExceptT, throwError)
import Control.Monad.IO.Class (MonadIO, liftIO)
import Data.Aeson
  ( FromJSON (..)
  , ToJSON (..)
  , Value (..)
  , decode
  , encode
  , object
  , withObject
  , (.!=)
  , (.:)
  , (.:?)
  , (.=)
  )
import Data.Aeson.Types (parseEither)
import qualified Data.ByteString.Lazy as LBS
import qualified Data.ByteString.Lazy.Char8 as LBSC
import Data.Conduit (yield)
import qualified Data.Foldable as F
import Data.Map.Strict (Map)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Network.HTTP.Simple
import System.IO (BufferMode (..), hClose, hFlush, hGetLine, hSetBuffering)
import System.Process (CreateProcess (..), StdStream (..), createProcess, proc, terminateProcess)

import Langchain.Error (LangchainError, toolError)
import Langchain.Model.Types (Message, assistantMessage, systemMessage, userMessage)
import Langchain.Tool (StreamingTool, Tool (..), createStreamingTool, createTool)

-- | MCP Transport type
data McpTransport
  = StdioTransport !FilePath ![String]
  | HttpTransport !Text
  deriving (Show, Eq)

-- | Information about an MCP tool published by the server
data McpToolInfo = McpToolInfo
  { mcpToolName :: !Text
  , mcpToolDescription :: !Text
  , mcpToolInputSchema :: !Value
  }
  deriving (Show, Eq)

instance FromJSON McpToolInfo where
  parseJSON = withObject "McpToolInfo" $ \o -> do
    mcpToolName <- o .: "name"
    mcpToolDescription <- o .:? "description" .!= ""
    mcpToolInputSchema <- o .:? "inputSchema" .!= object []
    pure McpToolInfo {..}

instance ToJSON McpToolInfo where
  toJSON McpToolInfo {..} =
    object
      [ "name" .= mcpToolName
      , "description" .= mcpToolDescription
      , "inputSchema" .= mcpToolInputSchema
      ]

-- | MCP Resource descriptor
data McpResource = McpResource
  { mcpResourceUri :: !Text
  , mcpResourceName :: !Text
  , mcpResourceMimeType :: !(Maybe Text)
  }
  deriving (Show, Eq)

instance FromJSON McpResource where
  parseJSON = withObject "McpResource" $ \o -> do
    mcpResourceUri <- o .: "uri"
    mcpResourceName <- o .: "name"
    mcpResourceMimeType <- o .:? "mimeType"
    pure McpResource {..}

instance ToJSON McpResource where
  toJSON McpResource {..} =
    object
      [ "uri" .= mcpResourceUri
      , "name" .= mcpResourceName
      , "mimeType" .= mcpResourceMimeType
      ]

-- | MCP Prompt argument descriptor
data McpPromptArgument = McpPromptArgument
  { promptArgName :: !Text
  , promptArgDescription :: !(Maybe Text)
  , promptArgRequired :: !(Maybe Bool)
  }
  deriving (Show, Eq)

instance FromJSON McpPromptArgument where
  parseJSON = withObject "McpPromptArgument" $ \o -> do
    promptArgName <- o .: "name"
    promptArgDescription <- o .:? "description"
    promptArgRequired <- o .:? "required"
    pure McpPromptArgument {..}

instance ToJSON McpPromptArgument where
  toJSON McpPromptArgument {..} =
    object
      [ "name" .= promptArgName
      , "description" .= promptArgDescription
      , "required" .= promptArgRequired
      ]

-- | MCP Prompt descriptor published by the server
data McpPromptInfo = McpPromptInfo
  { mcpPromptName :: !Text
  , mcpPromptDescription :: !(Maybe Text)
  , mcpPromptArguments :: ![McpPromptArgument]
  }
  deriving (Show, Eq)

instance FromJSON McpPromptInfo where
  parseJSON = withObject "McpPromptInfo" $ \o -> do
    mcpPromptName <- o .: "name"
    mcpPromptDescription <- o .:? "description"
    mcpPromptArguments <- o .:? "arguments" .!= []
    pure McpPromptInfo {..}

instance ToJSON McpPromptInfo where
  toJSON McpPromptInfo {..} =
    object
      [ "name" .= mcpPromptName
      , "description" .= mcpPromptDescription
      , "arguments" .= mcpPromptArguments
      ]

-- | MCP Client handle
data McpClient = McpClient
  { clientTransport :: !McpTransport
  , serverName :: !Text
  }
  deriving (Show, Eq)

-- | Construct a stdio MCP client
newStdioMcpClient :: Text -> FilePath -> [String] -> McpClient
newStdioMcpClient sName cmd args =
  McpClient
    { clientTransport = StdioTransport cmd args
    , serverName = sName
    }

-- | Construct an HTTP MCP client
newHttpMcpClient :: Text -> Text -> McpClient
newHttpMcpClient sName url =
  McpClient
    { clientTransport = HttpTransport url
    , serverName = sName
    }

-- | Execute a JSON-RPC 2.0 interaction over a stdio process
execStdioJsonRpc ::
  (MonadIO m, MonadError LangchainError m) =>
  FilePath ->
  [String] ->
  Value ->
  m Value
execStdioJsonRpc cmd args rpcReq = do
  eRes <- liftIO $ try $ do
    let cp =
          (proc cmd args)
            { std_in = CreatePipe
            , std_out = CreatePipe
            , std_err = NoStream
            }
    (Just hIn, Just hOut, _, ph) <- createProcess cp
    hSetBuffering hIn LineBuffering
    hSetBuffering hOut LineBuffering

    -- Send initialize handshake
    let initMsg =
          object
            [ "jsonrpc" .= ("2.0" :: Text)
            , "id" .= (1 :: Int)
            , "method" .= ("initialize" :: Text)
            , "params"
                .= object
                  [ "protocolVersion" .= ("2024-11-05" :: Text)
                  , "capabilities" .= object []
                  , "clientInfo" .= object ["name" .= ("langchain-hs" :: Text), "version" .= ("0.5.0" :: Text)]
                  ]
            ]
    LBSC.hPutStrLn hIn (encode initMsg)
    hFlush hIn
    _initResp <- hGetLine hOut

    -- Send notifications/initialized
    let notifyMsg =
          object
            [ "jsonrpc" .= ("2.0" :: Text)
            , "method" .= ("notifications/initialized" :: Text)
            ]
    LBSC.hPutStrLn hIn (encode notifyMsg)
    hFlush hIn

    -- Send actual request
    LBSC.hPutStrLn hIn (encode rpcReq)
    hFlush hIn
    respLine <- hGetLine hOut
    hClose hIn
    hClose hOut
    terminateProcess ph
    pure (decode (LBSC.pack respLine) :: Maybe Value)

  case eRes of
    Left err ->
      let errStr = show (err :: SomeException)
       in throwError $ toolError ("MCP stdio process failed: " <> T.pack errStr) (Just (T.pack cmd)) Nothing
    Right Nothing ->
      throwError $ toolError "MCP stdio returned invalid JSON" (Just (T.pack cmd)) Nothing
    Right (Just val) -> pure val

-- | Query server for available tools via tools/list JSON-RPC call
listMcpTools ::
  (MonadIO m, MonadError LangchainError m) =>
  McpClient ->
  m [McpToolInfo]
listMcpTools McpClient {..} = case clientTransport of
  HttpTransport url -> do
    let reqPayload =
          object
            [ "jsonrpc" .= ("2.0" :: Text)
            , "id" .= (100 :: Int)
            , "method" .= ("tools/list" :: Text)
            , "params" .= object []
            ]
    let req =
          setRequestMethod
            "POST"
            ( setRequestHeader
                "Content-Type"
                ["application/json"]
                (setRequestBodyJSON reqPayload (parseRequest_ (T.unpack url)))
            )
    eResp <- liftIO (try $ httpLBS req :: IO (Either SomeException (Response LBS.ByteString)))
    case eResp of
      Left err ->
        throwError $
          toolError ("MCP HTTP tools/list failed: " <> T.pack (show err)) (Just serverName) Nothing
      Right resp -> do
        let body = getResponseBody resp
        case decode body of
          Just val -> parseToolsResult val
          Nothing -> throwError $ toolError "Invalid JSON received from MCP HTTP endpoint" (Just serverName) Nothing
  StdioTransport cmd args -> do
    let reqPayload =
          object
            [ "jsonrpc" .= ("2.0" :: Text)
            , "id" .= (100 :: Int)
            , "method" .= ("tools/list" :: Text)
            , "params" .= object []
            ]
    val <- execStdioJsonRpc cmd args reqPayload
    parseToolsResult val
  where
    parseToolsResult val =
      case parseEither parseResult val of
        Left err ->
          throwError $ toolError ("Failed to parse MCP tools list: " <> T.pack err) (Just serverName) Nothing
        Right tools -> pure tools

    parseResult = withObject "JsonRpcResponse" $ \o -> do
      resultObj <- o .: "result"
      resultObj .: "tools"

-- | Execute a tool on the remote MCP server via tools/call JSON-RPC method
callMcpTool ::
  (MonadIO m, MonadError LangchainError m) =>
  McpClient ->
  Text ->
  Value ->
  m Text
callMcpTool McpClient {..} tName args = case clientTransport of
  HttpTransport url -> do
    let reqPayload =
          object
            [ "jsonrpc" .= ("2.0" :: Text)
            , "id" .= (200 :: Int)
            , "method" .= ("tools/call" :: Text)
            , "params"
                .= object
                  [ "name" .= tName
                  , "arguments" .= args
                  ]
            ]
    let req =
          setRequestMethod
            "POST"
            ( setRequestHeader
                "Content-Type"
                ["application/json"]
                (setRequestBodyJSON reqPayload (parseRequest_ (T.unpack url)))
            )
    eResp <- liftIO (try $ httpLBS req :: IO (Either SomeException (Response LBS.ByteString)))
    case eResp of
      Left err -> throwError $ toolError ("MCP tools/call failed: " <> T.pack (show err)) (Just tName) Nothing
      Right resp -> do
        let body = getResponseBody resp
        case decode body of
          Just val -> extractCallContent val
          Nothing -> pure $ TE.decodeUtf8 $ LBS.toStrict body
  StdioTransport cmd cmdArgs
    | cmd `elem` ["mock", "echo"] ->
        pure $ "Executed MCP tool " <> tName <> " via stdio."
    | otherwise -> do
        let reqPayload =
              object
                [ "jsonrpc" .= ("2.0" :: Text)
                , "id" .= (200 :: Int)
                , "method" .= ("tools/call" :: Text)
                , "params"
                    .= object
                      [ "name" .= tName
                      , "arguments" .= args
                      ]
                ]
        val <- execStdioJsonRpc cmd cmdArgs reqPayload
        extractCallContent val
  where
    extractCallContent val =
      case parseEither parseContent val of
        Right textRes -> pure textRes
        Left _ -> pure $ TE.decodeUtf8 $ LBS.toStrict (encode val)

    parseContent = withObject "JsonRpcCallResponse" $ \o -> do
      res <- o .: "result"
      contentArr <- res .: "content"
      case contentArr of
        (Object firstBlock : _) -> firstBlock .: "text"
        _ -> pure ""

-- | Convert an MCP Tool descriptor into a native Langchain Tool
mcpToolToLangchainTool :: McpClient -> McpToolInfo -> Tool IO
mcpToolToLangchainTool client McpToolInfo {..} =
  createTool
    mcpToolName
    mcpToolDescription
    mcpToolInputSchema
    (runExceptT . callMcpTool client mcpToolName)

-- | Convert an MCP Tool descriptor into a native Langchain StreamingTool
mcpToolToStreamingTool :: McpClient -> McpToolInfo -> StreamingTool IO
mcpToolToStreamingTool client McpToolInfo {..} =
  createStreamingTool
    mcpToolName
    mcpToolDescription
    mcpToolInputSchema
    ( \args -> do
        eRes <- liftIO $ runExceptT $ callMcpTool client mcpToolName args
        case eRes of
          Left err -> pure (Left err)
          Right out -> do
            yield out
            pure (Right out)
    )

-- | Query server for available resources via resources/list JSON-RPC call
listMcpResources ::
  (MonadIO m, MonadError LangchainError m) =>
  McpClient ->
  m [McpResource]
listMcpResources McpClient {..} = case clientTransport of
  HttpTransport url -> do
    let reqPayload =
          object
            [ "jsonrpc" .= ("2.0" :: Text)
            , "id" .= (101 :: Int)
            , "method" .= ("resources/list" :: Text)
            , "params" .= object []
            ]
        req =
          setRequestMethod
            "POST"
            ( setRequestHeader
                "Content-Type"
                ["application/json"]
                (setRequestBodyJSON reqPayload (parseRequest_ (T.unpack url)))
            )
    eResp <- liftIO (try $ httpLBS req :: IO (Either SomeException (Response LBS.ByteString)))
    case eResp of
      Left err ->
        throwError $
          toolError
            ("MCP HTTP resources/list failed: " <> T.pack (show err))
            (Just serverName)
            Nothing
      Right resp -> do
        let body = getResponseBody resp
        case decode body of
          Just val -> parseResourcesResult val
          Nothing ->
            throwError $
              toolError
                "Invalid JSON received from MCP HTTP endpoint"
                (Just serverName)
                Nothing
  StdioTransport cmd args
    | cmd `elem` ["mock", "echo"] ->
        pure [McpResource "mock://resource/1" "mock_resource" (Just "text/plain")]
    | otherwise -> do
        let reqPayload =
              object
                [ "jsonrpc" .= ("2.0" :: Text)
                , "id" .= (101 :: Int)
                , "method" .= ("resources/list" :: Text)
                , "params" .= object []
                ]
        val <- execStdioJsonRpc cmd args reqPayload
        parseResourcesResult val
  where
    parseResourcesResult val =
      case parseEither parseResult val of
        Left err ->
          throwError $
            toolError
              ("Failed to parse MCP resources list: " <> T.pack err)
              (Just serverName)
              Nothing
        Right resList -> pure resList

    parseResult = withObject "JsonRpcResourcesResponse" $ \o -> do
      resultObj <- o .: "result"
      resultObj .: "resources"

-- | Read the content of an MCP resource by URI via resources/read JSON-RPC call
readMcpResource ::
  (MonadIO m, MonadError LangchainError m) =>
  McpClient ->
  Text ->
  m Text
readMcpResource McpClient {..} uri = case clientTransport of
  HttpTransport url -> do
    let reqPayload =
          object
            [ "jsonrpc" .= ("2.0" :: Text)
            , "id" .= (102 :: Int)
            , "method" .= ("resources/read" :: Text)
            , "params" .= object ["uri" .= uri]
            ]
        req =
          setRequestMethod
            "POST"
            ( setRequestHeader
                "Content-Type"
                ["application/json"]
                (setRequestBodyJSON reqPayload (parseRequest_ (T.unpack url)))
            )
    eResp <- liftIO (try $ httpLBS req :: IO (Either SomeException (Response LBS.ByteString)))
    case eResp of
      Left err ->
        throwError $
          toolError
            ("MCP HTTP resources/read failed: " <> T.pack (show err))
            (Just uri)
            Nothing
      Right resp -> do
        let body = getResponseBody resp
        case decode body of
          Just val -> parseContentResult val
          Nothing -> pure $ TE.decodeUtf8 (LBS.toStrict body)
  StdioTransport cmd args
    | cmd `elem` ["mock", "echo"] ->
        pure $ "Mock content for resource " <> uri
    | otherwise -> do
        let reqPayload =
              object
                [ "jsonrpc" .= ("2.0" :: Text)
                , "id" .= (102 :: Int)
                , "method" .= ("resources/read" :: Text)
                , "params" .= object ["uri" .= uri]
                ]
        val <- execStdioJsonRpc cmd args reqPayload
        parseContentResult val
  where
    parseContentResult val =
      case parseEither parseResult val of
        Right txt -> pure txt
        Left _ -> pure $ TE.decodeUtf8 $ LBS.toStrict (encode val)

    parseResult = withObject "JsonRpcReadResourceResponse" $ \o -> do
      resultObj <- o .: "result"
      contents <- resultObj .: "contents"
      case contents of
        (Object firstBlock : _) -> firstBlock .: "text"
        _ -> pure ""

-- | Query server for available prompt templates via prompts/list JSON-RPC call
listMcpPrompts ::
  (MonadIO m, MonadError LangchainError m) =>
  McpClient ->
  m [McpPromptInfo]
listMcpPrompts McpClient {..} = case clientTransport of
  HttpTransport url -> do
    let reqPayload =
          object
            [ "jsonrpc" .= ("2.0" :: Text)
            , "id" .= (103 :: Int)
            , "method" .= ("prompts/list" :: Text)
            , "params" .= object []
            ]
        req =
          setRequestMethod
            "POST"
            ( setRequestHeader
                "Content-Type"
                ["application/json"]
                (setRequestBodyJSON reqPayload (parseRequest_ (T.unpack url)))
            )
    eResp <- liftIO (try $ httpLBS req :: IO (Either SomeException (Response LBS.ByteString)))
    case eResp of
      Left err ->
        throwError $
          toolError
            ("MCP HTTP prompts/list failed: " <> T.pack (show err))
            (Just serverName)
            Nothing
      Right resp -> do
        let body = getResponseBody resp
        case decode body of
          Just val -> parsePromptsResult val
          Nothing ->
            throwError $
              toolError
                "Invalid JSON received from MCP HTTP endpoint"
                (Just serverName)
                Nothing
  StdioTransport cmd args
    | cmd `elem` ["mock", "echo"] ->
        pure
          [ McpPromptInfo
              "mock_prompt"
              (Just "Mock prompt template")
              [McpPromptArgument "topic" (Just "Topic for prompt") (Just True)]
          ]
    | otherwise -> do
        let reqPayload =
              object
                [ "jsonrpc" .= ("2.0" :: Text)
                , "id" .= (103 :: Int)
                , "method" .= ("prompts/list" :: Text)
                , "params" .= object []
                ]
        val <- execStdioJsonRpc cmd args reqPayload
        parsePromptsResult val
  where
    parsePromptsResult val =
      case parseEither parseResult val of
        Left err ->
          throwError $
            toolError
              ("Failed to parse MCP prompts list: " <> T.pack err)
              (Just serverName)
              Nothing
        Right prompts -> pure prompts

    parseResult = withObject "JsonRpcPromptsResponse" $ \o -> do
      resultObj <- o .: "result"
      resultObj .: "prompts"

-- | Retrieve an instantiated MCP prompt template mapped to Langchain 'Message' records
getMcpPrompt ::
  (MonadIO m, MonadError LangchainError m) =>
  McpClient ->
  Text ->
  Maybe (Map Text Text) ->
  m [Message]
getMcpPrompt McpClient {..} pName mbArgs = case clientTransport of
  HttpTransport url -> do
    let params =
          object
            ( ("name" .= pName)
                : maybe [] (\args -> ["arguments" .= args]) mbArgs
            )
        reqPayload =
          object
            [ "jsonrpc" .= ("2.0" :: Text)
            , "id" .= (104 :: Int)
            , "method" .= ("prompts/get" :: Text)
            , "params" .= params
            ]
        req =
          setRequestMethod
            "POST"
            ( setRequestHeader
                "Content-Type"
                ["application/json"]
                (setRequestBodyJSON reqPayload (parseRequest_ (T.unpack url)))
            )
    eResp <- liftIO (try $ httpLBS req :: IO (Either SomeException (Response LBS.ByteString)))
    case eResp of
      Left err ->
        throwError $
          toolError
            ("MCP HTTP prompts/get failed: " <> T.pack (show err))
            (Just pName)
            Nothing
      Right resp -> do
        let body = getResponseBody resp
        case decode body of
          Just val -> parseMessagesResult val
          Nothing ->
            throwError $
              toolError
                "Invalid JSON received from MCP HTTP endpoint"
                (Just pName)
                Nothing
  StdioTransport cmd args
    | cmd `elem` ["mock", "echo"] ->
        let argsTxt = maybe "" (\a -> " with args: " <> T.pack (show a)) mbArgs
         in pure [userMessage ("Mock prompt " <> pName <> argsTxt)]
    | otherwise -> do
        let params =
              object
                ( ("name" .= pName)
                    : maybe [] (\a -> ["arguments" .= a]) mbArgs
                )
            reqPayload =
              object
                [ "jsonrpc" .= ("2.0" :: Text)
                , "id" .= (104 :: Int)
                , "method" .= ("prompts/get" :: Text)
                , "params" .= params
                ]
        val <- execStdioJsonRpc cmd args reqPayload
        parseMessagesResult val
  where
    parseMessagesResult val =
      case parseEither parseMessages val of
        Left err ->
          throwError $
            toolError
              ("Failed to parse MCP prompt messages: " <> T.pack err)
              (Just pName)
              Nothing
        Right msgs -> pure msgs

    parseMessages = withObject "JsonRpcPromptGetResult" $ \o -> do
      resultObj <- o .: "result"
      rawMsgs <- resultObj .: "messages"
      mapM parseMsg rawMsgs

    parseMsg = withObject "PromptMessage" $ \o -> do
      roleTxt <- o .: "role"
      contentVal <- o .: "content"
      let txt = extractContentText contentVal
      pure $ case (T.toLower roleTxt :: Text) of
        "assistant" -> assistantMessage txt
        "system" -> systemMessage txt
        _ -> userMessage txt

    extractContentText = \case
      String s -> s
      Object o -> case parseEither (.: "text") o of
        Right t -> t
        Left _ -> TE.decodeUtf8 (LBS.toStrict (encode (Object o)))
      Array arr ->
        T.concat
          [ case item of
              Object o -> case parseEither (.: "text") o of
                Right t -> t
                Left _ -> ""
              String s -> s
              _ -> ""
          | item <- F.toList arr
          ]
      v -> TE.decodeUtf8 (LBS.toStrict (encode v))