mcp-0.3.0.1: src/MCP/Server/HTTP.hs
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE TypeOperators #-}
{- |
Module: MCP.Server.HTTP
License: MPL-2.0
Maintainer: <matti@dpella.io>, <lobo@dpella.io>
Servant-based HTTP transport for the MCP server.
This module provides a JWT-authenticated Servant API that accepts
JSON-RPC requests via POST and returns responses as SSE streams.
-}
module MCP.Server.HTTP (
-- * Servant API
MCPAPI,
mcpAPI,
handleMCPRequest,
handleMCPEvents,
) where
import Control.Concurrent.MVar
import Control.Monad (when)
import Control.Monad.Except
import Control.Monad.State.Lazy
import Data.Aeson (encode, object, toJSON, (.=))
import Data.Aeson qualified as Aeson
import Data.ByteString.Lazy.Char8 qualified as BSL
import Data.IntMap qualified as IM
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding (encodeUtf8)
import Data.Tuple (swap)
import MCP.Server.Common
import Network.HTTP.Media ((//))
import Servant
import Servant.Auth.Server (Auth, AuthResult (..), JWT)
import Servant.Auth.Server qualified as AuthServer
import Servant.Types.SourceT
import Servant.Types.SourceT qualified as Source
-- | Frame for JSON-RPC messages.
data JSONRPCFrame
instance FramingRender JSONRPCFrame where
framingRender _ f = fmap (\x -> f x <> "\n")
-- | Event for JSON-RPC messages.
data JSONRPCEvent
instance Accept JSONRPCEvent where
contentType _ = "text" // "event-stream"
instance MimeRender JSONRPCEvent JSONRPCMessage where
mimeRender _ val =
("event: message\n" <>) $
("data:" <> encode (toJSON val) <> "\n")
{- | Servant API type for the MCP endpoint.
Accepts JSON-RPC requests with JWT authentication via servant-auth.
All MCP methods are multiplexed through this single endpoint.
-}
type MCPAPI =
"mcp"
:> Auth '[JWT] MCPHandlerUser
-- \^ JWT authentication using servant-auth
:> ReqBody '[JSON] JSONRPCMessage
-- \^ JSON-RPC request
:> StreamPost JSONRPCFrame JSONRPCEvent (SourceIO JSONRPCMessage)
-- \^ Stream of JSON-RPC responses
:<|> "mcp"
:> Auth '[JWT] MCPHandlerUser
:> StreamGet JSONRPCFrame JSONRPCEvent (SourceIO JSONRPCMessage)
-- | Type of the MCP API
mcpAPI :: MVar MCPServerState -> Server MCPAPI
mcpAPI state_var =
handleMCPRequest state_var
:<|> handleMCPEvents
-- | This handles the Get /mcp requests. Does nothing for now except authenticate.
handleMCPEvents ::
AuthResult MCPHandlerUser ->
Handler (SourceIO JSONRPCMessage)
handleMCPEvents auth_result = do
case auth_result of
AuthServer.NoSuchUser -> throwError err401{errBody = "Invalid authentication credentials"}
AuthServer.BadPassword -> throwError err401{errBody = "Authentication failed"}
AuthServer.Indefinite -> throwError err401{errBody = "Authentication error"}
AuthServer.Authenticated _ -> return $ fromStepT $ Stop
{- | Handle incoming MCP JSON-RPC requests.
This is the main entry point for MCP requests. It:
1. Validates authentication via servant-auth (automatically handled)
2. Parses the JSON-RPC request
3. Routes to the appropriate handler based on method name
4. Manages server state updates
5. Returns properly formatted JSON-RPC responses
The server enforces initialization before accepting most methods.
Only "ping" and "initialize" can be called before initialization.
-}
handleMCPRequest ::
MVar MCPServerState ->
AuthResult MCPHandlerUser ->
JSONRPCMessage ->
Handler (SourceIO JSONRPCMessage)
handleMCPRequest state_var auth_result request_value =
fromStepT <$> do
case auth_result of
AuthServer.NoSuchUser -> mcpAuthError "Invalid authentication credentials"
AuthServer.BadPassword -> mcpAuthError "Authentication failed"
AuthServer.Indefinite -> mcpAuthError "Authentication error"
AuthServer.Authenticated auth_user -> do
MCPServerState
{ mcp_server_initialized = server_initialized
, mcp_log_level = log_level
} <-
liftIO $ readMVar state_var
case log_level of
Nothing -> return ()
Just level ->
when (level >= Debug) $
liftIO $
BSL.putStrLn $
"[request] " <> encode request_value
case request_value of
NotificationMessage _ ->
return $
flip Yield Stop $
NotificationMessage $
JSONRPCNotification rPC_VERSION "ok" Aeson.Null
ErrorMessage err -> do
throwError err400{errBody = encode err}
-- Here we fill in the MVar for any pending responses from the client
ResponseMessage (JSONRPCResponse _ (RequestId req_id) result) -> do
cur_st <- liftIO $ readMVar state_var
case Aeson.fromJSON req_id of
Aeson.Error err -> return (Source.Error $ "Invalid request ID: " <> err)
Aeson.Success req_index ->
case IM.lookup req_index (mcp_pending_responses cur_st) of
Nothing ->
return $ Source.Error "Server received unexpected response"
Just mvar -> liftIO $ do
modifyMVar_ state_var $ \cur_st' ->
return cur_st'{mcp_pending_responses = IM.delete req_index (mcp_pending_responses cur_st')}
putMVar mvar result
return Stop
RequestMessage (JSONRPCRequest jsonrpc req_id method params) -> do
-- Validate JSON-RPC version
let bs_rpc_version = BSL.fromStrict (encodeUtf8 rPC_VERSION)
when (jsonrpc /= rPC_VERSION) $
throwError err400{errBody = "Invalid jsonrpc version, must be '" <> bs_rpc_version <> "'"}
-- Validate request ID
when (not $ isValidRequestId req_id) $
throwError err400{errBody = "Invalid request ID, must be string, number, or null"}
-- Initialize handler state on first initialize request
when (not server_initialized && method == "initialize") $
liftIO $
modifyMVar_ state_var $
\cur_st@MCPServerState
{ mcp_handler_state = handler_st
, mcp_handler_init = mb_handler_init
} -> case mb_handler_init of
Nothing -> return cur_st
Just handler_init -> do
h_st' <- handler_init auth_user handler_st
return cur_st{mcp_handler_state = h_st'}
-- Process the request routing to the appropriate handler
res <- liftIO $ modifyMVar state_var $ fmap swap <$> runStateT (processMethod server_initialized method params)
-- Log the response if debug level
cur_log_level <- liftIO $ mcp_log_level <$> readMVar state_var
case cur_log_level of
Just level | level >= Debug -> liftIO $ do
let prefix = "[response] " <> BSL.fromStrict (encodeUtf8 method) <> " -> "
let body = case res of
ProcessSuccess val -> encode val
ProcessRPCError code msg -> encode $ object ["error" .= msg, "code" .= code]
ProcessServerError err -> encode $ object ["serverError" .= err]
ProcessClientInput{} -> "client-input"
BSL.putStrLn $ prefix <> body
_ -> return ()
-- Get the final result to return
final_result <- liftIO $ handleProcessResult req_id res
-- Finalize handler state after use
liftIO $
modifyMVar_ state_var $
\st@MCPServerState
{ mcp_handler_state = handler_st
, mcp_handler_finalize = mb_finalizer
} -> do
case mb_finalizer of
Nothing -> return st
Just handler_finalize -> do
-- Call the finalizer to clean up the handler state
handler_st' <- handler_finalize handler_st
-- Update the state with the finalized handler state
let st' = st{mcp_handler_state = handler_st'}
-- Return the updated state
return st'
return final_result
where
-- Handle the result of processing a request
handleProcessResult ::
(Aeson.ToJSON a) =>
RequestId ->
ProcessResult a ->
IO (StepT IO JSONRPCMessage)
handleProcessResult req_id =
\case
ProcessServerError err ->
return (Source.Error $ T.unpack err)
ProcessRPCError code msg ->
return $
flip Yield Stop $
ErrorMessage $
JSONRPCError rPC_VERSION req_id $
JSONRPCErrorInfo code msg Nothing
ProcessClientInput ci_mthd ci_params ci_cont -> do
mvar <- liftIO newEmptyMVar
r_id <- liftIO $ modifyMVar state_var $ \cur_st -> do
let pr = mcp_pending_responses cur_st
let pr_id = mcp_pending_responses_next cur_st
let pr' = IM.insert pr_id mvar pr
let pr_id' = pr_id + 1
return
( cur_st{mcp_pending_responses = pr', mcp_pending_responses_next = pr_id'}
, pr_id
)
let msg =
RequestMessage $
JSONRPCRequest rPC_VERSION (RequestId $ Aeson.Number $ fromIntegral r_id) ci_mthd ci_params
return $
Yield msg $
Effect $ do
-- Wait for the response
ci_resp <- liftIO $ takeMVar mvar
-- Make sure to use the current updated state
cur_st <- liftIO $ readMVar state_var
(result, cur_st') <-
liftIO $
flip runStateT cur_st $
runExceptT $
ci_cont ci_resp
liftIO $ modifyMVar_ state_var $ \_ -> return cur_st'
case result of
Left err -> do
return $ Source.Error $ T.unpack err
Right response ->
handleProcessResult req_id response
ProcessSuccess response -> do
return $
flip Yield Stop $
ResponseMessage $
JSONRPCResponse rPC_VERSION req_id (recurReplaceMeta $ toJSON response)
-- Helper to throw unauthorized errors
mcpAuthError :: Text -> Handler (StepT IO JSONRPCMessage)
mcpAuthError err = throwError err401{errBody = encode $ object ["error" .= err]}