polytropos-mcp-0.1.0.2: src/Polytropos/MCP.hs
{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PolyKinds #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TypeOperators #-}
{-# OPTIONS_GHC -Wno-orphans #-}
{-|
Module : Polytropos.MCP
Description : Model Context Protocol (MCP) server integration for Polytropos
Copyright : (c) 2026 The Polytropos Authors
License : EUPL-1.2 OR Apache-2.0
Maintainer : nadia.yvette.chambers@ik.me
Stability : experimental
Portability : non-portable (GHC extensions)
Model Context Protocol (MCP) tool integration and JSON-RPC dispatch for
@polytropos@ open records and variants.
= Architectural Overview & Rationale
== The MCP Schema Problem
In standard Model Context Protocol server implementations, defining tools requires
tedious manual duplication:
1. Declaring a nominal parameter data type.
2. Deriving or writing an OpenAPI / JSON Schema definition.
3. Writing a parser that inspects the tool name string and runs the appropriate decoder.
4. Writing an error-prone dispatcher branch that invokes the handler.
== The Polytropos Solution
@polytropos-mcp@ collapses this entire pipeline into row types:
* __Tool Schemas:__ A tool's parameters are just an open record row type @r@.
'recordToTool' reifies @r@ into an OpenAPI schema directly via 'Polytropos.OpenApi'.
* __Tool Call Variant:__ The entire suite of registered tools forms an open sum
variant @Variant s@, where constructor tags match tool names and constructor
payloads match parameter rows.
* __\(O(1)\) Dispatch:__ Tool calls are parsed into @Variant s@ and dispatched
using 'Polytropos.eliminateUniform', compiling to an indirect function call
jump table without string comparisons or branch cascades.
* __Dynamic Extensibility:__ New tools can be added to the server by simply widening
the variant with 'Polytropos.widenVariant'.
= When to Use This Module
* When exposing @polytropos@ data pipelines, compilers, or database query engines
to AI coding assistants (such as Claude, Gemini, or custom agent frameworks)
via the standard Model Context Protocol.
= Worked Example
> {-# LANGUAGE DataKinds #-}
> {-# LANGUAGE OverloadedRecordDot #-}
> {-# LANGUAGE TypeApplications #-}
>
> import Data.Proxy (Proxy (..))
> import Data.Text (Text)
> import Polytropos
> import Polytropos.MCP
>
> -- 1. Define parameter rows for tools:
> type SearchArgs = '["query" ':= Text, "limit" ':= Int]
> type EchoArgs = '["message" ':= Text]
>
> -- 2. Define the tool registry as an open variant:
> type MyTools = Variant
> '[ "search" ':= SearchArgs
> , "echo" := EchoArgs
> ]
>
> -- 3. Generate tool manifests with OpenAPI schemas:
> toolList :: [McpTool]
> toolList =
> [ recordToTool @"search" "Search indexed documents" (Proxy @SearchArgs)
> , recordToTool @"echo" "Echo back a message" (Proxy @EchoArgs)
> ]
>
> -- 4. Dispatch tool calls using O(1) jump tables:
> handleCall :: MyTools -> IO [McpContent]
> handleCall = eliminateUniform REC
> { search = \args -> pure [TextContent ("Found hits for: " <> args.query)]
> , echo = \args -> pure [TextContent ("Echo: " <> args.message)]
> }
-}
module Polytropos.MCP
( -- * MCP Protocol Data Types
JsonRpcRequest (..)
, JsonRpcResponse (..)
, JsonRpcError (..)
, McpTool (..)
, CallToolParams (..)
, CallToolResult (..)
, McpContent (..)
, ServerInfo (..)
, ServerCapabilities (..)
, InitializeResult (..)
-- * Tool Schema Synthesis
, recordToTool
-- * Tool Call Parsing & Dispatch
, toolCallToVariant
, dispatchToolCall
, handleJsonRpcMessage
, runStdioServer
) where
import Control.Exception (SomeException, try)
import Data.Aeson
( FromJSON (..)
, ToJSON (..)
, Value (..)
, eitherDecode
, encode
, fromJSON
, object
, withObject
, (.:)
, (.:?)
, (.=)
)
import qualified Data.Aeson.Key as Key
import qualified Data.Aeson.KeyMap as KM
import qualified Data.Aeson.Types as Aeson
import qualified Data.ByteString.Char8 as BSC8
import qualified Data.ByteString.Lazy as LBS
import qualified Data.ByteString.Lazy.Char8 as LBC8
import Data.OpenApi (toSchema)
import Data.Proxy (Proxy (..))
import Data.Text (Text)
import qualified Data.Text as T
import Data.Typeable (Typeable)
import GHC.Generics (Generic)
import GHC.TypeLits (KnownSymbol, Symbol, symbolVal)
import System.IO (hFlush, isEOF, stdin, stdout)
import Polytropos (Record, Variant)
import Polytropos.Aeson (AllCtorsFromJSON)
import Polytropos.OpenApi (AllToSchema)
{-------------------------------------------------------------------------------
MCP Protocol Types
-------------------------------------------------------------------------------}
-- | A JSON-RPC 2.0 request or notification envelope.
data JsonRpcRequest = JsonRpcRequest
{ jsonrpcReqVersion :: !Text
, jsonrpcReqMethod :: !Text
, jsonrpcReqId :: !(Maybe Value)
, jsonrpcReqParams :: !(Maybe Value)
} deriving (Show, Eq, Generic)
instance FromJSON JsonRpcRequest where
parseJSON = withObject "JsonRpcRequest" $ \o ->
JsonRpcRequest
<$> o .: "jsonrpc"
<*> o .: "method"
<*> o .:? "id"
<*> o .:? "params"
-- | A JSON-RPC 2.0 response envelope.
data JsonRpcResponse = JsonRpcResponse
{ jsonrpcRespVersion :: !Text
, jsonrpcRespId :: !(Maybe Value)
, jsonrpcRespResult :: !(Maybe Value)
, jsonrpcRespError :: !(Maybe JsonRpcError)
} deriving (Show, Eq, Generic)
instance ToJSON JsonRpcResponse where
toJSON (JsonRpcResponse v i res err) =
object $ [ "jsonrpc" .= v, "id" .= i ]
++ maybe [] (\r -> ["result" .= r]) res
++ maybe [] (\e -> ["error" .= e]) err
instance FromJSON JsonRpcResponse where
parseJSON = withObject "JsonRpcResponse" $ \o ->
JsonRpcResponse
<$> o .: "jsonrpc"
<*> o .:? "id"
<*> o .:? "result"
<*> o .:? "error"
-- | A JSON-RPC 2.0 protocol error object.
data JsonRpcError = JsonRpcError
{ jsonrpcErrorCode :: !Int
, jsonrpcErrorMessage :: !Text
, jsonrpcErrorData :: !(Maybe Value)
} deriving (Show, Eq, Generic)
instance ToJSON JsonRpcError where
toJSON (JsonRpcError c m d) =
object $ [ "code" .= c, "message" .= m ]
++ maybe [] (\x -> ["data" .= x]) d
instance FromJSON JsonRpcError where
parseJSON = withObject "JsonRpcError" $ \o ->
JsonRpcError
<$> o .: "code"
<*> o .: "message"
<*> o .:? "data"
-- | An MCP content item returned by a tool execution.
data McpContent
= TextContent !Text
| ImageContent !Text !Text -- ^ base64 data, mimeType
| ResourceContent !Text !(Maybe Text) -- ^ uri, optional text
deriving (Show, Eq, Generic)
instance ToJSON McpContent where
toJSON (TextContent txt) =
object [ "type" .= ("text" :: Text), "text" .= txt ]
toJSON (ImageContent d m) =
object [ "type" .= ("image" :: Text), "data" .= d, "mimeType" .= m ]
toJSON (ResourceContent u mt) =
object $ [ "type" .= ("resource" :: Text), "uri" .= u ]
++ maybe [] (\t -> ["text" .= t]) mt
instance FromJSON McpContent where
parseJSON = withObject "McpContent" $ \o -> do
t <- o .: "type"
case (t :: Text) of
"text" -> TextContent <$> o .: "text"
"image" -> ImageContent <$> o .: "data" <*> o .: "mimeType"
"resource" -> ResourceContent <$> o .: "uri" <*> o .:? "text"
_ -> fail ("unknown McpContent type: " ++ T.unpack t)
-- | Result of an MCP @tools/call@ execution.
data CallToolResult = CallToolResult
{ callToolContent :: ![McpContent]
, callToolIsError :: !(Maybe Bool)
} deriving (Show, Eq, Generic)
instance ToJSON CallToolResult where
toJSON (CallToolResult c e) =
object $ [ "content" .= c ]
++ maybe [] (\b -> ["isError" .= b]) e
instance FromJSON CallToolResult where
parseJSON = withObject "CallToolResult" $ \o ->
CallToolResult
<$> o .: "content"
<*> o .:? "isError"
-- | Parameters passed in an MCP @tools/call@ request.
data CallToolParams = CallToolParams
{ callToolName :: !Text
, callToolArguments :: !(Maybe Value)
} deriving (Show, Eq, Generic)
instance FromJSON CallToolParams where
parseJSON = withObject "CallToolParams" $ \o ->
CallToolParams
<$> o .: "name"
<*> o .:? "arguments"
-- | An MCP Tool specification describing a callable tool.
data McpTool = McpTool
{ toolName :: !Text
, toolDescription :: !Text
, toolInputSchema :: !Value
} deriving (Show, Eq, Generic)
instance ToJSON McpTool where
toJSON (McpTool n d s) =
object [ "name" .= n, "description" .= d, "inputSchema" .= s ]
instance FromJSON McpTool where
parseJSON = withObject "McpTool" $ \o ->
McpTool
<$> o .: "name"
<*> o .: "description"
<*> o .: "inputSchema"
-- | Server information reported during the @initialize@ handshake.
data ServerInfo = ServerInfo
{ serverName :: !Text
, serverVersion :: !Text
} deriving (Show, Eq, Generic)
instance ToJSON ServerInfo where
toJSON (ServerInfo n v) =
object [ "name" .= n, "version" .= v ]
-- | Server capabilities announced during initialization.
data ServerCapabilities = ServerCapabilities
{ capTools :: !(Maybe Value)
} deriving (Show, Eq, Generic)
instance ToJSON ServerCapabilities where
toJSON (ServerCapabilities t) =
object $ maybe [] (\x -> ["tools" .= x]) t
-- | The payload returned to the client upon a successful @initialize@ handshake.
data InitializeResult = InitializeResult
{ initProtocolVersion :: !Text
, initCapabilities :: !ServerCapabilities
, initServerInfo :: !ServerInfo
} deriving (Show, Eq, Generic)
instance ToJSON InitializeResult where
toJSON (InitializeResult p c s) =
object
[ "protocolVersion" .= p
, "capabilities" .= c
, "serverInfo" .= s
]
{-------------------------------------------------------------------------------
Tool Schema Synthesis
-------------------------------------------------------------------------------}
-- | Synthesizes an MCP @t'McpTool'@ definition from an open record row type @r@.
-- The tool's parameter schema is generated via 'Polytropos.OpenApi.toSchema'.
recordToTool
:: forall (name :: Symbol) r.
(KnownSymbol name, Typeable r, AllToSchema r)
=> Text -- ^ Human-readable tool description
-> Proxy r -- ^ Parameter row proxy
-> McpTool
recordToTool desc _ =
McpTool
{ toolName = T.pack (symbolVal (Proxy @name))
, toolDescription = desc
, toolInputSchema = toJSON (toSchema (Proxy @(Record r)))
}
{-------------------------------------------------------------------------------
Tool Call Parsing & Dispatch
-------------------------------------------------------------------------------}
-- | Parses tool call arguments into a Polytropos 'Variant' sum row @s@.
-- Constructs a tagged singleton object @{"toolName": arguments}@ and decodes
-- via 'Polytropos.Aeson.AllCtorsFromJSON'.
toolCallToVariant
:: forall s. (AllCtorsFromJSON s)
=> Text -- ^ Invoked tool name
-> Value -- ^ JSON argument object
-> Either String (Variant s)
toolCallToVariant tName args =
let taggedObj = Object (KM.singleton (Key.fromText tName) args)
in case fromJSON taggedObj of
Aeson.Success v -> Right v
Aeson.Error err -> Left ("Invalid arguments for tool '" <> T.unpack tName <> "': " <> err)
-- | Dispatches a tool execution request by decoding into @Variant s@ and
-- invoking the provided handler (typically constructed via 'Polytropos.eliminateUniform').
dispatchToolCall
:: forall s. (AllCtorsFromJSON s)
=> (Variant s -> IO [McpContent])
-> Text
-> Value
-> IO CallToolResult
dispatchToolCall handler tName args =
case toolCallToVariant @s tName args of
Left parseErr ->
pure CallToolResult
{ callToolContent = [TextContent (T.pack parseErr)]
, callToolIsError = Just True
}
Right variantVal -> do
res <- try (handler variantVal)
case res of
Left (ex :: SomeException) ->
pure CallToolResult
{ callToolContent = [TextContent ("Tool execution failed: " <> T.pack (show ex))]
, callToolIsError = Just True
}
Right contents ->
pure CallToolResult
{ callToolContent = contents
, callToolIsError = Nothing
}
-- | Handles a single incoming JSON-RPC raw message line, producing a response
-- (or 'Nothing' for notifications that expect no reply).
handleJsonRpcMessage
:: ServerInfo
-> [McpTool]
-> (Text -> Value -> IO CallToolResult)
-> LBS.ByteString
-> IO (Maybe LBS.ByteString)
handleJsonRpcMessage sInfo tools toolDispatcher rawMsg =
case eitherDecode rawMsg of
Left parseErr ->
pure $ Just $ encode $ JsonRpcResponse
{ jsonrpcRespVersion = "2.0"
, jsonrpcRespId = Nothing
, jsonrpcRespResult = Nothing
, jsonrpcRespError = Just $ JsonRpcError (-32700) ("Parse error: " <> T.pack parseErr) Nothing
}
Right (JsonRpcRequest _ "initialize" reqId _) ->
pure $ Just $ encode $ JsonRpcResponse
{ jsonrpcRespVersion = "2.0"
, jsonrpcRespId = reqId
, jsonrpcRespResult = Just $ toJSON $ InitializeResult
{ initProtocolVersion = "2024-11-05"
, initCapabilities = ServerCapabilities (Just (object ["listChanged" .= False]))
, initServerInfo = sInfo
}
, jsonrpcRespError = Nothing
}
Right (JsonRpcRequest _ "notifications/initialized" _ _) ->
pure Nothing
Right (JsonRpcRequest _ "ping" reqId _) ->
pure $ Just $ encode $ JsonRpcResponse
{ jsonrpcRespVersion = "2.0"
, jsonrpcRespId = reqId
, jsonrpcRespResult = Just (object [])
, jsonrpcRespError = Nothing
}
Right (JsonRpcRequest _ "tools/list" reqId _) ->
pure $ Just $ encode $ JsonRpcResponse
{ jsonrpcRespVersion = "2.0"
, jsonrpcRespId = reqId
, jsonrpcRespResult = Just $ object [ "tools" .= tools ]
, jsonrpcRespError = Nothing
}
Right (JsonRpcRequest _ "tools/call" reqId mParams) -> do
case mParams of
Just pVal ->
case fromJSON pVal of
Aeson.Success (CallToolParams tName mArgs) -> do
let args = maybe (Object KM.empty) id mArgs
res <- toolDispatcher tName args
pure $ Just $ encode $ JsonRpcResponse
{ jsonrpcRespVersion = "2.0"
, jsonrpcRespId = reqId
, jsonrpcRespResult = Just (toJSON res)
, jsonrpcRespError = Nothing
}
Aeson.Error err ->
pure $ Just $ encode $ JsonRpcResponse
{ jsonrpcRespVersion = "2.0"
, jsonrpcRespId = reqId
, jsonrpcRespResult = Nothing
, jsonrpcRespError = Just $ JsonRpcError (-32602) ("Invalid params: " <> T.pack err) Nothing
}
Nothing ->
pure $ Just $ encode $ JsonRpcResponse
{ jsonrpcRespVersion = "2.0"
, jsonrpcRespId = reqId
, jsonrpcRespResult = Nothing
, jsonrpcRespError = Just $ JsonRpcError (-32602) "Missing params for tools/call" Nothing
}
Right (JsonRpcRequest _ method reqId _) ->
pure $ Just $ encode $ JsonRpcResponse
{ jsonrpcRespVersion = "2.0"
, jsonrpcRespId = reqId
, jsonrpcRespResult = Nothing
, jsonrpcRespError = Just $ JsonRpcError (-32601) ("Method not found: " <> method) Nothing
}
-- | Runs an MCP server over standard input/output using newline-delimited JSON-RPC messages.
runStdioServer
:: ServerInfo
-> [McpTool]
-> (Text -> Value -> IO CallToolResult)
-> IO ()
runStdioServer sInfo tools toolDispatcher = loop
where
loop = do
done <- isEOF
if done
then pure ()
else do
raw <- BSC8.hGetLine stdin
let line = LBS.fromStrict raw
if LBS.null line
then loop
else do
mResp <- handleJsonRpcMessage sInfo tools toolDispatcher line
case mResp of
Just resp -> do
LBC8.putStrLn resp
hFlush stdout
Nothing -> pure ()
loop