mcp-server-0.2.0.0: src/MCP/Server/Handlers.hs
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
module MCP.Server.Handlers
( -- * Core Message Handling
handleMcpMessage
, jsonValueToText
-- * Individual Request Handlers
, handleInitialize
, handlePing
, handleServerDiscover
, handlePromptsList
, handlePromptsGet
, handleResourcesList
, handleResourcesRead
, handleResourcesTemplatesList
, handleToolsList
, handleToolsCall
, handleCompletionComplete
-- * Protocol Support
, validateProtocolVersion
, getMessageSummary
, metaProtocolVersion
, unsupportedVersionError
-- * Error Conversion
, errorCodeFromMcpError
, errorMessageFromMcpError
) where
import Data.Aeson
import qualified Data.Aeson.Key as Key
import qualified Data.Aeson.KeyMap as KM
import qualified Data.Map as Map
import Data.Maybe (fromMaybe, isJust)
import Data.Text (Text)
import qualified Data.Text as T
import System.IO (hPutStrLn, stderr)
import MCP.Server.JsonRpc
import MCP.Server.Protocol
import MCP.Server.Types
-- | Convert JSON Value to Text representation suitable for handlers
jsonValueToText :: Value -> Text
jsonValueToText (String t) = t
jsonValueToText (Number n) =
-- Check if it's a whole number, if so format as integer
if fromInteger (round n) == n
then T.pack $ show (round n :: Integer)
else T.pack $ show n
jsonValueToText (Bool True) = "true"
jsonValueToText (Bool False) = "false"
jsonValueToText Null = ""
jsonValueToText v = T.pack $ show v
-- | Extract a brief summary of a JSON-RPC message for logging
getMessageSummary :: JsonRpcMessage -> String
getMessageSummary (JsonRpcMessageRequest req) =
"Request[" ++ show (requestId req) ++ "] " ++ T.unpack (requestMethod req)
getMessageSummary (JsonRpcMessageNotification notif) =
"Notification " ++ T.unpack (notificationMethod notif)
getMessageSummary (JsonRpcMessageResponse resp) =
"Response[" ++ show (responseId resp) ++ "]"
-- | Validate protocol version and return negotiated version
-- Per MCP spec: "If the server supports the requested protocol version,
-- it MUST respond with the same version. Otherwise, the server MUST respond
-- with another protocol version it supports."
--
-- Only /legacy/ revisions are eligible here: a client that negotiates via
-- @initialize@ is legacy by definition, so proposing @2026-07-28@ (or
-- anything unknown) negotiates down to the newest legacy revision rather
-- than promising stateless semantics inside a handshake.
validateProtocolVersion :: Text -> Either Text Text
validateProtocolVersion clientVersion
| clientVersion `elem` supportedVersions = Right clientVersion -- Supported: echo the client's own version
| otherwise = Right protocolVersion -- Unknown: negotiate down to the server's default version
-- | Look up an @io.modelcontextprotocol/\<key\>@ entry in a request's
-- params @_meta@ object.
metaLookup :: Text -> Maybe Value -> Maybe Value
metaLookup key params = do
Object o <- params
Object m <- KM.lookup "_meta" o
KM.lookup (Key.fromText ("io.modelcontextprotocol/" <> key)) m
-- | The protocol revision a request declares in its params @_meta@
-- (modern, 2026-07-28+ clients). 'Nothing' for legacy requests.
metaProtocolVersion :: Maybe Value -> Maybe Text
metaProtocolVersion params = case metaLookup "protocolVersion" params of
Just (String v) -> Just v
_ -> Nothing
-- | The @UnsupportedProtocolVersionError@ (-32022) for a declared version
-- this library does not implement, listing what it does.
unsupportedVersionError :: Text -> JsonRpcError
unsupportedVersionError requested = JsonRpcError
{ errorCode = -32022
, errorMessage = "Unsupported protocol version"
, errorData = Just $ object
[ "supported" .= allVersions
, "requested" .= requested
]
}
-- | Methods whose modern results carry the required cacheability fields.
cacheableMethods :: [Text]
cacheableMethods =
[ "server/discover"
, "tools/list"
, "prompts/list"
, "resources/list"
, "resources/read"
, "resources/templates/list"
]
-- | Stamp the modern-revision result envelope onto a successful response:
-- @resultType: \"complete\"@, the server's identity in result @_meta@, and
-- (for cacheable methods) @ttlMs@ and @cacheScope@. Error responses and
-- legacy responses pass through untouched.
decorateModern :: McpServerInfo -> CacheHints -> Text -> JsonRpcResponse -> JsonRpcResponse
decorateModern serverInfo hints method resp = case responseResult resp of
Just (Object o) -> resp { responseResult = Just $ Object $ decorate o }
_ -> resp
where
decorate = insertCache . KM.insert "resultType" (String "complete") . insertMeta
insertMeta o = KM.insert "_meta" (Object meta) o
where meta = case KM.lookup "_meta" o of
Just (Object m) -> KM.insert serverInfoKey serverInfoVal m
_ -> KM.singleton serverInfoKey serverInfoVal
serverInfoKey = "io.modelcontextprotocol/serverInfo"
serverInfoVal = object
[ "name" .= serverName serverInfo
, "version" .= serverVersion serverInfo
]
insertCache o
| method `elem` cacheableMethods =
KM.insert "ttlMs" (toJSON (cacheTtlMs hints)) $
KM.insert "cacheScope"
(String (if cacheScopePublic hints then "public" else "private")) o
| otherwise = o
-- | Handle an MCP message and return a response if needed.
--
-- The server is dual-era: a request that declares a protocol revision in
-- its params @_meta@ (2026-07-28+) is served statelessly with the modern
-- result envelope; a request that does not is served exactly as before
-- under the revision negotiated by @initialize@.
handleMcpMessage :: McpServerInfo
-> CacheHints
-> NotificationSupport
-> McpServerHandlers
-> ClientContext
-> JsonRpcMessage
-> IO (Maybe JsonRpcMessage)
handleMcpMessage serverInfo hints notifSupport handlers ctx0 (JsonRpcMessageRequest req) = do
let params = requestParams req
declaredVersion = metaProtocolVersion params
case declaredVersion of
Just v | v `notElem` modernVersions ->
return $ Just $ JsonRpcMessageResponse $
makeErrorResponse (requestId req) (unsupportedVersionError v)
_ -> do
-- server/discover is itself a modern-revision method (and the
-- backwards-compatibility probe), so it always gets the modern
-- envelope even if a probing client omitted _meta.
let modern = isJust declaredVersion || requestMethod req == "server/discover"
ctx = ctx0
{ clientProtocolVersion = declaredVersion
, clientInfo = metaLookup "clientInfo" params
, clientCapabilities = metaLookup "clientCapabilities" params
}
response <- case requestMethod req of
-- Era purity: the modern revision has neither initialize (nothing to
-- negotiate statelessly) nor ping (removed) — a request declaring a
-- modern revision must be served "according to this revision", so
-- these are unknown methods there.
m | isJust declaredVersion, m `elem` ["initialize", "ping"] ->
return $ makeErrorResponse (requestId req) $ JsonRpcError
{ errorCode = -32601
, errorMessage = "Method not found: " <> m
<> " (not part of protocol revision " <> fromMaybe "" declaredVersion <> ")"
, errorData = Nothing
}
"initialize" -> handleInitialize serverInfo notifSupport handlers req
"ping" -> handlePing req
"server/discover" -> handleServerDiscover serverInfo notifSupport handlers req
"prompts/list" -> handlePromptsList handlers ctx req
"prompts/get" -> handlePromptsGet handlers ctx req
"resources/list" -> handleResourcesList handlers ctx req
"resources/read" -> handleResourcesRead handlers ctx req
"resources/templates/list" -> handleResourcesTemplatesList handlers ctx req
"tools/list" -> handleToolsList handlers ctx req
"tools/call" -> handleToolsCall handlers ctx req
"completion/complete" -> handleCompletionComplete handlers ctx req
method -> return $ makeErrorResponse (requestId req) $ JsonRpcError
{ errorCode = -32601
, errorMessage = "Method not found: " <> method
, errorData = Nothing
}
let response' = if modern
then decorateModern serverInfo hints (requestMethod req) response
else response
return $ Just $ JsonRpcMessageResponse response'
handleMcpMessage _ _ _ _ _ (JsonRpcMessageNotification notif) = do
case notificationMethod notif of
"notifications/initialized" ->
hPutStrLn stderr "Received initialized notification - server is ready for operation"
_ ->
hPutStrLn stderr $ "Received unknown notification: " ++ T.unpack (notificationMethod notif)
return Nothing
handleMcpMessage _ _ _ _ _ (JsonRpcMessageResponse _) =
return Nothing
-- | Handle initialize request
handleInitialize :: McpServerInfo -> NotificationSupport -> McpServerHandlers -> JsonRpcRequest -> IO JsonRpcResponse
handleInitialize serverInfo notifSupport handlers req = do
case requestParams req of
Nothing -> return $ makeErrorResponse (requestId req) $ JsonRpcError
{ errorCode = -32602
, errorMessage = "Missing required parameters for initialize"
, errorData = Nothing
}
Just params ->
case fromJSON params of
Error err -> return $ makeErrorResponse (requestId req) $ JsonRpcError
{ errorCode = -32602
, errorMessage = "Invalid initialize parameters: " <> T.pack err
, errorData = Nothing
}
Success initReq -> do
-- Check protocol version compatibility
let clientVersion = initProtocolVersion initReq
case validateProtocolVersion clientVersion of
Left errorMsg -> return $ makeErrorResponse (requestId req) $ JsonRpcError
{ errorCode = -32602
, errorMessage = errorMsg
, errorData = Nothing
}
Right negotiatedVersion -> do
hPutStrLn stderr $ "Client version: " ++ T.unpack clientVersion ++ ", using: " ++ T.unpack negotiatedVersion
let response = InitializeResponse
{ initRespProtocolVersion = negotiatedVersion
-- Legacy clients can only receive pushed notifications
-- where the transport supports it (stdio); the modern
-- subscribe mechanism is not part of their revision.
, initRespCapabilities =
handlerCapabilities (supportsLegacyPush notifSupport) False handlers
, initRespServerInfo = serverInfo
}
return $ makeSuccessResponse (requestId req) (toJSON response)
-- | Only advertise a capability that actually has a handler.
-- Advertising e.g. "prompts" while prompts/list returns an error
-- makes strict clients (e.g. Crush) drop the whole server.
--
-- The flags say whether change notifications (@listChanged@) and resource
-- update subscriptions (@subscribe@) are deliverable to the requesting
-- client — which depends on transport and era, so callers decide.
handlerCapabilities :: Bool -> Bool -> McpServerHandlers -> ServerCapabilities
handlerCapabilities listChanged subscribe handlers = ServerCapabilities
{ capabilityPrompts = PromptCapabilities { promptListChanged = flag listChanged } <$ prompts handlers
, capabilityResources =
if isJust (resources handlers) || isJust (resourceTemplates handlers)
then Just ResourceCapabilities
{ resourceSubscribe = flag subscribe
, resourceListChanged = flag listChanged
}
else Nothing
, capabilityTools = ToolCapabilities { toolListChanged = flag listChanged } <$ tools handlers
, capabilityCompletions = CompletionCapabilities <$ completions handlers
, capabilityLogging = Nothing -- Not supported yet
}
where
flag b = if b then Just True else Nothing
-- | Handle ping request
handlePing :: JsonRpcRequest -> IO JsonRpcResponse
handlePing req = return $ makeSuccessResponse (requestId req) (toJSON PongResponse)
-- | Handle server/discover (2026-07-28+): the server's supported protocol
-- revisions, capabilities and identity, available before any other request.
-- Also serves as the stdio backwards-compatibility probe. The modern result
-- envelope (resultType, serverInfo _meta, cacheability) is stamped on by
-- 'decorateModern'.
handleServerDiscover :: McpServerInfo -> NotificationSupport -> McpServerHandlers -> JsonRpcRequest -> IO JsonRpcResponse
handleServerDiscover serverInfo notifSupport handlers req = do
-- Modern clients receive notifications via subscriptions/listen, so both
-- listChanged and subscribe hinge on that being served.
let listen = supportsListen notifSupport
result = object $
[ "supportedVersions" .= allVersions
, "capabilities" .= handlerCapabilities listen listen handlers
] ++ (if T.null (serverInstructions serverInfo)
then []
else ["instructions" .= serverInstructions serverInfo])
return $ makeSuccessResponse (requestId req) result
-- | Handle prompts/list request
handlePromptsList :: McpServerHandlers -> ClientContext -> JsonRpcRequest -> IO JsonRpcResponse
handlePromptsList handlers ctx req =
case prompts handlers of
Nothing -> return $ makeErrorResponse (requestId req) $ JsonRpcError
{ errorCode = -32601
, errorMessage = "Prompts not supported"
, errorData = Nothing
}
Just (listHandler, _) -> do
promptsList <- listHandler ctx
let response = PromptsListResponse
{ promptsListPrompts = promptsList
}
return $ makeSuccessResponse (requestId req) (toJSON response)
-- | Handle prompts/get request
handlePromptsGet :: McpServerHandlers -> ClientContext -> JsonRpcRequest -> IO JsonRpcResponse
handlePromptsGet handlers ctx req =
case prompts handlers of
Nothing -> return $ makeErrorResponse (requestId req) $ JsonRpcError
{ errorCode = -32601
, errorMessage = "Prompts not supported"
, errorData = Nothing
}
Just (_, getHandler) -> do
case requestParams req of
Nothing -> return $ makeErrorResponse (requestId req) $ JsonRpcError
{ errorCode = -32602
, errorMessage = "Missing parameters"
, errorData = Nothing
}
Just params ->
case fromJSON params of
Error err -> return $ makeErrorResponse (requestId req) $ JsonRpcError
{ errorCode = -32602
, errorMessage = "Invalid parameters: " <> T.pack err
, errorData = Nothing
}
Success getReq -> do
-- Prompt arguments are string-valued per the MCP spec; flatten
-- any non-string values a lenient client may have sent.
let args = maybe Map.empty (fmap jsonValueToText) (promptsGetArguments getReq)
result <- getHandler ctx (promptsGetName getReq) args
case result of
Left err -> return $ makeErrorResponse (requestId req) $ JsonRpcError
{ errorCode = errorCodeFromMcpError err
, errorMessage = errorMessageFromMcpError err
, errorData = Nothing
}
Right promptRes -> do
let response = PromptsGetResponse
{ promptsGetDescription = promptResultDescription promptRes
, promptsGetMessages = promptResultMessages promptRes
, promptsGetMeta = Nothing
}
return $ makeSuccessResponse (requestId req) (toJSON response)
-- | Handle resources/list request
handleResourcesList :: McpServerHandlers -> ClientContext -> JsonRpcRequest -> IO JsonRpcResponse
handleResourcesList handlers ctx req =
case resources handlers of
-- A templates-only configuration advertises the resources capability,
-- so resources/list must answer (with an empty list) rather than error:
-- strict clients drop servers whose advertised capabilities error.
Nothing | isJust (resourceTemplates handlers) ->
return $ makeSuccessResponse (requestId req)
(toJSON (ResourcesListResponse { resourcesListResources = [] }))
Nothing -> return $ makeErrorResponse (requestId req) $ JsonRpcError
{ errorCode = -32601
, errorMessage = "Resources not supported"
, errorData = Nothing
}
Just (listHandler, _) -> do
resourcesList <- listHandler ctx
let response = ResourcesListResponse
{ resourcesListResources = resourcesList
}
return $ makeSuccessResponse (requestId req) (toJSON response)
-- | Handle resources/read request
handleResourcesRead :: McpServerHandlers -> ClientContext -> JsonRpcRequest -> IO JsonRpcResponse
handleResourcesRead handlers ctx req =
case resources handlers of
Nothing -> return $ makeErrorResponse (requestId req) $ JsonRpcError
{ errorCode = -32601
, errorMessage = "Resources not supported"
, errorData = Nothing
}
Just (_, readHandler) -> do
case requestParams req of
Nothing -> return $ makeErrorResponse (requestId req) $ JsonRpcError
{ errorCode = -32602
, errorMessage = "Missing parameters"
, errorData = Nothing
}
Just params ->
case fromJSON params of
Error err -> return $ makeErrorResponse (requestId req) $ JsonRpcError
{ errorCode = -32602
, errorMessage = "Invalid parameters: " <> T.pack err
, errorData = Nothing
}
Success readReq -> do
result <- readHandler ctx (resourcesReadUri readReq)
case result of
Left err -> return $ makeErrorResponse (requestId req) $ JsonRpcError
{ errorCode = errorCodeFromMcpError err
, errorMessage = errorMessageFromMcpError err
, errorData = Nothing
}
Right resourceContent -> do
let response = ResourcesReadResponse
{ resourcesReadContents = [resourceContent]
}
return $ makeSuccessResponse (requestId req) (toJSON response)
-- | Handle resources/templates/list request
handleResourcesTemplatesList :: McpServerHandlers -> ClientContext -> JsonRpcRequest -> IO JsonRpcResponse
handleResourcesTemplatesList handlers ctx req =
case resourceTemplates handlers of
Nothing -> return $ makeErrorResponse (requestId req) $ JsonRpcError
{ errorCode = -32601
, errorMessage = "Resource templates not supported"
, errorData = Nothing
}
Just listHandler -> do
templates <- listHandler ctx
let response = ResourcesTemplatesListResponse
{ resourcesTemplatesList = templates
}
return $ makeSuccessResponse (requestId req) (toJSON response)
-- | Handle completion/complete request
handleCompletionComplete :: McpServerHandlers -> ClientContext -> JsonRpcRequest -> IO JsonRpcResponse
handleCompletionComplete handlers ctx req =
case completions handlers of
Nothing -> return $ makeErrorResponse (requestId req) $ JsonRpcError
{ errorCode = -32601
, errorMessage = "Completions not supported"
, errorData = Nothing
}
Just completeHandler ->
case requestParams req of
Nothing -> return $ makeErrorResponse (requestId req) $ JsonRpcError
{ errorCode = -32602
, errorMessage = "Missing parameters"
, errorData = Nothing
}
Just params ->
case fromJSON params of
Error err -> return $ makeErrorResponse (requestId req) $ JsonRpcError
{ errorCode = -32602
, errorMessage = "Invalid parameters: " <> T.pack err
, errorData = Nothing
}
Success completeReq -> do
result <- completeHandler ctx
(completeRef completeReq)
(completeArgumentName completeReq)
(completeArgumentValue completeReq)
(completeContextArgs completeReq)
case result of
Left err -> return $ makeErrorResponse (requestId req) $ JsonRpcError
{ errorCode = errorCodeFromMcpError err
, errorMessage = errorMessageFromMcpError err
, errorData = Nothing
}
Right completion ->
return $ makeSuccessResponse (requestId req) (toJSON (CompleteResponse completion))
-- | Handle tools/list request
handleToolsList :: McpServerHandlers -> ClientContext -> JsonRpcRequest -> IO JsonRpcResponse
handleToolsList handlers ctx req =
case tools handlers of
Nothing -> return $ makeErrorResponse (requestId req) $ JsonRpcError
{ errorCode = -32601
, errorMessage = "Tools not supported"
, errorData = Nothing
}
Just (listHandler, _) -> do
toolsList <- listHandler ctx
let response = ToolsListResponse
{ toolsListTools = toolsList
}
return $ makeSuccessResponse (requestId req) (toJSON response)
-- | Handle tools/call request
handleToolsCall :: McpServerHandlers -> ClientContext -> JsonRpcRequest -> IO JsonRpcResponse
handleToolsCall handlers ctx req =
case tools handlers of
Nothing -> return $ makeErrorResponse (requestId req) $ JsonRpcError
{ errorCode = -32601
, errorMessage = "Tools not supported"
, errorData = Nothing
}
Just (_, callHandler) -> do
case requestParams req of
Nothing -> return $ makeErrorResponse (requestId req) $ JsonRpcError
{ errorCode = -32602
, errorMessage = "Missing parameters"
, errorData = Nothing
}
Just params ->
case fromJSON params of
Error err -> return $ makeErrorResponse (requestId req) $ JsonRpcError
{ errorCode = -32602
, errorMessage = "Invalid parameters: " <> T.pack err
, errorData = Nothing
}
Success callReq -> do
-- Tool arguments are passed through as full JSON values.
let args = fromMaybe Map.empty (toolsCallArguments callReq)
result <- callHandler ctx (toolsCallName callReq) args
case result of
Left err -> return $ makeErrorResponse (requestId req) $ JsonRpcError
{ errorCode = errorCodeFromMcpError err
, errorMessage = errorMessageFromMcpError err
, errorData = Nothing
}
Right toolRes -> do
let response = ToolsCallResponse
{ toolsCallContent = toolResultContent toolRes
, toolsCallIsError = if toolResultIsError toolRes then Just True else Nothing
, toolsCallStructuredContent = toolResultStructured toolRes
, toolsCallMeta = toolResultMeta toolRes
}
return $ makeSuccessResponse (requestId req) (toJSON response)
-- | Convert MCP error to JSON-RPC error code
errorCodeFromMcpError :: Error -> Int
errorCodeFromMcpError (InvalidPromptName _) = -32602
errorCodeFromMcpError (MissingRequiredParams _) = -32602
errorCodeFromMcpError (ResourceNotFound _) = -32602
errorCodeFromMcpError (InternalError _) = -32603
errorCodeFromMcpError (UnknownTool _) = -32602
errorCodeFromMcpError (InvalidRequest _) = -32600
errorCodeFromMcpError (MethodNotFound _) = -32601
errorCodeFromMcpError (InvalidParams _) = -32602
-- | Convert MCP error to JSON-RPC error message
errorMessageFromMcpError :: Error -> Text
errorMessageFromMcpError (InvalidPromptName msg) = "Invalid prompt name: " <> msg
errorMessageFromMcpError (MissingRequiredParams msg) = "Missing required parameters: " <> msg
errorMessageFromMcpError (ResourceNotFound msg) = "Resource not found: " <> msg
errorMessageFromMcpError (InternalError msg) = "Internal error: " <> msg
errorMessageFromMcpError (UnknownTool msg) = "Unknown tool: " <> msg
errorMessageFromMcpError (InvalidRequest msg) = "Invalid request: " <> msg
errorMessageFromMcpError (MethodNotFound msg) = "Method not found: " <> msg
errorMessageFromMcpError (InvalidParams msg) = "Invalid parameters: " <> msg