packages feed

mcp-server-0.2.0.1: src/MCP/Server/Transport/Stdio.hs

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

module MCP.Server.Transport.Stdio
  ( -- * STDIO Transport
    transportRunStdio
  , transportRunStdioWithConfig
  , StdioConfig(..)
  , defaultStdioConfig
  ) where

import           Control.Concurrent     (ThreadId, forkIO, killThread)
import           Control.Concurrent.Async (Async, async, cancel)
import           Control.Concurrent.MVar (modifyMVar_, newEmptyMVar, newMVar,
                                          putMVar, readMVar, takeMVar,
                                          withMVar)
import           Control.Concurrent.STM (atomically, readTChan)
import           Control.Exception      (finally, uninterruptibleMask_)
import           Control.Monad          (forever, unless, when)
import           Data.Aeson
import qualified Data.Aeson.KeyMap      as KM
import           Data.Aeson.Types       (parseEither)
import qualified Data.ByteString.Lazy   as BSL
import           Data.IORef             (newIORef, readIORef, writeIORef)
import qualified Data.Text              as T
import qualified Data.Text.Encoding     as TE
import qualified Data.Text.IO           as TIO
import           System.IO              (hFlush, hIsEOF, hSetEncoding, stderr,
                                         stdin, stdout, utf8)

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

-- | STDIO transport configuration
data StdioConfig = StdioConfig
  { stdioVerbose :: Bool
    -- ^ When 'True', raw request bodies are logged to stderr. The default
    -- ('False') logs only message summaries (method and id): tool arguments
    -- may carry sensitive data that does not belong in logs.
  , stdioCacheHints :: CacheHints
    -- ^ Cacheability hints stamped onto modern (2026-07-28+) list/read
    -- results.
  , stdioNotifications :: Maybe NotificationSource
    -- ^ When configured, change notifications are delivered: modern clients
    -- via @subscriptions\/listen@, legacy clients as spontaneous
    -- notifications after @initialize@ — and the corresponding
    -- @listChanged@\/@subscribe@ capabilities are advertised.
  }

-- | Default STDIO configuration: summaries only, no raw bodies, no caching,
-- no notifications.
defaultStdioConfig :: StdioConfig
defaultStdioConfig = StdioConfig
  { stdioVerbose = False
  , stdioCacheHints = defaultCacheHints
  , stdioNotifications = Nothing
  }

-- | Run the STDIO transport with the default configuration.
transportRunStdio :: McpServerInfo -> McpServerHandlers -> IO ()
transportRunStdio = transportRunStdioWithConfig defaultStdioConfig

