packages feed

langchain-hs-0.0.5.0: src/Langchain/MCP/Client.hs

{-# LANGUAGE FlexibleContexts #-}
{-# 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 (..)
  , McpClient (..)
  , newStdioMcpClient
  , newHttpMcpClient
  , listMcpTools
  , callMcpTool
  , mcpToolToLangchainTool
  ) 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.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.Core.Error (LangchainError, toolError)
import Langchain.Tool.Core (Tool (..), 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 {..}

-- | 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)