packages feed

mcp-server-0.2.0.0: src/MCP/Server/Transport/Http.hs

{-# LANGUAGE OverloadedStrings   #-}
{-# LANGUAGE ScopedTypeVariables #-}

module MCP.Server.Transport.Http
  ( -- * HTTP Transport
    HttpConfig(..)
  , transportRunHttp
  , defaultHttpConfig

    -- * Request validation (exposed for testing)
  , BodyPeek(..)
  , peekBody
  , peekIsModern
  , validateRequestHeaders
  , decodeSentinel
  ) where

import           Control.Concurrent.STM   (atomically, check, orElse,
                                           readTChan, readTVar, registerDelay)
import           Control.Monad            (forever, when)
import           Data.Aeson
import qualified Data.Aeson.KeyMap        as KM
import           Data.Aeson.Types         (parseEither)
import qualified Data.ByteString.Base64   as B64
import qualified Data.ByteString.Builder  as B
import qualified Data.ByteString.Lazy     as BSL
import           Data.Maybe               (isJust)
import           Data.String              (IsString (fromString))
import           Data.Text                (Text)
import qualified Data.Text                as T
import qualified Data.Text.Encoding       as TE
import           Data.Text.Encoding.Error (lenientDecode)
import           Network.HTTP.Types
import qualified Network.Wai              as Wai
import qualified Network.Wai.Handler.Warp as Warp
import           System.IO                (hPutStrLn, stderr)

import           MCP.Server.Handlers
import           MCP.Server.JsonRpc
import           MCP.Server.Notifications
import           MCP.Server.Protocol (allVersions, modernVersions,
                                      supportedVersions)
import           MCP.Server.Types

-- | HTTP transport configuration following the MCP Streamable HTTP specification
--
-- Note: 'HttpConfig' has no 'Show'/'Eq' instances because 'httpAuthorize' is a
-- function.
data HttpConfig = HttpConfig
  { httpPort      :: Int      -- ^ Port to listen on
  , httpHost      :: String   -- ^ Host to bind to (default "localhost")
  , httpEndpoint  :: String   -- ^ MCP endpoint path (default "/mcp")
  , httpVerbose   :: Bool     -- ^ Enable verbose logging (default False)
  , httpAuthorize :: Maybe (Maybe Text -> IO (Maybe Value))
      -- ^ Optional authorization callback. 'Nothing' disables authentication.
      --   When @'Just' check@, the bearer token presented by each request (or
      --   'Nothing' when absent / not a Bearer credential) is passed to
      --   @check@, which returns the caller's principal: @'Just' principal@
      --   authorizes the request — the principal (e.g. a role) is placed in the
      --   handler 'ClientContext' as 'clientPrincipal' — while 'Nothing' rejects
      --   the request with @401@. Validation and principal assignment are left
      --   entirely to the caller.
  , httpAllowedOrigins :: Maybe [Text]
      -- ^ Origin-validation policy (DNS-rebinding protection; the MCP
      --   Streamable HTTP spec requires servers to validate the @Origin@
      --   header). @'Just' origins@ rejects any request whose @Origin@ header
      --   is present but not in the list with @403@, and echoes the allowed
      --   origin in CORS headers instead of @*@. Requests without an @Origin@
      --   header (non-browser clients) are always allowed. 'Nothing' disables
      --   the check — only appropriate when the server is not reachable from
      --   a browser (e.g. bound to localhost for CLI clients).
  , httpCacheHints :: CacheHints
      -- ^ Cacheability hints stamped onto modern (2026-07-28+) list/read
      --   results.
  , httpNotifications :: Maybe NotificationSource
      -- ^ When configured, @subscriptions\/listen@ (2026-07-28) is served as
      --   a long-lived SSE stream delivering the change notifications the
      --   client opted into, and the @listChanged@\/@subscribe@ capabilities
      --   are advertised to modern clients. Legacy HTTP clients have no
      --   delivery channel (the deprecated GET SSE stream is not offered),
      --   so legacy @initialize@ does not advertise them.
  }

-- | Default HTTP configuration (authentication disabled).
defaultHttpConfig :: HttpConfig
defaultHttpConfig = HttpConfig
  { httpPort = 3000
  , httpHost = "localhost"
  , httpEndpoint = "/mcp"
  , httpVerbose = False
  , httpAuthorize = Nothing
  , httpAllowedOrigins = Nothing
  , httpCacheHints = defaultCacheHints
  , httpNotifications = Nothing
  }

-- | Helper for conditional logging
logVerbose :: HttpConfig -> String -> IO ()
logVerbose config msg = when (httpVerbose config) $ hPutStrLn stderr msg

-- | Whether a notification source is configured.
httpHasNotifications :: HttpConfig -> Bool
httpHasNotifications = isJust . httpNotifications


-- | Transport-specific implementation for HTTP
transportRunHttp :: HttpConfig -> McpServerInfo -> McpServerHandlers -> IO ()
transportRunHttp config serverInfo handlers = do
  let settings = Warp.setHost (fromString $ httpHost config) $
                 Warp.setPort (httpPort config) $
                 Warp.defaultSettings

  putStrLn $ "Starting MCP HTTP server on " ++ httpHost config ++ ":" ++ show (httpPort config) ++ httpEndpoint config
  Warp.runSettings settings (mcpApplication config serverInfo handlers)

-- | WAI Application for MCP over HTTP
mcpApplication :: HttpConfig -> McpServerInfo -> McpServerHandlers -> Wai.Application
mcpApplication config serverInfo handlers req respond0 = do
  -- Log the request
  logVerbose config $ "HTTP " ++ show (Wai.requestMethod req) ++ " " ++ T.unpack (TE.decodeUtf8 $ Wai.rawPathInfo req)

  let origin = lookup hOrigin (Wai.requestHeaders req)
      -- Every response carries exactly one Access-Control-Allow-Origin header:
      -- the validated request origin when a policy is configured, "*"
      -- otherwise. When the value depends on the request origin, Vary: Origin
      -- keeps shared caches from serving one origin's response to another.
      corsHeaders = case (httpAllowedOrigins config, origin) of
        (Just _, Just o) -> [("Access-Control-Allow-Origin", o), ("Vary", "Origin")]
        _                -> [("Access-Control-Allow-Origin", "*")]
      addCors hs = corsHeaders
                 ++ filter ((/= "Access-Control-Allow-Origin") . fst) hs
      respond = respond0 . Wai.mapResponseHeaders addCors

  -- Origin validation first (DNS-rebinding protection, a MUST in the spec).
  if not (originAllowed config req)
    then do
      logVerbose config $ "Request rejected: disallowed Origin " ++ show origin
      respond0 $ Wai.responseLBS
        status403
        [("Content-Type", "application/json")]
        (encode $ object ["error" .= ("Origin not allowed" :: Text)])
    else do
      -- Authenticate and obtain the caller's principal (if any) before anything
      -- else. CORS preflight requests are exempt: browsers never attach
      -- credentials to an OPTIONS preflight, and the preflight response is what
      -- tells the browser it may send the Authorization header at all.
      decision <- case httpAuthorize config of
        _ | Wai.requestMethod req == "OPTIONS"
                   -> pure (Just Nothing)      -- CORS preflight: no credentials
        Nothing    -> pure (Just Nothing)      -- auth disabled: allowed, no principal
        Just authorize -> fmap (fmap Just) (authorize (bearerToken req))
      case decision of
        Nothing -> do
          logVerbose config "Request rejected by authorization callback"
          respond $ Wai.responseLBS
            status401
            [("Content-Type", "application/json"), ("WWW-Authenticate", "Bearer")]
            (encode $ object ["error" .= ("Unauthorized" :: Text)])
        Just principal -> do
          let ctx = anonymousContext { clientToken = bearerToken req, clientPrincipal = principal }
          -- Check if this is our MCP endpoint
          if TE.decodeUtf8 (Wai.rawPathInfo req) == T.pack (httpEndpoint config)
            then handleMcpRequest config serverInfo handlers ctx req respond
            else respond $ Wai.responseLBS status404 [("Content-Type", "text/plain")] "Not Found"

-- | Whether the request passes the configured Origin policy: with no policy
-- everything passes; with a policy, a present Origin header must be in the
-- allow-list (absent Origin means a non-browser client and always passes).
originAllowed :: HttpConfig -> Wai.Request -> Bool
originAllowed config req = case httpAllowedOrigins config of
  Nothing      -> True
  Just allowed -> case lookup hOrigin (Wai.requestHeaders req) of
    Nothing -> True
    Just o  -> TE.decodeUtf8With lenientDecode o `elem` allowed

-- | The bearer token presented by a request, if any: the value following
-- @Authorization: Bearer @. The scheme is matched case-insensitively per
-- RFC 7235, and invalid UTF-8 in the header is replaced rather than thrown.
bearerToken :: Wai.Request -> Maybe Text
bearerToken req = do
  header <- lookup hAuthorization (Wai.requestHeaders req)
  let (scheme, rest) = T.break (== ' ') (TE.decodeUtf8With lenientDecode header)
  if T.toCaseFold scheme == "bearer" && not (T.null rest)
    then Just (T.stripStart rest)
    else Nothing

-- | Handle MCP requests according to Streamable HTTP specification
handleMcpRequest :: HttpConfig -> McpServerInfo -> McpServerHandlers -> ClientContext -> Wai.Request -> (Wai.Response -> IO Wai.ResponseReceived) -> IO Wai.ResponseReceived
handleMcpRequest config serverInfo handlers ctx req respond = do
  -- Read the POST body up front: the request era and header validation both
  -- depend on it. A body declaring a modern protocol revision in _meta gets
  -- the 2026-07-28 header validation (header/body match, Mcp-Method,
  -- Mcp-Name); a legacy body keeps the relaxed pre-2026 rules, where
  -- `initialize` is exempt (it negotiates in the body), a missing
  -- MCP-Protocol-Version header is accepted, and a present-but-unsupported
  -- one is rejected with 400.
  body <- if Wai.requestMethod req == "POST" then Wai.strictRequestBody req else pure ""
  let peek = peekBody body
      modern = peekIsModern peek
  case Wai.requestMethod req of
    -- POST requests for JSON-RPC messages
    "POST" ->
      case validateRequestHeaders req peek of
        Just err -> do
          logVerbose config $ "Request rejected: " ++ T.unpack (errorMessage err)
          respond $ Wai.responseLBS
            status400
            [("Content-Type", "application/json")]
            (encode $ makeErrorResponse (peekId peek) err)
        Nothing
          -- subscriptions/listen is transport-level: the response is a
          -- long-lived SSE stream, not a single JSON object
          | peekMethod peek == Just "subscriptions/listen"
          , Just src <- httpNotifications config
          -> handleSubscriptionsListen config src body respond
          | otherwise -> do
              logVerbose config $ "Received POST body (" ++ show (BSL.length body) ++ " bytes): " ++ take 200 (show body)
              handleJsonRpcRequest config serverInfo handlers ctx modern body respond

    -- OPTIONS for CORS preflight
    "OPTIONS" -> respond $ Wai.responseLBS
      status200
      [ ("Access-Control-Allow-Methods", "POST, OPTIONS")
      , ("Access-Control-Allow-Headers", "Content-Type, Authorization, MCP-Protocol-Version, Mcp-Method, Mcp-Name")
      ]
      ""

    -- Everything else, including GET: the MCP endpoint only speaks
    -- JSON-RPC over POST (this library does not offer server-initiated
    -- SSE streams, and revision 2026-07-28 requires 405 for GET).
    _ -> respond $ Wai.responseLBS
      status405
      [("Content-Type", "text/plain"), ("Allow", "POST, OPTIONS")]
      "Method Not Allowed"

-- | The parts of a JSON-RPC body that header validation needs.
data BodyPeek = BodyPeek
  { peekMethod      :: Maybe Text
  , peekMetaVersion :: Maybe Text
  , peekName        :: Maybe Text  -- ^ params.name or params.uri
  , peekId          :: RequestId
  }

-- | Whether the body declares a modern (2026-07-28+) request.
peekIsModern :: BodyPeek -> Bool
peekIsModern peek =
  peekMetaVersion peek /= Nothing || peekMethod peek == Just "server/discover"

peekBody :: BSL.ByteString -> BodyPeek
peekBody body = case decode body of
  Just (Object o) -> BodyPeek
    { peekMethod = case KM.lookup "method" o of
        Just (String m) -> Just m
        _               -> Nothing
    , peekMetaVersion = metaProtocolVersion (KM.lookup "params" o)
    , peekName = case KM.lookup "params" o of
        Just (Object p) -> case (KM.lookup "name" p, KM.lookup "uri" p) of
          (Just (String n), _) -> Just n
          (_, Just (String u)) -> Just u
          _                    -> Nothing
        _ -> Nothing
    , peekId = case KM.lookup "id" o of
        Just v  -> either (const RequestIdNull) id (parseEither parseJSON v)
        Nothing -> RequestIdNull
    }
  _ -> BodyPeek Nothing Nothing Nothing RequestIdNull

-- | Validate the request-metadata headers against the body. 'Nothing' means
-- the request may proceed; @'Just' err@ is answered with 400 and the given
-- JSON-RPC error.
--
-- Modern requests (body declares a protocol revision in @_meta@) follow the
-- 2026-07-28 rules: the MCP-Protocol-Version header must match the body's
-- declared revision, Mcp-Method must match the body method, and Mcp-Name
-- must match @params.name@/@params.uri@ for the three named methods
-- (@HeaderMismatch@, -32020); an undeclared revision yields
-- @UnsupportedProtocolVersionError@ (-32022).
--
-- Legacy requests keep the relaxed pre-2026 rules: only a
-- present-but-unsupported MCP-Protocol-Version header is rejected, and
-- @initialize@ is exempt entirely.
validateRequestHeaders :: Wai.Request -> BodyPeek -> Maybe JsonRpcError
validateRequestHeaders req peek = case peekMetaVersion peek of
  Just v
    | v `notElem` modernVersions -> Just (unsupportedVersionError v)
    | headerText "MCP-Protocol-Version" /= Just v ->
        Just $ headerMismatch $ "MCP-Protocol-Version header "
          <> maybe "is missing" (\h -> "value '" <> h <> "'") (headerText "MCP-Protocol-Version")
          <> " and does not match body value '" <> v <> "'"
    | headerText "Mcp-Method" /= peekMethod peek ->
        Just $ headerMismatch $ "Mcp-Method header "
          <> maybe "is missing" (\h -> "value '" <> h <> "'") (headerText "Mcp-Method")
          <> " and does not match body method"
    | needsName
    , (decodeSentinel <$> headerText "Mcp-Name") /= peekName peek ->
        Just $ headerMismatch $ "Mcp-Name header "
          <> maybe "is missing" (\h -> "value '" <> h <> "'") (headerText "Mcp-Name")
          <> " and does not match the body name/uri"
    | otherwise -> Nothing
  Nothing
    | peekMethod peek /= Just "initialize"
    , Just hv <- headerText "MCP-Protocol-Version"
    , hv `notElem` supportedVersions ++ modernVersions ->
        Just $ JsonRpcError
          { errorCode = -32600
          , errorMessage = "Unsupported protocol version. Supported versions: "
              <> T.intercalate ", " allVersions
          , errorData = Nothing
          }
    | otherwise -> Nothing
  where
    headerText name =
      TE.decodeUtf8With lenientDecode <$> lookup name (Wai.requestHeaders req)
    needsName = peekMethod peek `elem` map Just ["tools/call", "resources/read", "prompts/get"]
    headerMismatch msg = JsonRpcError
      { errorCode = -32020
      , errorMessage = "Header mismatch: " <> msg
      , errorData = Nothing
      }

-- | Decode the @=?base64?...?=@ sentinel encoding the 2026-07-28 transport
-- uses for header values that cannot be represented in plain ASCII. Values
-- not wrapped in the sentinel (or with invalid base64) pass through as-is.
decodeSentinel :: Text -> Text
decodeSentinel t
  | Just inner <- T.stripPrefix "=?base64?" t >>= T.stripSuffix "?=" =
      case B64.decode (TE.encodeUtf8 inner) of
        Right bs -> TE.decodeUtf8With lenientDecode bs
        Left _   -> t
  | otherwise = t

-- | Serve a subscriptions/listen request (2026-07-28): a long-lived SSE
-- stream that first acknowledges the subscription and then delivers exactly
-- the notification types the client opted into, tagged with the
-- subscription id. Closing the stream is the client's cancellation signal;
-- periodic SSE comments keep intermediaries from timing the stream out.
handleSubscriptionsListen :: HttpConfig -> NotificationSource -> BSL.ByteString -> (Wai.Response -> IO Wai.ResponseReceived) -> IO Wai.ResponseReceived
handleSubscriptionsListen config src body respond =
  case eitherDecode body :: Either String JsonRpcRequest of
    Left err -> do
      hPutStrLn stderr $ "subscriptions/listen parse error: " ++ err
      respond $ Wai.responseLBS
        status400
        [("Content-Type", "application/json")]
        (encode $ makeErrorResponse RequestIdNull $ JsonRpcError
          { errorCode = -32600
          , errorMessage = "Invalid Request"
          , errorData = Nothing
          })
    Right req -> do
      let subId = requestId req
          notifFilter = parseNotificationFilter (requestParams req)
      logVerbose config $ "Opening subscription stream " ++ show subId
      respond $ Wai.responseStream
        status200
        [ ("Content-Type", "text/event-stream")
        , ("Cache-Control", "no-cache")
        , ("X-Accel-Buffering", "no")
        ]
        $ \write flush -> do
          chan <- atomically $ subscribeEvents src
          let sendJson v = do
                write $ B.lazyByteString $ "data: " <> encode v <> "\n\n"
                flush
          sendJson $ toJSON $ acknowledgedNotification subId notifFilter
          forever $ do
            keepAlive <- registerDelay 25000000  -- 25s
            next <- atomically $
              (Just <$> readTChan chan)
                `orElse` (readTVar keepAlive >>= check >> pure Nothing)
            case next of
              Just event
                | filterAccepts notifFilter event ->
                    sendJson $ toJSON $ eventNotification subId event
                | otherwise -> pure ()
              Nothing -> do
                write $ B.byteString ": keep-alive\n\n"
                flush

-- | Handle JSON-RPC request from HTTP body
handleJsonRpcRequest :: HttpConfig -> McpServerInfo -> McpServerHandlers -> ClientContext -> Bool -> BSL.ByteString -> (Wai.Response -> IO Wai.ResponseReceived) -> IO Wai.ResponseReceived
handleJsonRpcRequest config serverInfo handlers ctx modern body respond = do
  case eitherDecode body of
    Left err -> do
      hPutStrLn stderr $ "JSON parse error: " ++ err
      respond $ Wai.responseLBS
        status400
        [("Content-Type", "application/json")]
        (encode $ makeErrorResponse RequestIdNull $ JsonRpcError
          { errorCode = -32700
          , errorMessage = "Parse error"
          , errorData = Nothing
          })

    Right jsonValue -> handleSingleJsonRpc config serverInfo handlers ctx modern jsonValue respond

-- | Handle a single JSON-RPC message
handleSingleJsonRpc :: HttpConfig -> McpServerInfo -> McpServerHandlers -> ClientContext -> Bool -> Value -> (Wai.Response -> IO Wai.ResponseReceived) -> IO Wai.ResponseReceived
handleSingleJsonRpc config serverInfo handlers ctx modern jsonValue respond = do
  case parseJsonRpcMessage jsonValue of
    Left err -> do
      hPutStrLn stderr $ "JSON-RPC parse error: " ++ err
      respond $ Wai.responseLBS
        status400
        [("Content-Type", "application/json")]
        (encode $ makeErrorResponse RequestIdNull $ JsonRpcError
          { errorCode = -32600
          , errorMessage = "Invalid Request"
          , errorData = Nothing
          })

    Right message -> do
      logVerbose config $ "Processing HTTP message: " ++ show (getMessageSummary message)
      let notifSupport = NotificationSupport
            { supportsLegacyPush = False  -- no legacy HTTP delivery channel
            , supportsListen = httpHasNotifications config
            }
      maybeResponse <- handleMcpMessage serverInfo (httpCacheHints config) notifSupport handlers ctx message

      case maybeResponse of
        Just responseMsg -> do
          let responseJson = encode $ encodeJsonRpcMessage responseMsg
          logVerbose config $ "Sending HTTP response for: " ++ show (getMessageSummary message)
          respond $ Wai.responseLBS
            (statusForResponse modern responseMsg)
            [("Content-Type", "application/json")]
            responseJson

        Nothing -> do
          logVerbose config $ "No response needed for: " ++ show (getMessageSummary message)
          -- Accepted notification: 202 with no body, per the Streamable HTTP spec
          respond $ Wai.responseLBS status202 [] ""

-- | The HTTP status for a JSON-RPC response. Modern (2026-07-28) requests
-- map protocol-level failures onto HTTP statuses — 404 for an unknown RPC
-- method, 400 for an unsupported protocol version — so era-probing clients
-- can distinguish them; legacy requests always get 200 as before.
statusForResponse :: Bool -> JsonRpcMessage -> Status
statusForResponse True (JsonRpcMessageResponse r) = case responseError r of
  Just e | errorCode e == -32601 -> status404
         | errorCode e == -32022 -> status400
  _ -> status200
statusForResponse _ _ = status200