-- | Run the STDIO transport with the given configuration.
--
-- Each request is served in its own task, so a @notifications/cancelled@
-- naming its id can interrupt it mid-flight (after which nothing further is
-- written for that id). Cancellation reaches handler code as an
-- asynchronous exception: handlers that acquire resources should release
-- them with 'Control.Exception.bracket'.
--
-- This also means requests run /concurrently/ (before 0.2.0.1 the stdio
-- transport processed them strictly sequentially): handlers touching
-- shared mutable state must synchronize, as was already required of
-- handlers used with the HTTP transport.
transportRunStdioWithConfig :: StdioConfig -> McpServerInfo -> McpServerHandlers -> IO ()
transportRunStdioWithConfig config serverInfo handlers = do
  -- Ensure UTF-8 encoding for all handles
  hSetEncoding stderr utf8
  hSetEncoding stdout utf8

  -- Subscription threads, request tasks and the main loop share stdout:
  -- one line at a time.
  writeLock <- newMVar ()
  -- Active subscriptions_listen streams, by their request id
  subsVar <- newMVar ([] :: [(RequestId, ThreadId)])
  -- In-flight request tasks, by request id (cancellable)
  inflightVar <- newMVar ([] :: [(RequestId, Async ())])
  -- Whether a legacy client has completed initialize (gates legacy pushes)
  legacyReady <- newIORef False

  let logLine = TIO.hPutStrLn stderr
      logVerbose msg = when (stdioVerbose config) $ logLine msg

      -- Writes are uninterruptible so a cancellation arriving mid-write
      -- cannot corrupt the message channel with a half line
      sendRaw bytes = withMVar writeLock $ \_ -> uninterruptibleMask_ $ do
        TIO.putStrLn $ TE.decodeUtf8 $ BSL.toStrict bytes
        hFlush stdout
      sendMessage msg = sendRaw $ encode $ encodeJsonRpcMessage msg
      sendResponse resp = sendRaw $ encode $ toJSON (resp :: JsonRpcResponse)
      sendNotification notif = sendRaw $ encode $ toJSON (notif :: JsonRpcNotification)

      notifSupport = case stdioNotifications config of
        Nothing -> noNotificationSupport
        Just _  -> NotificationSupport { supportsLegacyPush = True, supportsListen = True }

  -- Legacy delivery: push untagged notifications to a client that completed
  -- the initialize handshake (which is when the capability was advertised).
  case stdioNotifications config of
    Nothing  -> pure ()
    Just src -> do
      chan <- atomically $ subscribeEvents src
      _ <- forkIO $ forever $ do
        event <- atomically $ readTChan chan
        ready <- readIORef legacyReady
        when ready $ sendNotification $ legacyEventNotification event
      pure ()

  let
    -- Open a subscriptions/listen stream: acknowledge, then deliver
    -- matching events tagged with the subscription id until cancelled.
    -- A listen reusing an already-open subscription id replaces the old
    -- stream (otherwise the shadowed writer would leak until EOF).
    openSubscription src req = do
      let subId = requestId req
          notifFilter = parseNotificationFilter (requestParams req)
      _ <- cancelSubscription subId
      chan <- atomically $ subscribeEvents src
      sendNotification $ acknowledgedNotification subId notifFilter
      tid <- forkIO $ forever $ do
        event <- atomically $ readTChan chan
        when (filterAccepts notifFilter event) $
          sendNotification $ eventNotification subId event
      modifyMVar_ subsVar (pure . ((subId, tid) :))
      logLine $ "Opened subscription " <> T.pack (show subId)

    cancelSubscription cancelledId = do
      subs <- readMVar subsVar
      case lookup cancelledId subs of
        Nothing  -> pure False
        Just tid -> do
          killThread tid
          modifyMVar_ subsVar (pure . filter ((/= cancelledId) . fst))
          logLine $ "Cancelled subscription " <> T.pack (show cancelledId)
          pure True

    -- Server-side teardown at EOF: graceful closure for every open stream
    closeAllSubscriptions = do
      subs <- readMVar subsVar
      mapM_ (\(subId, tid) -> do
                killThread tid
                sendResponse $ closureResponse serverInfo subId)
            subs

    -- Cancel an in-flight request task. 'cancel' waits for the task to
    -- finish, so once this returns nothing further is written for that id.
    cancelInflight cancelledId = do
      inflight <- readMVar inflightVar
      case lookup cancelledId inflight of
        Nothing -> pure False
        Just task -> do
          cancel task
          logLine $ "Cancelled request " <> T.pack (show cancelledId)
          pure True

    dispatchMessage message = do
      -- Request-scoped notifications (progress, client logs) share the
      -- locked stdout channel, interleaved before the response
      response <- handleMcpMessage serverInfo (stdioCacheHints config) notifSupport sendNotification handlers anonymousContext message
      case response of
        Just responseMsg -> do
          logLine $ "Sending response for: " <> T.pack (show (getMessageSummary message))
          sendMessage responseMsg
        Nothing ->
          logLine $ "No response needed for: " <> T.pack (show (getMessageSummary message))

    handleParsed message = case message of
      -- subscriptions/listen is transport-level: the stream outlives the
      -- request. Only intercept when a source is configured and the
      -- declared revision (if any) is one we implement — otherwise fall
      -- through for the ordinary -32601 / -32022 answer.
      JsonRpcMessageRequest req
        | requestMethod req == "subscriptions/listen"
        , Just src <- stdioNotifications config
        , maybe True (`elem` modernVersions) (metaProtocolVersion (requestParams req))
        -> openSubscription src req

      -- Every other request runs in its own task so a later
      -- notifications/cancelled can interrupt it while the read loop keeps
      -- serving. The task is registered before its body starts (the gate)
      -- so cancellation can never race registration; it deregisters itself
      -- on any exit, including cancellation.
        | otherwise -> do
          let rid = requestId req
          gate <- newEmptyMVar
          task <- async $ do
            takeMVar gate
            dispatchMessage message
              `finally` modifyMVar_ inflightVar (pure . filter ((/= rid) . fst))
          modifyMVar_ inflightVar (pure . ((rid, task) :))
          putMVar gate ()

      -- notifications/cancelled tears down the referenced subscription or
      -- in-flight request (no response either way, per the cancellation
      -- rules; unknown ids are ignored as the spec requires)
      JsonRpcMessageNotification notif
        | notificationMethod notif == "notifications/cancelled"
        , Just cancelledId <- cancelledRequestId (notificationParams notif)
        -> do
          wasSub <- cancelSubscription cancelledId
          unless wasSub $ do
            wasInflight <- cancelInflight cancelledId
            unless wasInflight $
              logLine $ "Ignoring cancellation for unknown request " <> T.pack (show cancelledId)

      _ -> do
        -- The client's initialized notification is the legacy ready signal:
        -- the lifecycle forbids server notifications before it arrives, and
        -- it only arrives after the client accepted a successful handshake
        -- response (so failed initializes never enable pushes).
        case message of
          JsonRpcMessageNotification n
            | notificationMethod n == "notifications/initialized" ->
                writeIORef legacyReady True
          _ -> pure ()
        dispatchMessage message

    loop = do
      eof <- hIsEOF stdin
      if eof
        then do
          inflight <- readMVar inflightVar
          mapM_ (cancel . snd) inflight
          closeAllSubscriptions
          logLine "stdin closed - shutting down"
        else do
          input <- TIO.getLine
          unless (T.null $ T.strip input) $ do
            logVerbose $ "Received request: " <> input
            case eitherDecode (BSL.fromStrict $ TE.encodeUtf8 input) of
              Left err -> do
                logLine $ "Parse error: " <> T.pack err
                sendResponse $ makeErrorResponse RequestIdNull $ JsonRpcError
                  { errorCode = -32700
                  , errorMessage = "Parse error"
                  , errorData = Nothing
                  }
              Right jsonValue ->
                case parseJsonRpcMessage jsonValue of
                  Left err -> do
                    logLine $ "JSON-RPC parse error: " <> T.pack err
                    sendResponse $ makeErrorResponse RequestIdNull $ JsonRpcError
                      { errorCode = -32600
                      , errorMessage = "Invalid Request"
                      , errorData = Nothing
                      }
                  Right message -> do
                    logLine $ "Processing message: " <> T.pack (show (getMessageSummary message))
                    handleParsed message
          loop

  loop

-- | The request id referenced by a notifications/cancelled notification.
cancelledRequestId :: Maybe Value -> Maybe RequestId
cancelledRequestId params = do
  Object o <- params
  v <- KM.lookup "requestId" o
  either (const Nothing) Just (parseEither parseJSON v)