mcp-0.3.0.1: src/MCP/Server/Stdio.hs
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE TypeApplications #-}
{- |
Module: MCP.Server.Stdio
License: MPL-2.0
Maintainer: <matti@dpella.io>, <lobo@dpella.io>
Stdio transport for the MCP server.
Reads JSON-RPC messages line-by-line from an input handle and writes
responses to an output handle. This transport does not use JWT
authentication; it is assumed that the process boundary provides
authentication.
-}
module MCP.Server.Stdio (
serveStdio,
) where
import Control.Monad.Except
import Control.Monad.State.Lazy
import Data.Aeson (encode)
import Data.Aeson qualified as Aeson
import Data.ByteString.Char8 qualified as BS
import Data.ByteString.Lazy.Char8 qualified as BSL
import Data.IORef
import Data.Text qualified as T
import MCP.Server.Common
import System.IO (BufferMode (..), Handle, hFlush, hIsEOF, hSetBuffering)
{- | Run the MCP server over stdio transport.
Reads JSON-RPC messages line-by-line from the input handle,
processes them through the standard MCP message handler, and writes
JSON-RPC responses line-by-line to the output handle.
Stdio transport does not use JWT authentication; it is assumed that
the process boundary provides authentication.
The server runs until EOF is reached on the input handle.
__Note:__ When a handler returns 'ProcessClientInput', the server writes a
request to the client and blocks on the input handle until a response
arrives. There is no timeout — if the client never responds, the server
will block indefinitely.
-}
serveStdio ::
-- | Input handle (typically stdin)
Handle ->
-- | Output handle (typically stdout)
Handle ->
-- | Initial server state
MCPServerState ->
IO ()
serveStdio h_in h_out initial_state = do
hSetBuffering h_in LineBuffering
hSetBuffering h_out LineBuffering
state_ref <- newIORef initial_state
loop state_ref
where
loop :: IORef MCPServerState -> IO ()
loop state_ref = do
eof <- hIsEOF h_in
if eof
then return ()
else do
line <- BS.hGetLine h_in
case Aeson.eitherDecodeStrict' line of
Left err -> do
-- JSON parse error — write error response with null id
writeMsg $
ErrorMessage $
JSONRPCError rPC_VERSION (RequestId Aeson.Null) $
JSONRPCErrorInfo pARSE_ERROR (T.pack err) Nothing
loop state_ref
Right msg -> do
processStdioMessage state_ref msg
loop state_ref
processStdioMessage :: IORef MCPServerState -> JSONRPCMessage -> IO ()
processStdioMessage state_ref = \case
NotificationMessage _ ->
-- Notifications don't produce a response
return ()
ErrorMessage _ ->
-- Client sent an error — nothing to respond to
return ()
ResponseMessage _ ->
-- Unexpected response from client outside of ProcessClientInput
return ()
RequestMessage (JSONRPCRequest jsonrpc req_id method params) -> do
-- Validate JSON-RPC version
if jsonrpc /= rPC_VERSION
then
writeMsg $
ErrorMessage $
JSONRPCError rPC_VERSION req_id $
JSONRPCErrorInfo iNVALID_REQUEST "Invalid jsonrpc version" Nothing
else do
-- Validate request ID
if not (isValidRequestId req_id)
then
writeMsg $
ErrorMessage $
JSONRPCError rPC_VERSION req_id $
JSONRPCErrorInfo iNVALID_REQUEST "Invalid request ID" Nothing
else do
cur_st <- readIORef state_ref
let initialized = mcp_server_initialized cur_st
-- Process the request
(res, new_st) <- runStateT (processMethod initialized method params) cur_st
writeIORef state_ref new_st
-- Handle ProcessClientInput by synchronous read/write
final_res <- resolveClientInput state_ref res
-- Convert result to response message
case final_res of
ProcessServerError err ->
writeMsg $
ErrorMessage $
JSONRPCError rPC_VERSION req_id $
JSONRPCErrorInfo iNTERNAL_ERROR err Nothing
ProcessRPCError rpc_code rpc_msg ->
writeMsg $
ErrorMessage $
JSONRPCError rPC_VERSION req_id $
JSONRPCErrorInfo rpc_code rpc_msg Nothing
ProcessSuccess response ->
writeMsg $
ResponseMessage $
JSONRPCResponse rPC_VERSION req_id (recurReplaceMeta response)
ProcessClientInput{} ->
-- Should not happen after resolveClientInput
writeMsg $
ErrorMessage $
JSONRPCError rPC_VERSION req_id $
JSONRPCErrorInfo iNTERNAL_ERROR "Unresolved client input" Nothing
-- Finalize handler state
st <- readIORef state_ref
case mcp_handler_finalize st of
Nothing -> return ()
Just finalizer -> do
h_st' <- finalizer (mcp_handler_state st)
writeIORef state_ref st{mcp_handler_state = h_st'}
-- \| Resolve ProcessClientInput by writing a request to the client and
-- reading the response synchronously from stdin.
resolveClientInput :: IORef MCPServerState -> ProcessResult Aeson.Value -> IO (ProcessResult Aeson.Value)
resolveClientInput state_ref = \case
ProcessClientInput ci_mthd ci_params ci_cont -> do
-- Assign a request ID
st <- readIORef state_ref
let r_id = mcp_pending_responses_next st
writeIORef state_ref st{mcp_pending_responses_next = r_id + 1}
-- Write the server-to-client request
writeMsg $
RequestMessage $
JSONRPCRequest rPC_VERSION (RequestId $ Aeson.Number $ fromIntegral r_id) ci_mthd ci_params
-- Read the client's response synchronously
resp_line <- BS.hGetLine h_in
case Aeson.eitherDecodeStrict' @JSONRPCMessage resp_line of
Right (ResponseMessage (JSONRPCResponse _ _ result)) -> do
-- Run the continuation with the client's response
cur_st <- readIORef state_ref
(cont_result, new_st) <- runStateT (runExceptT $ ci_cont result) cur_st
writeIORef state_ref new_st
case cont_result of
Left err -> return $ ProcessServerError err
Right next_res -> resolveClientInput state_ref next_res
Right (ErrorMessage (JSONRPCError _ _ (JSONRPCErrorInfo _ err_msg _))) ->
return $ ProcessServerError err_msg
_ ->
return $ ProcessServerError "Expected response to client input request"
other -> return other
writeMsg :: JSONRPCMessage -> IO ()
writeMsg msg = do
BSL.hPut h_out (encode msg)
BSL.hPut h_out "\n"
hFlush h_out