packages feed

mcp-server 0.3.0.0 → 0.3.0.1

raw patch · 51 files changed

+2929/−235 lines, 51 filesdep +cryptondep +ramdep +timedep ~basedep ~template-haskelldep ~textnew-component:exe:ask-examplePVP: major bump suggested

API removals or changes: PVP suggests a major version bump

Dependencies added: crypton, ram, time

Dependency ranges changed: base, template-haskell, text

API changes (from Hackage documentation)

+ MCP.Server: StateCodec :: (ByteString -> IO Text) -> (Text -> IO (Either StateError ByteString)) -> NominalDiffTime -> StateCodec
+ MCP.Server: StateInvalid :: StateError
+ MCP.Server: StateUnknownKey :: StateError
+ MCP.Server: [httpStateCodec] :: HttpConfig -> Maybe StateCodec
+ MCP.Server: [openState] :: StateCodec -> Text -> IO (Either StateError ByteString)
+ MCP.Server: [sealState] :: StateCodec -> ByteString -> IO Text
+ MCP.Server: [stateLifetime] :: StateCodec -> NominalDiffTime
+ MCP.Server: [stdioStateCodec] :: StdioConfig -> Maybe StateCodec
+ MCP.Server: aeadStateCodec :: StateKey -> [StateKey] -> StateCodec
+ MCP.Server: data StateCodec
+ MCP.Server: data StateError
+ MCP.Server: data StateKey
+ MCP.Server: defaultStateLifetime :: NominalDiffTime
+ MCP.Server: ephemeralStateCodec :: IO StateCodec
+ MCP.Server: stateKey :: Text -> ByteString -> Maybe StateKey
+ MCP.Server: stateKeyId :: StateKey -> Text
+ MCP.Server.Ask: ($dmformParse) :: (Form a, FromJSON a) => Map Text Value -> Either Text a
+ MCP.Server.Ask: Accepted :: a -> Answer a
+ MCP.Server.Ask: Cancelled :: Answer a
+ MCP.Server.Ask: Declined :: Answer a
+ MCP.Server.Ask: Unavailable :: Answer a
+ MCP.Server.Ask: askContext :: Ask ClientContext
+ MCP.Server.Ask: class Form a
+ MCP.Server.Ask: class Monad m => HandlerMonad (m :: Type -> Type)
+ MCP.Server.Ask: data Answer a
+ MCP.Server.Ask: data Ask a
+ MCP.Server.Ask: elicit :: Form a => Text -> Ask (Answer a)
+ MCP.Server.Ask: elicitUrl :: Text -> URI -> Ask (Answer ())
+ MCP.Server.Ask: formParse :: Form a => Map Text Value -> Either Text a
+ MCP.Server.Ask: formSchema :: Form a => Proxy a -> Schema
+ MCP.Server.Ask: instance Control.Monad.IO.Class.MonadIO MCP.Server.Ask.Ask
+ MCP.Server.Ask: instance Data.Aeson.Types.FromJSON.FromJSON MCP.Server.Ask.Entry
+ MCP.Server.Ask: instance Data.Aeson.Types.ToJSON.ToJSON MCP.Server.Ask.Entry
+ MCP.Server.Ask: instance GHC.Classes.Eq a => GHC.Classes.Eq (MCP.Server.Ask.Answer a)
+ MCP.Server.Ask: instance GHC.Internal.Base.Applicative MCP.Server.Ask.Ask
+ MCP.Server.Ask: instance GHC.Internal.Base.Functor MCP.Server.Ask.Answer
+ MCP.Server.Ask: instance GHC.Internal.Base.Functor MCP.Server.Ask.Ask
+ MCP.Server.Ask: instance GHC.Internal.Base.Monad MCP.Server.Ask.Ask
+ MCP.Server.Ask: instance GHC.Internal.Show.Show a => GHC.Internal.Show.Show (MCP.Server.Ask.Answer a)
+ MCP.Server.Ask: instance MCP.Server.Ask.HandlerMonad GHC.Types.IO
+ MCP.Server.Ask: instance MCP.Server.Ask.HandlerMonad MCP.Server.Ask.Ask
+ MCP.Server.Ask: runAsk :: Ask a -> ClientContext -> IO (Either Error (Outcome a))
+ MCP.Server.Ask: runHandler :: HandlerMonad m => ClientContext -> m a -> IO (Either Error (Outcome a))
+ MCP.Server.Ask: step :: (ToJSON a, FromJSON a) => IO a -> Ask a
+ MCP.Server.Derive: Array :: !Array -> Value
+ MCP.Server.Derive: Bool :: !Bool -> Value
+ MCP.Server.Derive: FormFieldOptions :: Maybe Text -> Maybe Text -> Maybe Value -> FormFieldOptions
+ MCP.Server.Derive: Null :: Value
+ MCP.Server.Derive: Number :: !Scientific -> Value
+ MCP.Server.Derive: Object :: !Object -> Value
+ MCP.Server.Derive: String :: !Text -> Value
+ MCP.Server.Derive: [formFieldDefault] :: FormFieldOptions -> Maybe Value
+ MCP.Server.Derive: [formFieldDescription] :: FormFieldOptions -> Maybe Text
+ MCP.Server.Derive: [formFieldTitle] :: FormFieldOptions -> Maybe Text
+ MCP.Server.Derive: data FormFieldOptions
+ MCP.Server.Derive: data Value
+ MCP.Server.Derive: defaultFormFieldOptions :: FormFieldOptions
+ MCP.Server.Derive: deriveForm :: Name -> Q [Dec]
+ MCP.Server.Derive: deriveFormWithDescription :: Name -> [(String, String)] -> Q [Dec]
+ MCP.Server.Derive: deriveFormWithOptions :: Name -> [(String, FormFieldOptions)] -> Q [Dec]
+ MCP.Server.Derive: instance GHC.Classes.Eq MCP.Server.Derive.FormFieldOptions
+ MCP.Server.Derive: instance GHC.Internal.Show.Show MCP.Server.Derive.FormFieldOptions
+ MCP.Server.RequestState: StateCodec :: (ByteString -> IO Text) -> (Text -> IO (Either StateError ByteString)) -> NominalDiffTime -> StateCodec
+ MCP.Server.RequestState: StateInvalid :: StateError
+ MCP.Server.RequestState: StateUnknownKey :: StateError
+ MCP.Server.RequestState: [openState] :: StateCodec -> Text -> IO (Either StateError ByteString)
+ MCP.Server.RequestState: [sealState] :: StateCodec -> ByteString -> IO Text
+ MCP.Server.RequestState: [stateLifetime] :: StateCodec -> NominalDiffTime
+ MCP.Server.RequestState: aeadStateCodec :: StateKey -> [StateKey] -> StateCodec
+ MCP.Server.RequestState: data StateCodec
+ MCP.Server.RequestState: data StateError
+ MCP.Server.RequestState: data StateKey
+ MCP.Server.RequestState: defaultStateLifetime :: NominalDiffTime
+ MCP.Server.RequestState: ephemeralStateCodec :: IO StateCodec
+ MCP.Server.RequestState: instance GHC.Classes.Eq MCP.Server.RequestState.StateError
+ MCP.Server.RequestState: instance GHC.Internal.Show.Show MCP.Server.RequestState.StateError
+ MCP.Server.RequestState: stateKey :: Text -> ByteString -> Maybe StateKey
+ MCP.Server.RequestState: stateKeyId :: StateKey -> Text
+ MCP.Server.Transport.Http: [httpStateCodec] :: HttpConfig -> Maybe StateCodec
+ MCP.Server.Transport.Stdio: [stdioStateCodec] :: StdioConfig -> Maybe StateCodec
+ MCP.Server.Types: Complete :: a -> Outcome a
+ MCP.Server.Types: ElicitAccept :: ElicitAction
+ MCP.Server.Types: ElicitCancel :: ElicitAction
+ MCP.Server.Types: ElicitDecline :: ElicitAction
+ MCP.Server.Types: ElicitForm :: Text -> Schema -> InputRequest
+ MCP.Server.Types: ElicitResponse :: ElicitAction -> Maybe (Map Text Value) -> InputResponse
+ MCP.Server.Types: ElicitUrl :: Text -> URI -> InputRequest
+ MCP.Server.Types: InputRequired :: Map Text InputRequest -> Maybe Text -> Outcome a
+ MCP.Server.Types: RawInputRequest :: Text -> Value -> InputRequest
+ MCP.Server.Types: RawInputResponse :: Value -> InputResponse
+ MCP.Server.Types: [clientInputResponses] :: ClientContext -> Map Text InputResponse
+ MCP.Server.Types: [clientRequestState] :: ClientContext -> Maybe Text
+ MCP.Server.Types: clientSupportsInput :: ClientContext -> InputRequest -> Bool
+ MCP.Server.Types: data ElicitAction
+ MCP.Server.Types: data InputRequest
+ MCP.Server.Types: data InputResponse
+ MCP.Server.Types: data Outcome a
+ MCP.Server.Types: instance Data.Aeson.Types.FromJSON.FromJSON MCP.Server.Types.InputResponse
+ MCP.Server.Types: instance Data.Aeson.Types.ToJSON.ToJSON MCP.Server.Types.InputRequest
+ MCP.Server.Types: instance GHC.Classes.Eq MCP.Server.Types.ElicitAction
+ MCP.Server.Types: instance GHC.Classes.Eq MCP.Server.Types.InputRequest
+ MCP.Server.Types: instance GHC.Classes.Eq MCP.Server.Types.InputResponse
+ MCP.Server.Types: instance GHC.Classes.Eq a => GHC.Classes.Eq (MCP.Server.Types.Outcome a)
+ MCP.Server.Types: instance GHC.Classes.Ord MCP.Server.Types.ElicitAction
+ MCP.Server.Types: instance GHC.Internal.Base.Functor MCP.Server.Types.Outcome
+ MCP.Server.Types: instance GHC.Internal.Enum.Bounded MCP.Server.Types.ElicitAction
+ MCP.Server.Types: instance GHC.Internal.Enum.Enum MCP.Server.Types.ElicitAction
+ MCP.Server.Types: instance GHC.Internal.Show.Show MCP.Server.Types.ElicitAction
+ MCP.Server.Types: instance GHC.Internal.Show.Show MCP.Server.Types.InputRequest
+ MCP.Server.Types: instance GHC.Internal.Show.Show MCP.Server.Types.InputResponse
+ MCP.Server.Types: instance GHC.Internal.Show.Show a => GHC.Internal.Show.Show (MCP.Server.Types.Outcome a)
- MCP.Server: HttpConfig :: Int -> String -> String -> Bool -> Maybe (Maybe Text -> IO (Maybe Value)) -> Maybe [Text] -> CacheHints -> Maybe NotificationSource -> HttpConfig
+ MCP.Server: HttpConfig :: Int -> String -> String -> Bool -> Maybe (Maybe Text -> IO (Maybe Value)) -> Maybe [Text] -> CacheHints -> Maybe StateCodec -> Maybe NotificationSource -> HttpConfig
- MCP.Server: StdioConfig :: Bool -> CacheHints -> Maybe NotificationSource -> StdioConfig
+ MCP.Server: StdioConfig :: Bool -> CacheHints -> Maybe StateCodec -> Maybe NotificationSource -> StdioConfig
- MCP.Server: mcpApplication :: HttpConfig -> McpServerInfo -> McpServerHandlers -> Application
+ MCP.Server: mcpApplication :: HttpConfig -> McpServerInfo -> McpServerHandlers -> IO Application
- MCP.Server.Handlers: handleMcpMessage :: McpServerInfo -> CacheHints -> NotificationSupport -> (JsonRpcNotification -> IO ()) -> McpServerHandlers -> ClientContext -> JsonRpcMessage -> IO (Maybe JsonRpcMessage)
+ MCP.Server.Handlers: handleMcpMessage :: McpServerInfo -> CacheHints -> StateCodec -> NotificationSupport -> (JsonRpcNotification -> IO ()) -> McpServerHandlers -> ClientContext -> JsonRpcMessage -> IO (Maybe JsonRpcMessage)
- MCP.Server.Handlers: handlePromptsGet :: McpServerHandlers -> ClientContext -> JsonRpcRequest -> IO JsonRpcResponse
+ MCP.Server.Handlers: handlePromptsGet :: StateCodec -> McpServerHandlers -> ClientContext -> JsonRpcRequest -> IO JsonRpcResponse
- MCP.Server.Handlers: handleResourcesRead :: McpServerHandlers -> ClientContext -> JsonRpcRequest -> IO JsonRpcResponse
+ MCP.Server.Handlers: handleResourcesRead :: StateCodec -> McpServerHandlers -> ClientContext -> JsonRpcRequest -> IO JsonRpcResponse
- MCP.Server.Handlers: handleToolsCall :: McpServerHandlers -> ClientContext -> JsonRpcRequest -> IO JsonRpcResponse
+ MCP.Server.Handlers: handleToolsCall :: StateCodec -> McpServerHandlers -> ClientContext -> JsonRpcRequest -> IO JsonRpcResponse
- MCP.Server.Transport.Http: HttpConfig :: Int -> String -> String -> Bool -> Maybe (Maybe Text -> IO (Maybe Value)) -> Maybe [Text] -> CacheHints -> Maybe NotificationSource -> HttpConfig
+ MCP.Server.Transport.Http: HttpConfig :: Int -> String -> String -> Bool -> Maybe (Maybe Text -> IO (Maybe Value)) -> Maybe [Text] -> CacheHints -> Maybe StateCodec -> Maybe NotificationSource -> HttpConfig
- MCP.Server.Transport.Http: mcpApplication :: HttpConfig -> McpServerInfo -> McpServerHandlers -> Application
+ MCP.Server.Transport.Http: mcpApplication :: HttpConfig -> McpServerInfo -> McpServerHandlers -> IO Application
- MCP.Server.Transport.Stdio: StdioConfig :: Bool -> CacheHints -> Maybe NotificationSource -> StdioConfig
+ MCP.Server.Transport.Stdio: StdioConfig :: Bool -> CacheHints -> Maybe StateCodec -> Maybe NotificationSource -> StdioConfig
- MCP.Server.Types: ClientContext :: Maybe Text -> Maybe Value -> Maybe Text -> Maybe Value -> Maybe Value -> (Double -> Maybe Double -> Maybe Text -> IO ()) -> (LogLevel -> Value -> IO ()) -> ClientContext
+ MCP.Server.Types: ClientContext :: Maybe Text -> Maybe Value -> Maybe Text -> Maybe Value -> Maybe Value -> (Double -> Maybe Double -> Maybe Text -> IO ()) -> (LogLevel -> Value -> IO ()) -> Map Text InputResponse -> Maybe Text -> ClientContext
- MCP.Server.Types: type PromptGetHandler = ClientContext -> PromptName -> Map Text Text -> IO Either Error PromptResult
+ MCP.Server.Types: type PromptGetHandler = ClientContext -> PromptName -> Map Text Text -> IO Either Error Outcome PromptResult
- MCP.Server.Types: type ResourceReadHandler = ClientContext -> URI -> IO Either Error ResourceContent
+ MCP.Server.Types: type ResourceReadHandler = ClientContext -> URI -> IO Either Error Outcome ResourceContent
- MCP.Server.Types: type ToolCallHandler = ClientContext -> ToolName -> Map Text Value -> IO Either Error ToolResult
+ MCP.Server.Types: type ToolCallHandler = ClientContext -> ToolName -> Map Text Value -> IO Either Error Outcome ToolResult

Files

CHANGELOG.md view
@@ -1,5 +1,92 @@ # Revision history for mcp-server +## 0.3.0.1 - 2026-10-20++Handlers can ask the user for input part-way through a request: the+multi round-trip requests of MCP 2026-07-28 (ADR 0009).++### Upgrading from 0.3.0.0++* Derived handlers (`derive*Handler`) need no changes.+* Hand-written `ToolCallHandler`, `PromptGetHandler` and+  `ResourceReadHandler` functions return `Outcome`: wrap results in+  `Complete`, e.g. `Right (Complete result)`.+* Code that builds a `ClientContext` by hand should start from+  `anonymousContext` and record-update it; the type gained fields.+* `mcpApplication` returns `IO Application`: bind it before handing it+  to Warp.+* Code that calls `handleMcpMessage` directly passes a `StateCodec` after+  the `CacheHints`; `handlePromptsGet`, `handleResourcesRead` and+  `handleToolsCall` take one as their first argument. One from+  `ephemeralStateCodec` will do.+* `MCP.Server` now exports the names of `MCP.Server.Ask` (`Ask`, `elicit`,+  `elicitUrl`, `step`, `Form`, `Answer(..)`, `HandlerMonad`, `runAsk`,+  `askContext`): hide or qualify them where they clash with your own.+* `MCP.Server.Derive` now exports aeson's `Value`, so a module that also+  imports `Value` from `Data.Aeson` may get a redundant-import warning;+  drop one of the two.++### Asking for input++* Derived handlers can ask: the `derive*Handler` functions accept+  handlers returning `Ask` as well as `IO` (through the new+  `HandlerMonad` class), so a handler starts asking by changing its+  type. `elicit` asks for a form and `elicitUrl` sends the user to a URL;+  the answer is `Accepted`, `Declined`, `Cancelled` or, without a round+  trip when the client cannot ask, `Unavailable`.+* `deriveForm`, `deriveFormWithDescription` and `deriveFormWithOptions`+  (labels and defaults, via `FormFieldOptions`) derive the new `Form`+  class from flat records. Fields a form cannot hold, options for unknown+  fields and defaults that do not fit their field are compile-time+  errors. `MCP.Server.Derive` re-exports aeson's `Value` for writing+  defaults.+* The `Ask` monad (new module `MCP.Server.Ask`, re-exported by+  `MCP.Server`) runs a handler from the start on every round against a+  journal of earlier rounds: answers and `step` results are recorded once+  and replayed. A handler that asks something different at a position+  fails rather than receiving the wrong answer. `runAsk` runs one from a+  hand-written handler.+* Low level: call handlers return `Outcome` (`Complete`, or+  `InputRequired` with requests and state), answered as an+  `input_required` result. Retries carry the answers in+  `clientInputResponses` and the handler's state in+  `clientRequestState`; `clientSupportsInput` checks the client's+  protocol revision and declared capabilities.+* The `requestState` sent to clients is sealed by a `StateCodec` (new+  module `MCP.Server.RequestState`): ChaCha20-Poly1305, bound to the+  caller, the request's parameters and an expiry (60 minutes by+  default), and verified before the handler runs. The default key is+  random per process; multi-instance HTTP deployments set a shared key+  with `httpStateCodec` and `aeadStateCodec`, which supports rotation.+  `stdioStateCodec` does the same for stdio.+* New example `ask-example` (`examples/Ask`): a table-booking server whose+  tools ask the user for details and confirmation.++### Other changes++* `deriveToolHandlerWithOutput` and its variants no longer need+  `toJSON`, `object` and `.=` from `Data.Aeson` in scope where they are+  spliced: the generated serializer looked those names up in the user's+  module.+* Documentation: every module has a header and every export a doc+  comment, and Haddock links that were ambiguous or out of scope are+  fixed.+* New dependencies: `crypton` (1.1.4 to 2.2), `ram` and `time`.++### Breaking changes, in full++* `ToolCallHandler`, `PromptGetHandler` and `ResourceReadHandler` return+  `Outcome`.+* `ClientContext` gains `clientInputResponses` and `clientRequestState`;+  `StdioConfig` and `HttpConfig` gain `stdioStateCodec` and+  `httpStateCodec`.+* `handleMcpMessage`, `handlePromptsGet`, `handleResourcesRead` and+  `handleToolsCall` take a `StateCodec`.+* `mcpApplication` returns `IO Application`, creating the default key+  once.+* `MCP.Server` exports `MCP.Server.Ask`, and `MCP.Server.Derive` exports+  aeson's `Value`: new names that can clash with or duplicate yours.+ ## 0.3.0.0 - 2026-10-16  * Raw JSON Schema keywords (ADR 0014): `SchemaRaw` is a new `SchemaType`
README.md view
@@ -45,7 +45,7 @@  The derivation reads your types, so the schema on the wire always matches the handler that receives the arguments. Given this constructor from-[`examples/Complete`](examples/Complete/Types.hs):+[`examples/Complete`](https://github.com/drshade/haskell-mcp-server/blob/main/examples/Complete/Types.hs):  ```haskell data ShippingSpeed = Standard | Express | Overnight@@ -61,7 +61,8 @@     | ... ``` -`tools/list` returns exactly this (captured from the running example):+`tools/list` returns exactly this (captured from the running example; a+field without a description is described by its name):  ```json {@@ -72,14 +73,16 @@     "type": "object",     "required": ["speed", "shipTo"],     "properties": {-      "speed":  { "type": "string", "enum": ["standard", "express", "overnight"] },+      "speed":  { "type": "string", "enum": ["standard", "express", "overnight"],+                  "description": "speed" },       "shipTo": {         "type": "object",+        "description": "shipTo",         "required": ["street", "city"],         "properties": {-          "street":  { "type": "string" },-          "city":    { "type": "string" },-          "zipCode": { "type": "string" }+          "street":  { "type": "string", "description": "street" },+          "city":    { "type": "string", "description": "city" },+          "zipCode": { "type": "string", "description": "zipCode" }         }       }     }@@ -175,7 +178,7 @@  Claude Desktop launches stdio servers from `claude_desktop_config.json`. A Docker image is a convenient way to ship a Haskell binary to it — the-repository's [`Dockerfile`](Dockerfile) builds all three examples:+repository's [`Dockerfile`](https://github.com/drshade/haskell-mcp-server/blob/main/Dockerfile) builds all the examples:  ```bash docker build -t haskell-mcp-server .@@ -228,7 +231,7 @@ recursively until a record is found:  ```haskell-data SetValueParams = SetValueParams { key :: Text, value :: Text }+data SetValueParams = SetValueParams { setKey :: Text, setValue :: Text }  data SimpleTool     = GetValue { key :: Text }@@ -292,7 +295,7 @@  ### Descriptions, annotations and icons -Every `derive*` function has a `WithDescription` variant taking a flat list of+Every `derive*Handler` and `deriveResourceTemplates` function has a `WithDescription` variant taking a flat list of constructor and field descriptions, and a `WithOptions` variant taking per-constructor `DefinitionOptions`: description, title, icons, behavioral annotations (which drive client permission UX, such as auto-approving@@ -400,7 +403,7 @@  - A `derive*` splice can only see types declared in an earlier declaration   group. Either put the types in their own module (as the-  [examples](examples/) do) or end the group with an empty `$(pure [])`+  [examples](https://github.com/drshade/haskell-mcp-server/tree/main/examples/) do) or end the group with an empty `$(pure [])`   splice before the `main` that uses them. - Every handler receives the per-request `ClientContext` first. It carries the   caller's bearer token and principal on HTTP, the protocol revision and@@ -415,11 +418,15 @@  ```haskell promptListHandler :: ClientContext -> IO [PromptDefinition]-promptGetHandler  :: ClientContext -> PromptName -> Map Text Text -> IO (Either Error PromptResult)+promptGetHandler  :: ClientContext -> PromptName -> Map Text Text -> IO (Either Error (Outcome PromptResult))  handlers = noHandlers { prompts = Just (promptListHandler, promptGetHandler) } ``` +`tools/call`, `prompts/get` and `resources/read` handlers return an+`Outcome`: `Complete result`, or a request for input (see+[Asking the user for input](#asking-the-user-for-input)).+ Tool schemas built by hand can use `SchemaRaw` for anything the typed shapes cannot express — `null`, `anyOf`, `$defs` and `$ref`, numeric bounds — and it is serialized as given:@@ -450,7 +457,7 @@ ### Streamable HTTP  ```haskell-import MCP.Server.Transport.Http+import MCP.Server  main = runMcpServerHttp serverInfo handlers            -- localhost:3000/mcp @@ -495,13 +502,17 @@ import MCP.Server (mcpApplication, defaultHttpConfig) import qualified Network.Wai.Handler.Warp as Warp -main = Warp.runSettings mySettings $ \req respond ->-    -- route /mcp to the MCP endpoint, everything else to your app-    mcpApplication defaultHttpConfig serverInfo handlers req respond+main = do+    mcp <- mcpApplication defaultHttpConfig serverInfo handlers+    Warp.runSettings mySettings $ \req respond ->+        -- route /mcp to the MCP endpoint, everything else to your app+        mcp req respond ``` -`httpPort` and `httpHost` are ignored when embedding; the endpoint path,-Origin validation, bearer auth and streaming all apply as usual.+Build the application once: it holds the key that protects multi+round-trip state. `httpPort` and `httpHost` are ignored when embedding; the+endpoint path, Origin validation, bearer auth and streaming all apply as+usual.  ## Long-running tools @@ -559,6 +570,150 @@ Requests are served concurrently on both transports. Handlers touching shared mutable state must synchronize with `MVar`, `STM` or similar. +## Asking the user for input++A tool, prompt or resource handler can stop part-way through and ask the+user something: a form to fill in, or a URL to visit. This uses the+2026-07-28 multi round-trip requests. Give the handler the type `Ask`+instead of `IO`, derive forms from the records you want back, and write+the questions in order, as if the handler could wait for the answers:++```haskell+data Environment = Staging | Production++data DeployTarget = DeployTarget+  { environment :: Environment     -- a choice: staging, production+  , reason      :: Text+  , notify      :: Maybe Bool      -- optional+  }++newtype Confirm = Confirm { proceed :: Bool }++data OpsTool = Deploy { service :: Text } | Status { service :: Text }++$(deriveFormWithDescription ''DeployTarget [("reason", "Why deploy now?")])+$(deriveForm ''Confirm)++handleTool :: ClientContext -> OpsTool -> Ask Content+handleTool _ (Deploy svc) = do+  target <- elicit ("Where should " <> svc <> " deploy to?")+  case target of+    Accepted t -> do+      plan <- step (planDeploy svc (environment t))      -- runs once+      ok   <- elicit ("Apply " <> describePlan plan <> "?")+      case ok of+        Accepted (Confirm True) -> ContentText <$> step (applyPlan plan)+        _                       -> pure (ContentText "Deployment cancelled")+    Unavailable -> pure (ContentText "This client cannot ask where to deploy")+    _           -> pure (ContentText "No target chosen")+handleTool _ (Status svc) = ContentText <$> liftIO (currentStatus svc)++tools = Just $(deriveToolHandler ''OpsTool 'handleTool)+```++`elicit` picks the form from the type the code expects back, so+`Accepted (Confirm True)` is all it takes to ask the `Confirm` form. The+answer is `Accepted` with the submitted record, `Declined` or `Cancelled`,+or `Unavailable`, without a round trip, when the client cannot ask: it+speaks an older protocol revision or did not declare form elicitation.+`elicitUrl` sends the user to a URL instead.++The `derive*Handler` functions take `IO` and `Ask` handlers alike, so a+handler switches by changing its type. A tool that never asks, like+`Status`, finishes in one round; plain IO runs in `Ask` through `liftIO`.+Structured output works the same way: return `Ask (ToolOutput a)`.++### Forms++`deriveForm` builds the form's schema and its parser from a record.+Elicitation forms are flat, so the fields can be `Text`, `Int`, `Integer`,+`Double`, `Float`, `Bool`, enumerations (offered as snake_cased choices),+lists of enumerations (several choices) and `Maybe` of these for optional+fields. Anything else, such as a nested record, is a compile-time error+naming the field. `deriveFormWithDescription` adds help text per field, and+`deriveFormWithOptions` also sets labels and starting values:++```haskell+$(deriveFormWithOptions ''DeployTarget+    [ ("environment", defaultFormFieldOptions+        { formFieldTitle = Just "Environment", formFieldDefault = Just "staging" })+    , ("reason", defaultFormFieldOptions { formFieldDescription = Just "Why deploy now?" })+    ])+```++A default that does not fit its field, or an option for a field the record+does not have, is also a compile-time error. `deriveForm` is a declaration splice, and+Template Haskell lets later splices see only the types declared above the+last declaration splice, so declare the tool, prompt and resource types+before the `deriveForm` lines (or in another module). For a form a record cannot+describe, write the `Form` instance by hand: a schema and a parser.++### How it works, and the rules that follow++The handler does not pause between rounds. Each round ends the request:+the client asks the user, then sends the request again with the answer,+possibly to another server instance. So the handler runs again from the+start, and the library replays a journal of what earlier rounds produced,+kept in the state the client carries: `elicit` returns the recorded+answer, or ends the round with its question; `step` returns its recorded+result instead of running the action again.++- **Ask the same questions and run the same steps in the same order on+  every round.** A handler that strays from its journal fails with an+  internal error naming the position, rather than receiving the answer to+  a different question.+- **`step` is at-least-once.** A client that retries from an earlier state+  runs later steps again. Make effects that must happen once idempotent.+- **`step` results travel to the client and back**, encrypted, on every+  later round: return an id rather than the whole object. Plain `liftIO` is+  not journaled and runs on every round; use it for reads.++### Protecting the state++The library protects the state the client carries between rounds: it is+encrypted, and bound to the caller, the request's arguments and an expiry+(60 minutes by default), and a retry presenting tampered, expired or+misdirected state is rejected before the handler runs. By default the key+is random per process, which suits stdio and a single HTTP instance. HTTP+deployments with several instances must share a key:++```haskell+main = do+  secret <- readSecret                                  -- 32 bytes, shared by every instance+  key <- maybe (fail "state key must be 32 bytes") pure (stateKey "2026-10" secret)+  runMcpServerHttpWithConfig defaultHttpConfig+    { httpStateCodec = Just (aeadStateCodec key []) }   -- to rotate: aeadStateCodec new [old]+    serverInfo handlers+```++### Low-level handlers++Hand-written call handlers return an `Outcome`: `Complete` with the result,+or `InputRequired` with requests keyed by ids of the handler's choosing and+some state of its own. On the retry the handler sees the answers in+`clientInputResponses` and its state in `clientRequestState`:++```haskell+deploy :: ToolCallHandler+deploy ctx _ _ = pure $ Right $+  case Map.lookup "confirm" (clientInputResponses ctx) of+    Nothing | clientSupportsInput ctx question ->+      InputRequired (Map.singleton "confirm" question) (Just "step-1")+    Just (ElicitResponse ElicitAccept (Just answer))+      | Map.lookup "ok" answer == Just (Bool True) ->+          Complete (toolResult [ContentText "Deployed"])+    _ -> Complete (toolError "Not confirmed")+  where+    question = ElicitForm "Deploy to production?"+      (schema (SchemaObject [("ok", schema SchemaBoolean)] ["ok"]))+```++`clientSupportsInput` says whether the client can answer: it must speak+2026-07-28 and have declared the elicitation mode (or sampling, or roots)+in question. Asking a client that cannot answer is reported as an internal+error, so check first and fall back. A low-level handler can also run an+`Ask` computation with `runAsk`, which is what the derived handlers do.+ ## Change notifications  Servers whose tool, prompt or resource lists change at runtime can push@@ -596,30 +751,35 @@ | Progress and per-request logging | ✅ | ✅ | | Cancellation | ✅ | ✅ | | Change notifications | stdio only | `subscriptions/listen` (stdio and HTTP) |+| Asking for input (multi round-trip requests) | — | `Ask` handlers and derived forms, protected `requestState` | | Result typing and cacheability hints | — | `resultType`, `httpCacheHints` | | HTTP request-metadata headers | — | `MCP-Protocol-Version`, `Mcp-Method`, `Mcp-Name` validated | -Design decisions and planned work (input-required results, OAuth resource-metadata, pagination, the tasks extension) live as ADRs under-[`specs/`](specs/README.md), ordered by [`specs/ROADMAP.md`](specs/ROADMAP.md).+Design decisions and planned work (OAuth resource metadata, pagination,+the tasks extension) live as ADRs under+[`specs/`](https://github.com/drshade/haskell-mcp-server/blob/main/specs/README.md), ordered by [`specs/ROADMAP.md`](https://github.com/drshade/haskell-mcp-server/blob/main/specs/ROADMAP.md).  ## Examples -- [`examples/Simple/`](examples/Simple/): a key-value store with two tools over stdio.-- [`examples/Complete/`](examples/Complete/): prompts, resources, a resource+- [`examples/Simple/`](https://github.com/drshade/haskell-mcp-server/tree/main/examples/Simple): a key-value store with two tools over stdio.+- [`examples/Complete/`](https://github.com/drshade/haskell-mcp-server/tree/main/examples/Complete): prompts, resources, a resource   template, tools with enum, nested and list arguments, `isError`,   annotations, progress and completions.-- [`examples/HttpSimple/`](examples/HttpSimple/): the key-value store over+- [`examples/Ask/`](https://github.com/drshade/haskell-mcp-server/tree/main/examples/Ask): tools that ask the user for input+  part-way through: a table booking with a derived form, a confirmation+  and a fallback for clients that cannot ask.+- [`examples/HttpSimple/`](https://github.com/drshade/haskell-mcp-server/tree/main/examples/HttpSimple): the key-value store over   Streamable HTTP.  ```bash cabal run simple-example        # stdio; type JSON-RPC on stdin+cabal run ask-example           # stdio; asks for input (2026-07-28 clients) cabal run http-simple-example   # http://localhost:3000/mcp ```  ## Conformance corpus -The wire-format fixtures under [`test/golden/`](test/golden/README.md) are a+The wire-format fixtures under [`test/golden/`](https://github.com/drshade/haskell-mcp-server/blob/main/test/golden/README.md) are a language-agnostic MCP conformance corpus: each case is a raw JSON-RPC `.request.json` and the exact `.response.json` a reference server answers, per protocol era, enumerated by a `manifest.json`. Any MCP implementation@@ -635,7 +795,7 @@ ## Contributing  Contributions are welcome. See the issue tracker for open issues and feature-requests, and [RELEASING.md](RELEASING.md) for how versions reach Hackage.+requests, and [RELEASING.md](https://github.com/drshade/haskell-mcp-server/blob/main/RELEASING.md) for how versions reach Hackage.  ## AI assistance 
+ examples/Ask/Main.hs view
@@ -0,0 +1,125 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TemplateHaskell   #-}++-- | Tools that ask the user for input part-way through (multi round-trip+-- requests, MCP 2026-07-28). Run it, then watch stderr while a client+-- books a table: the handler replays from the top on every round, but each+-- 'step' runs once in a normal booking, its result replayed afterwards.+--+-- 'step' is at-least-once, though: a client that retries a round with an+-- earlier state (say, after losing a reply) runs the steps after that point+-- again. A real 'confirmBooking' must therefore be idempotent, for example+-- by keying the booking on something the retry repeats.+module Main where++import           Control.Monad.IO.Class (liftIO)+import           Data.Text              (Text)+import qualified Data.Text              as T+import           Data.Time.Clock.POSIX  (getPOSIXTime)+import           MCP.Server+import           MCP.Server.Derive+import           System.IO              (hPutStrLn, hSetEncoding, stderr,+                                         stdout, utf8)+import           Types++-- Returning 'Ask' instead of 'IO' is all it takes to ask questions.+handleTool :: ClientContext -> BookingTool -> Ask Content+handleTool _ (BookTable place) = do+    -- Ask for the details. 'elicit' picks the form from the type the+    -- answer is used at: BookingDetails.+    answer <- elicit ("Booking a table at " <> place <> ". What do you need?")+    case answer of+        Accepted details -> do+            -- Work between questions goes in 'step': it runs once, and+            -- its result is replayed on later rounds.+            offer <- step (findTable place details)+            ok <- elicit ("Book " <> offer <> "?")+            case ok of+                Accepted (Confirm True) -> do+                    ref <- step (confirmBooking offer)+                    pure $ ContentText $ "Booked " <> offer <> ". Your reference is " <> ref <> "."+                _ -> pure $ ContentText "No problem, nothing was booked."+        Declined  -> pure $ ContentText "No problem, nothing was booked."+        Cancelled -> pure $ ContentText "Booking cancelled."+        -- The client cannot show forms (an older protocol revision, or no+        -- elicitation capability): no round trip happens, so say what to do.+        Unavailable -> pure $ ContentText $+            "I can't ask for booking details in this client. Please call "+                <> place <> " to book."++handleTool _ (CancelBooking ref) = do+    ok <- elicit ("Cancel booking " <> ref <> "? This cannot be undone.")+    case ok of+        Accepted (Confirm True) -> do+            _ <- step (cancelBooking ref)+            pure $ ContentText $ "Booking " <> ref <> " is cancelled."+        -- Cancelling without confirmation would be destructive: refuse.+        Unavailable -> pure $ ContentText "Cancelling needs confirmation, which this client can't ask for."+        _ -> pure $ ContentText $ "Booking " <> ref <> " is unchanged."++-- A tool that never asks finishes in one round; plain IO goes through liftIO.+handleTool _ (OpeningHours place) =+    ContentText <$> liftIO (lookupHours place)++-- The "back end". Each logs to stderr so you can see when it runs.++findTable :: Text -> BookingDetails -> IO Text+findTable place details = do+    logLine $ "finding a table at " <> place <> " for " <> T.pack (show details)+    pure $ "a table for " <> T.pack (show (partySize details)) <> " at " <> place+        <> ", " <> slotTime (slot details)+        <> (if null (dietary details) then "" else " (kitchen notified: "+              <> T.intercalate ", " (map dietLabel (dietary details)) <> ")")+        <> (if highChair details == Just True then " with a high chair" else "")++-- Not idempotent: a retried final round would book again under a new+-- reference. Fine for a demo; see the note at the top of the module.+confirmBooking :: Text -> IO Text+confirmBooking offer = do+    now <- getPOSIXTime+    let ref = "BK-" <> T.pack (show (floor now `mod` (100000 :: Integer)))+    logLine $ "booked " <> offer <> " as " <> ref+    pure ref++cancelBooking :: Text -> IO ()+cancelBooking ref = logLine $ "cancelled " <> ref++lookupHours :: Text -> IO Text+lookupHours place = pure $ place <> " is open 12:00-15:00 and 17:30-23:00, Tuesday to Sunday."++slotTime :: Slot -> Text+slotTime Lunch       = "13:00"+slotTime EarlyDinner = "18:00"+slotTime LateDinner  = "20:30"++dietLabel :: Diet -> Text+dietLabel Vegetarian = "vegetarian"+dietLabel Vegan      = "vegan"+dietLabel GlutenFree = "gluten-free"+dietLabel NutAllergy = "nut allergy"++logLine :: Text -> IO ()+logLine = hPutStrLn stderr . ("[bookings] " <>) . T.unpack++main :: IO ()+main = do+    hSetEncoding stdout utf8+    hSetEncoding stderr utf8+    runMcpServerStdio+        McpServerInfo+            { serverName = "Table Booking Example"+            , serverVersion = "0.1.0"+            , serverInstructions = "Books restaurant tables, asking the user for the details."+            }+        noHandlers+            { tools = Just $(deriveToolHandlerWithOptions ''BookingTool 'handleTool+                [ ("BookTable", defaultDefinitionOptions+                    { optDescription = Just "Book a table at a restaurant; asks the user for the details" })+                , ("CancelBooking", defaultDefinitionOptions+                    { optDescription = Just "Cancel a booking by its reference, after confirming with the user"+                    , optToolAnnotations = Just defaultToolAnnotations { toolDestructiveHint = Just True } })+                , ("OpeningHours", defaultDefinitionOptions+                    { optDescription = Just "A restaurant's opening hours"+                    , optToolAnnotations = Just defaultToolAnnotations { toolReadOnlyHint = Just True } })+                ])+            }
+ examples/Ask/Types.hs view
@@ -0,0 +1,50 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TemplateHaskell   #-}++module Types where++import           Data.Text         (Text)+import           MCP.Server.Derive++-- The tools. Declared above the deriveForm splices below: a splice only+-- sees the types declared above the last declaration splice.+data BookingTool+    = BookTable { restaurant :: Text }+    | CancelBooking { reference :: Text }+    | OpeningHours { restaurant :: Text }+    deriving (Show, Eq)++-- The forms: plain records whose answers the handlers want back.++-- An enumeration is offered as a choice ("lunch", "early_dinner", ...)+data Slot = Lunch | EarlyDinner | LateDinner+    deriving (Show, Eq)++data Diet = Vegetarian | Vegan | GlutenFree | NutAllergy+    deriving (Show, Eq)++data BookingDetails = BookingDetails+    { partySize :: Int+    , slot      :: Slot+    , dietary   :: [Diet]       -- a list of an enumeration: pick several+    , highChair :: Maybe Bool   -- Maybe: the user may leave it out+    } deriving (Show, Eq)++-- A yes/no question is a one-field form+newtype Confirm = Confirm { confirmed :: Bool }+    deriving (Show, Eq)++-- Labels, help text and starting values for the form's fields. A default+-- that does not fit its field is a compile-time error.+$(deriveFormWithOptions ''BookingDetails+    [ ("partySize", defaultFormFieldOptions+        { formFieldTitle = Just "Party size", formFieldDefault = Just (Number 2) })+    , ("slot", defaultFormFieldOptions+        { formFieldTitle = Just "Time", formFieldDefault = Just "early_dinner" })+    , ("dietary", defaultFormFieldOptions+        { formFieldTitle = Just "Dietary requirements"+        , formFieldDescription = Just "Tick any that apply to your party" })+    , ("highChair", defaultFormFieldOptions { formFieldTitle = Just "Need a high chair?" })+    ])++$(deriveFormWithDescription ''Confirm [("confirmed", "Tick to confirm")])
examples/Complete/Types.hs view
@@ -4,7 +4,7 @@  import           Data.Text (Text) --- High-level data type definitions from SPEC.md+-- High-level data type definitions (see specs/ADR_0001_original_spec.md)  data MyPrompt     = Recipe { idea :: Text }
examples/HttpSimple/Main.hs view
@@ -23,9 +23,7 @@                 Nothing -> pure $ ContentText $ "Key '" <> k <> "' not found"                 Just v  -> pure $ ContentText v         handleTool _ (SetValue k v) = do-            pairs <- readIORef store-            let newPairs = (k, v) : filter ((/= k) . fst) pairs-            writeIORef store newPairs+            atomicModifyIORef' store $ \pairs -> ((k, v) : filter ((/= k) . fst) pairs, ())             pure $ ContentText $ "Set '" <> k <> "' to '" <> v <> "'"      -- Derive the tool handlers using Template Haskell with descriptions
examples/Simple/Main.hs view
@@ -23,9 +23,7 @@                 Nothing -> pure $ ContentText $ "Key '" <> k <> "' not found"                 Just v  -> pure $ ContentText v         handleTool _ (SetValue k v) = do-            pairs <- readIORef store-            let newPairs = (k, v) : filter ((/= k) . fst) pairs-            writeIORef store newPairs+            atomicModifyIORef' store $ \pairs -> ((k, v) : filter ((/= k) . fst) pairs, ())             pure $ ContentText $ "Set '" <> k <> "' to '" <> v <> "'"      -- Derive the tool handlers using Template Haskell with descriptions
mcp-server.cabal view
@@ -15,7 +15,7 @@ -- PVP summary:     +-+------- breaking API changes --                  | | +----- non-breaking API additions --                  | | | +--- code changes with no API change-version: 0.3.0.0+version: 0.3.0.1 -- A short (one-line) description of the package. synopsis: Library for building Model Context Protocol (MCP) servers -- A longer description of the package.@@ -71,17 +71,21 @@   -- Modules exported by the library.   exposed-modules:     MCP.Server+    MCP.Server.Ask     MCP.Server.Derive     MCP.Server.Handlers     MCP.Server.JsonRpc     MCP.Server.Notifications     MCP.Server.Protocol+    MCP.Server.RequestState     MCP.Server.Transport.Http     MCP.Server.Transport.Stdio     MCP.Server.Types    other-modules:     MCP.Server.Derive.Internal+    MCP.Server.Internal.InputRound+    MCP.Server.Internal.Json    -- Modules included in this library but not exported.   -- other-modules:@@ -95,12 +99,15 @@     base64-bytestring >=1.0 && <1.3,     bytestring >=0.10 && <0.13,     containers >=0.6 && <0.9,+    crypton >=1.1.4 && <2.3,     http-types >=0.12 && <1,     network-uri >=2.6 && <2.8,+    ram >=0.20.1 && <0.23,     scientific >=0.3 && <0.4,     stm >=2.5 && <2.6,     template-haskell >=2.16 && <2.25,     text >=1.2 && <3,+    time >=1.9 && <1.17,     wai >=3.2 && <4,     warp >=3.3 && <4, @@ -150,6 +157,25 @@   -- Base language which the package is written in.   default-language: GHC2021 +executable ask-example+  -- Import common warning flags.+  import: warnings+  -- .hs or .lhs file containing the Main module.+  main-is: Main.hs+  -- Modules included in this executable, other than Main.+  other-modules: Types+  -- Other library packages from which modules are imported.+  build-depends:+    base,+    mcp-server,+    text,+    time,++  -- Directories containing source files.+  hs-source-dirs: examples/Ask+  -- Base language which the package is written in.+  default-language: GHC2021+ executable http-simple-example   -- Import common warning flags.   import: warnings@@ -179,14 +205,18 @@   default-language: GHC2021   -- Modules included in this executable, other than Main.   other-modules:+    AskTypes     Spec.AdvancedDerivation+    Spec.AskMonad     Spec.BasicDerivation     Spec.Cancellation     Spec.DefinitionMetadata+    Spec.DerivedAsk     Spec.DerivedOutput     Spec.GoldenWire     Spec.HandlerExceptions     Spec.HttpTransport+    Spec.InputRequired     Spec.JSONConversion     Spec.ModernEra     Spec.Progress@@ -224,5 +254,6 @@     mcp-server,     network-uri,     stm,+    template-haskell,     text,     wai,
src/MCP/Server.hs view
@@ -1,6 +1,10 @@ {-# LANGUAGE OverloadedStrings   #-} {-# LANGUAGE ScopedTypeVariables #-} +-- | Build an MCP server: run it over stdio or HTTP, or mount it as a WAI+-- application, with the handler and definition types re-exported from+-- "MCP.Server.Types". "MCP.Server.Derive" generates handlers from data+-- types. module MCP.Server   ( -- * Server Runtime     runMcpServerStdio@@ -15,6 +19,19 @@   , defaultHttpConfig   , mcpApplication +    -- * Handlers that ask the client for input+  , module MCP.Server.Ask++    -- * Request state (multi round-trip requests)+  , StateCodec(..)+  , StateError(..)+  , StateKey+  , stateKey+  , stateKeyId+  , aeadStateCodec+  , ephemeralStateCodec+  , defaultStateLifetime+     -- * Change Notifications   , McpNotifier(..)   , NotificationSource@@ -24,6 +41,7 @@   , module MCP.Server.Types   ) where +import           MCP.Server.Ask import           MCP.Server.Notifications (McpNotifier (..), NotificationSource,                                            newMcpNotifier) import           MCP.Server.Transport.Stdio (StdioConfig (..), defaultStdioConfig,@@ -31,6 +49,7 @@                                              transportRunStdioWithConfig) import           MCP.Server.Transport.Http (HttpConfig(..), transportRunHttp, defaultHttpConfig,                                             mcpApplication)+import           MCP.Server.RequestState import           MCP.Server.Types  -- | Run an MCP server using STDIO transport
+ src/MCP/Server/Ask.hs view
@@ -0,0 +1,339 @@+{-# LANGUAGE DefaultSignatures #-}+{-# LANGUAGE OverloadedStrings #-}++-- | Handlers that ask the client questions part-way through, written as+-- straight-line code.+--+-- A multi round-trip request ends each round that needs input: the client+-- answers and retries the request, possibly on another server instance.+-- So an t'Ask' handler does not pause at a question. It runs from the start+-- on every round, against a /journal/ of what earlier rounds produced, which+-- the library keeps sealed in the request state:+--+-- * 'elicit' returns the answer recorded for it, or ends the round with the+--   question when there is none yet.+-- * 'step' runs its action once, records the result, and returns the+--   recorded result on later rounds.+--+-- > confirmDelete :: ClientContext -> Text -> Ask ToolResult+-- > confirmDelete _ name = do+-- >   size <- step (fileSize name)+-- >   answer <- elicit ("Delete " <> name <> " (" <> showSize size <> ")?")+-- >   case answer of+-- >     Accepted (Confirm True) -> do+-- >       step (deleteFile name)+-- >       pure (toolResult [ContentText "Deleted"])+-- >     Unavailable -> pure (toolError "Confirmation needed: this client cannot ask")+-- >     _ -> pure (toolResult [ContentText "Kept"])+--+-- Three rules follow from replay:+--+-- * __Ask the same questions in the same order.__ Each 'elicit' and 'step'+--   takes the next position in the journal. If a round asks something+--   different at a position, the request fails with an internal error that+--   names the position, rather than passing an answer to the wrong question.+-- * __'step' is at-least-once.__ If a round's reply is lost and the client+--   retries with an earlier state, the step runs again. Effects that must+--   happen once must be idempotent or guarded on the server.+-- * __'step' results travel to the client and back__ (encrypted) on every+--   later round. Keep them small: an id rather than the object.+--+-- Plain 'liftIO' is not journaled: it runs on every round. Use it for reads+-- that may change between rounds without changing which questions are+-- asked.+--+-- Derived handlers ("MCP.Server.Derive") may return t'Ask' instead of+-- 'IO', and 'MCP.Server.Derive.deriveForm' derives t'Form' instances from+-- records.+module MCP.Server.Ask+  ( -- * Handlers that ask+    Ask+  , runAsk+  , askContext+    -- * Questions+  , Answer(..)+  , Form(..)+  , elicit+  , elicitUrl+    -- * Journaled IO+  , step+    -- * Derived handlers+  , HandlerMonad(..)+  ) where++import           Control.Monad            (ap, liftM)+import           Control.Monad.IO.Class   (MonadIO (..))+import           Data.Aeson+import qualified Data.Aeson.KeyMap        as KM+import           Data.Aeson.Types         (parseEither)+import qualified Data.ByteString.Lazy     as BSL+import           Data.Map                 (Map)+import qualified Data.Map                 as Map+import           Data.Proxy               (Proxy (..))+import           Data.Text                (Text)+import qualified Data.Text                as T+import qualified Data.Text.Encoding       as TE++import           MCP.Server.Internal.Json (jsonDigest)+import           MCP.Server.Types++-- | How the user answered a question.+data Answer a+  = Accepted a+    -- ^ The user submitted the form (for 'elicitUrl': completed the+    --   interaction).+  | Declined+    -- ^ The user explicitly declined.+  | Cancelled+    -- ^ The user dismissed the question without choosing.+  | Unavailable+    -- ^ The client cannot ask: it speaks a protocol revision without+    --   multi round-trip requests, or did not declare the elicitation+    --   mode. No round trip took place.+  deriving (Show, Eq, Functor)++-- | A form to elicit: an object of strings, numbers, booleans and+-- enumerations, as elicitation allows, and how to read what the user+-- submitted. 'formParse' defaults to the type's 'FromJSON' instance.+--+-- > data Confirm = Confirm { proceed :: Bool } deriving Generic+-- > instance FromJSON Confirm+-- > instance Form Confirm where+-- >   formSchema _ = schema $ SchemaObject [("proceed", schema SchemaBoolean)] ["proceed"]+class Form a where+  -- | The requested schema.+  formSchema :: Proxy a -> Schema+  -- | Read the submitted content, or say why it does not fit the form.+  formParse :: Map Text Value -> Either Text a+  default formParse :: FromJSON a => Map Text Value -> Either Text a+  formParse content = either (Left . T.pack) Right (parseEither parseJSON (toJSON content))++-- | A handler that can ask the client for input. Run it with 'runAsk'.+newtype Ask a = Ask (ClientContext -> Run -> IO (Round a))++-- Where a run is in the journal.+data Run = Run+  { runReplay   :: [Entry]     -- entries from earlier rounds not yet reached+  , runPending  :: Maybe Text  -- fingerprint of the question the last round asked+  , runPosition :: Int+  , runWritten  :: [Entry]     -- the journal so far, newest first+  }++data Round a+  = Done a Run+  | Suspended Int Text InputRequest [Entry]  -- position, fingerprint, request, journal+  | Failed Error++instance Functor Ask where+  fmap = liftM++instance Applicative Ask where+  pure a = Ask $ \_ run -> pure (Done a run)+  (<*>) = ap++instance Monad Ask where+  Ask m >>= k = Ask $ \ctx run -> do+    result <- m ctx run+    case result of+      Done a run'                -> let Ask m' = k a in m' ctx run'+      Suspended pos fp req entries -> pure (Suspended pos fp req entries)+      Failed err                 -> pure (Failed err)++-- | Runs on every round; not journaled.+instance MonadIO Ask where+  liftIO io = Ask $ \_ run -> (\a -> Done a run) <$> io++-- | The context of the current round's request.+askContext :: Ask ClientContext+askContext = Ask $ \ctx run -> pure (Done ctx run)++-- | Run a handler for one round: 'Complete' when it finishes, or+-- 'InputRequired' with its next question and its journal as the state.+-- Use it in a low-level call handler; derived handlers are run with it+-- for you (see t'HandlerMonad').+runAsk :: Ask a -> ClientContext -> IO (Either Error (Outcome a))+runAsk (Ask m) ctx = case readJournal (clientRequestState ctx) of+  Left err -> pure (Left (InternalError err))+  Right (Journal entries pending) -> do+    result <- m ctx Run+      { runReplay = entries+      , runPending = pending+      , runPosition = 0+      , runWritten = []+      }+    pure $ case result of+      Done a run+        | null (runReplay run), Nothing <- runPending run -> Right (Complete a)+        | otherwise -> Left $ mismatch (runPosition run)+            "the handler finished before reaching it"+      Suspended pos fp request written -> Right $ InputRequired+        (Map.singleton (positionKey pos) request)+        (Just (writeJournal (Journal written (Just fp))))+      Failed err -> Left err++-- | The monads a derived handler can run in: 'IO' for handlers that never+-- ask, and t'Ask' for handlers that do. The @derive*Handler@ splices run+-- the handler through this class, so either return type works and a handler+-- switches by changing its type alone.+class Monad m => HandlerMonad m where+  -- | Run a handler for one round of a request.+  runHandler :: ClientContext -> m a -> IO (Either Error (Outcome a))++instance HandlerMonad IO where+  runHandler _ action = Right . Complete <$> action++instance HandlerMonad Ask where+  runHandler ctx handler = runAsk handler ctx++-- | Ask the user to fill in the form for @a@, with a message.+elicit :: Form a => Text -> Ask (Answer a)+elicit message = result+  where+    result = question (ElicitForm message (formSchema (proxyOf result))) parse+    parse (Just content) = formParse content+    parse Nothing        = Left "accepted without content"+    proxyOf :: Ask (Answer a) -> Proxy a+    proxyOf _ = Proxy++-- | Send the user to a URL to complete an interaction out of band, with a+-- message. 'Accepted' means the user agreed to go; the interaction's+-- result reaches the server by other means.+elicitUrl :: Text -> URI -> Ask (Answer ())+elicitUrl message url = question (ElicitUrl message url) (const (Right ()))++-- | Run an action once, and replay its result on later rounds. The result+-- is stored in the request state, so it must survive a JSON round trip; it+-- is returned decoded on every round, the first included.+step :: (ToJSON a, FromJSON a) => IO a -> Ask a+step action = Ask $ \_ run -> case (runReplay run, runPending run) of+  (Stepped v : rest, _) -> pure $ case fromJSON v of+    Success a -> Done a (advance (Stepped v) run { runReplay = rest })+    Error err -> Failed $ InternalError $+      "the step result at position " <> showT (runPosition run)+        <> " no longer decodes: " <> T.pack err+  (_ : _, _) -> pure $ Failed $ mismatch (runPosition run) "the handler ran a step instead"+  ([], Just _) -> pure $ Failed $ mismatch (runPosition run) "the handler ran a step instead"+  ([], Nothing) -> do+    v <- toJSON <$> action+    pure $ case fromJSON v of+      Success a -> Done a (advance (Stepped v) run)+      Error err -> Failed $ InternalError $+        "the step result at position " <> showT (runPosition run)+          <> " does not decode from its own JSON: " <> T.pack err++-- A question at the next position: the request, and how to read an+-- accepted answer's content.+question :: InputRequest -> (Maybe (Map Text Value) -> Either Text a) -> Ask (Answer a)+question request parse = Ask $ \ctx run ->+  let pos = runPosition run+      key = positionKey pos+      fp = jsonDigest (toJSON request)+      record recorded = advance (Asked fp recorded) run+      suspend = Suspended pos fp request (reverse (runWritten run))+      supported = clientSupportsInput ctx request+  in pure $ case (runReplay run, runPending run) of+    (Asked fp' recorded : rest, _)+      | fp' == fp -> case answerOf parse recorded of+          Right a  -> Done a (record recorded) { runReplay = rest }+          Left err -> Failed $ InternalError $+            "the answer at position " <> key <> " no longer parses: " <> err+    (_ : _, _) -> Failed $ mismatch pos "the handler asked a different question"+    ([], Just fp')+      | fp' /= fp -> Failed $ mismatch pos "the handler asked a different question"+      | otherwise -> case Map.lookup key (clientInputResponses ctx) of+          Nothing+            | supported -> suspend+            | otherwise -> Done Unavailable (record Unavailable') { runPending = Nothing }+          Just response -> case recordResponse response >>= \r -> (,) r <$> answerOf parse r of+            Right (recorded, a) -> Done a (record recorded) { runPending = Nothing }+            Left err -> Failed $ InvalidParams $ "inputResponses: " <> key <> ": " <> err+    ([], Nothing)+      | supported -> suspend+      | otherwise -> Done Unavailable (record Unavailable')++advance :: Entry -> Run -> Run+advance entry run = run+  { runPosition = runPosition run + 1+  , runWritten = entry : runWritten run+  }++mismatch :: Int -> Text -> Error+mismatch pos what = InternalError $+  "the handler does not match its journal at position " <> showT pos <> ": "+    <> what <> ". Ask handlers must ask the same questions and run the same steps"+    <> " in the same order on every round"++positionKey :: Int -> Text+positionKey = showT++showT :: Show a => a -> Text+showT = T.pack . show++-- The journal ------------------------------------------------------------++data Journal = Journal [Entry] (Maybe Text)++data Entry+  = Asked Text Recorded  -- the question's fingerprint, and its answer+  | Stepped Value++data Recorded+  = Accepted' (Maybe (Map Text Value))+  | Declined'+  | Cancelled'+  | Unavailable'++recordResponse :: InputResponse -> Either Text Recorded+recordResponse (ElicitResponse action content) = Right $ case action of+  ElicitAccept  -> Accepted' content+  ElicitDecline -> Declined'+  ElicitCancel  -> Cancelled'+recordResponse (RawInputResponse _) = Left "expected an elicitation result"++answerOf :: (Maybe (Map Text Value) -> Either Text a) -> Recorded -> Either Text (Answer a)+answerOf parse (Accepted' content) = Accepted <$> parse content+answerOf _ Declined'               = Right Declined+answerOf _ Cancelled'              = Right Cancelled+answerOf _ Unavailable'            = Right Unavailable++instance ToJSON Entry where+  toJSON (Asked fp recorded) = object $ ("q" .= fp) : case recorded of+    Accepted' content -> ["a" .= ("accept" :: Text), "c" .= content]+    Declined'         -> ["a" .= ("decline" :: Text)]+    Cancelled'        -> ["a" .= ("cancel" :: Text)]+    Unavailable'      -> ["a" .= ("unavailable" :: Text)]+  toJSON (Stepped v) = object ["s" .= v]++instance FromJSON Entry where+  parseJSON = withObject "Entry" $ \o -> do+    -- By presence: a step's result may itself be null.+    case KM.lookup "s" o of+      Just v -> pure (Stepped v)+      Nothing -> do+        fp <- o .: "q"+        action <- o .: "a"+        Asked fp <$> case action :: Text of+          "accept"      -> Accepted' <$> o .:? "c"+          "decline"     -> pure Declined'+          "cancel"      -> pure Cancelled'+          "unavailable" -> pure Unavailable'+          _             -> fail "unknown answer"++writeJournal :: Journal -> Text+writeJournal (Journal entries pending) = TE.decodeUtf8 $ BSL.toStrict $ encode $ object+  [ "v" .= (1 :: Int)+  , "j" .= entries+  , "p" .= pending+  ]++readJournal :: Maybe Text -> Either Text Journal+readJournal Nothing = Right (Journal [] Nothing)+readJournal (Just state) =+  either (\err -> Left ("the request state is not an Ask journal: " <> T.pack err)) Right $+    eitherDecodeStrict (TE.encodeUtf8 state) >>= parseEither parser+  where+    parser = withObject "Journal" $ \o -> do+      version <- o .: "v"+      if version /= (1 :: Int)+        then fail "unsupported journal version"+        else Journal <$> o .: "j" <*> o .:? "p"
src/MCP/Server/Derive.hs view
@@ -6,11 +6,15 @@ -- -- Tool arguments are decoded from full JSON values: records may contain -- primitive fields ('Data.Text.Text', 'Int', 'Integer', 'Double', 'Float',--- 'Bool'), optional fields ('Maybe'), lists, enumerations (data types whose+-- t'Bool'), optional fields ('Maybe'), lists, enumerations (data types whose -- constructors are all nullary), and nested records — the generated -- @inputSchema@ mirrors the same structure. Prompt arguments are -- string-valued per the MCP specification, so prompt records are limited to -- primitive and enumeration fields.+--+-- Handlers may return 'IO' or 'MCP.Server.Ask.Ask' (see t'HandlerMonad'):+-- an t'MCP.Server.Ask.Ask' handler can ask the user questions part-way+-- through, with forms derived by 'deriveForm'. module MCP.Server.Derive   ( -- * Template Haskell Derivation     derivePromptHandler@@ -29,21 +33,34 @@   , deriveToolHandlerWithOutputDescription   , deriveToolHandlerWithOutputOptions +    -- * Forms for elicitation+  , deriveForm+  , deriveFormWithDescription+  , deriveFormWithOptions+  , FormFieldOptions(..)+  , defaultFormFieldOptions+  , Value(..)+     -- * Per-constructor customization   , DefinitionOptions(..)   , defaultDefinitionOptions   ) where  import           Control.Monad       (zipWithM)-import           Data.Aeson          (Value (..))+-- Names used inside quotes must be imported here, or the generated code+-- looks them up in the user's module instead.+import           Data.Aeson          (Value (..), encode, object, toJSON, (.=))+import qualified Data.ByteString.Lazy as BSL import           Data.List           (intercalate) import           Data.Maybe          (fromMaybe) import           Data.Text           (Text) import qualified Data.Text           as T+import qualified Data.Text.Encoding  as TE import           Language.Haskell.TH import           Language.Haskell.TH.Syntax (Lift, lift) import qualified Data.Char           as Char +import           MCP.Server.Ask      (Form (..), HandlerMonad (..)) import           MCP.Server.Derive.Internal import           MCP.Server.Types @@ -437,7 +454,7 @@ derivePromptHandlerWithDescription typeName handlerName descriptions =   derivePromptHandlerGeneric typeName handlerName (optionsFromDescriptions descriptions) descriptions --- | Derive prompt handlers with per-constructor 'DefinitionOptions'+-- | Derive prompt handlers with per-constructor t'DefinitionOptions' -- (title, icons, scoped argument descriptions). Usage: -- -- > $(derivePromptHandlerWithOptions ''MyPrompt 'handlePrompt@@ -515,21 +532,18 @@     ToolArgs ->       [| case $(return decoder) of            Left err -> pure (Left err)-           Right v  -> do-             r <- $(varE handlerName) $(varE ctxName) v-             pure (Right (toToolResult r)) |]+           Right v  -> fmap (fmap toToolResult) <$>+             runHandler $(varE ctxName) ($(varE handlerName) $(varE ctxName) v) |]     ToolArgsOutput builder ->       [| case $(return decoder) of            Left err -> pure (Left err)-           Right v  -> do-             r <- $(varE handlerName) $(varE ctxName) v-             pure (Right (resolveToolOutput $(return builder) r)) |]+           Right v  -> fmap (fmap (resolveToolOutput $(return builder))) <$>+             runHandler $(varE ctxName) ($(varE handlerName) $(varE ctxName) v) |]     PromptArgs ->       [| case $(return decoder) of            Left err -> pure (Left err)-           Right v  -> do-             r <- $(varE handlerName) $(varE ctxName) v-             pure (Right (toPromptResult r)) |]+           Right v  -> fmap (fmap toPromptResult) <$>+             runHandler $(varE ctxName) ($(varE handlerName) $(varE ctxName) v) |]   clause [litP $ stringL sname] (normalB (return body)) []  -------------------------------------------------------------------------------@@ -553,7 +567,7 @@ deriveResourceHandlerWithDescription typeName handlerName descriptions =   deriveResourceHandlerGeneric typeName handlerName (optionsFromDescriptions descriptions) --- | Derive resource handlers with per-constructor 'DefinitionOptions'+-- | Derive resource handlers with per-constructor t'DefinitionOptions' -- (description, title, icons). deriveResourceHandlerWithOptions :: Name -> Name -> [(String, DefinitionOptions)] -> Q Exp deriveResourceHandlerWithOptions typeName handlerName opts =@@ -597,7 +611,7 @@   deriveResourceTemplatesGeneric typeName (optionsFromDescriptions descriptions)  -- | Derive a 'ResourceTemplateListHandler' with per-constructor--- 'DefinitionOptions' (description, title, icons).+-- t'DefinitionOptions' (description, title, icons). deriveResourceTemplatesWithOptions :: Name -> [(String, DefinitionOptions)] -> Q Exp deriveResourceTemplatesWithOptions typeName opts =   deriveResourceTemplatesGeneric typeName (optionsFor opts)@@ -666,7 +680,7 @@   NormalC name [] -> do     let resourceURI = "resource://" <> snakeName name     [| if show $(varE uriName) == $(litE $ stringL resourceURI)-         then Right <$> $(varE handlerName) $(varE ctxName) $(varE uriName) $(conE name)+         then runHandler $(varE ctxName) ($(varE handlerName) $(varE ctxName) $(varE uriName) $(conE name))          else $restQ |]   RecC name [] ->     fail $ "Resource template constructors need at least one field: " ++ nameBase name@@ -676,7 +690,7 @@     decoder <- mkSegmentsDecoder segsName name fields     handled <- [| case $(return decoder) of                     Left err -> pure (Left err)-                    Right v  -> Right <$> $(varE handlerName) $(varE ctxName) $(varE uriName) v |]+                    Right v  -> runHandler $(varE ctxName) ($(varE handlerName) $(varE ctxName) $(varE uriName) v) |]     rest <- restQ     matchExp <- [| matchTemplateSegments $(litE $ stringL prefix)                      $(litE $ integerL $ fromIntegral $ length fields)@@ -715,7 +729,7 @@ deriveToolHandlerWithDescription typeName handlerName descriptions =   deriveToolHandlerGeneric typeName handlerName (optionsFromDescriptions descriptions) descriptions Nothing --- | Derive tool handlers with per-constructor 'DefinitionOptions'+-- | Derive tool handlers with per-constructor t'DefinitionOptions' -- (description, title, icons, behavioral annotations, scoped argument -- descriptions). Usage: --@@ -741,7 +755,7 @@ -- record type describing the tools' results. Its shape becomes the derived -- @outputSchema@ (same field rules as input derivation: primitives, -- 'Maybe', lists, all-nullary enums, nested records), and the handler--- returns @'ToolOutput' \<output\>@, whose typed values the derivation+-- returns @t'ToolOutput' \<output\>@, whose typed values the derivation -- serializes into @structuredContent@ — guaranteed to match the schema. -- -- > handleTool :: ClientContext -> MyTool -> IO (ToolOutput WeatherReport)@@ -758,7 +772,7 @@   deriveToolHandlerGeneric typeName handlerName (optionsFromDescriptions descriptions) descriptions (Just outputName)  -- | 'deriveToolHandlerWithOutput' with per-constructor--- 'DefinitionOptions'. An entry keyed by the output type's name supplies+-- t'DefinitionOptions'. An entry keyed by the output type's name supplies -- the @outputSchema@ field descriptions via 'optFieldDescriptions'. deriveToolHandlerWithOutputOptions :: Name -> Name -> Name -> [(String, DefinitionOptions)] -> Q Exp deriveToolHandlerWithOutputOptions typeName handlerName outputName opts =@@ -817,3 +831,150 @@       , toolDefinitionAnnotations = $(lift (optToolAnnotations opts))       , toolDefinitionIcons = $(lift (optIcons opts))       } |]++-------------------------------------------------------------------------------+-- Form derivation (elicitation)+-------------------------------------------------------------------------------++-- | How one field of a derived form is presented.+data FormFieldOptions = FormFieldOptions+  { formFieldTitle       :: Maybe Text+    -- ^ The label clients show (falls back to the field name)+  , formFieldDescription :: Maybe Text+    -- ^ Help text shown with the field+  , formFieldDefault     :: Maybe Value+    -- ^ The value the field starts with; checked against the field's type+    --   at compile time+  } deriving (Show, Eq)++-- | Nothing customized; record-update what you need.+defaultFormFieldOptions :: FormFieldOptions+defaultFormFieldOptions = FormFieldOptions+  { formFieldTitle = Nothing+  , formFieldDescription = Nothing+  , formFieldDefault = Nothing+  }++-- | Derive a t'Form' instance from a record, for 'MCP.Server.Ask.elicit':+--+-- > data Environment = Staging | Production+-- > data DeployTarget = DeployTarget { environment :: Environment, reason :: Text, notify :: Maybe Bool }+-- > $(deriveForm ''DeployTarget)+--+-- Elicitation forms are flat, so the fields may be 'Text', 'Int',+-- 'Integer', 'Double', 'Float', t'Bool', enumerations (data types whose+-- constructors are all nullary, offered as snake_cased choices), lists of+-- enumerations (several choices), and 'Maybe' of any of these for optional+-- fields. Anything else, such as a nested record, is a compile-time error+-- naming the field.+deriveForm :: Name -> Q [Dec]+deriveForm typeName = deriveFormWithOptions typeName []++-- | 'deriveForm' with field descriptions, keyed by field name.+--+-- > $(deriveFormWithDescription ''DeployTarget [("reason", "Why deploy now?")])+deriveFormWithDescription :: Name -> [(String, String)] -> Q [Dec]+deriveFormWithDescription typeName descriptions = deriveFormWithOptions typeName+  [ (field, defaultFormFieldOptions { formFieldDescription = Just (T.pack d) })+  | (field, d) <- descriptions ]++-- | 'deriveForm' with per-field t'FormFieldOptions', keyed by field name.+--+-- > $(deriveFormWithOptions ''DeployTarget+-- >     [ ("environment", defaultFormFieldOptions+-- >         { formFieldTitle = Just "Environment", formFieldDefault = Just "staging" }) ])+deriveFormWithOptions :: Name -> [(String, FormFieldOptions)] -> Q [Dec]+deriveFormWithOptions typeName opts = do+  cons <- reifyCons typeName+  (con, fields) <- case cons of+    Just [RecC con fields@(_ : _)] -> pure (con, fields)+    _ -> fail $ "deriveForm: " ++ nameBase typeName+           ++ " must be a record with a single constructor and at least one field"+  let known = [nameBase fn | (fn, _, _) <- fields]+  case [field | (field, _) <- opts, field `notElem` known] of+    [] -> pure ()+    unknown -> fail $ "deriveForm: " ++ nameBase typeName+                 ++ " has no field named " ++ intercalate ", " unknown+  props <- traverse (formProperty typeName opts) fields+  let reqd = listE [litE $ stringL fn | fn <- requiredFieldNames fields]+  parser <- mkObjectParser typeName con fields+  [d| instance Form $(conT typeName) where+        formSchema _ = Schema Nothing (SchemaObject $(return (ListE props)) $reqd)+        formParse content = $(return parser) (toJSON content) |]++-- One property of a form's schema, after checking the field can be in one.+formProperty :: Name -> [(String, FormFieldOptions)] -> (Name, Bang, Type) -> Q Exp+formProperty typeName opts (fieldName, _, fieldType) = do+  let field = nameBase fieldName+      (_, innerType) = unwrapMaybe fieldType+      fieldOpts = fromMaybe defaultFormFieldOptions (lookup field opts)+      unsupported why = fail $ "deriveForm: field '" ++ field ++ "' of " ++ nameBase typeName+        ++ " " ++ why ++ "; form fields are Text, numbers, Bool, enumerations,"+        ++ " lists of enumerations, or Maybe of these"+  kind <- formFieldKind innerType >>= either unsupported pure+  traverse_ (checkFormDefault typeName field kind) (formFieldDefault fieldOpts)+  shape <- mkSchemaShape [] innerType+  let defaultJson = jsonText <$> formFieldDefault fieldOpts+  [| ( $(litE (stringL field))+     , formFieldSchema $(lift (formFieldTitle fieldOpts))+                       $(lift (formFieldDescription fieldOpts))+                       $(lift defaultJson)+                       $(return shape)+     ) |]+  where+    traverse_ f = maybe (pure ()) f++-- What a form field holds, for checking defaults.+data FormFieldKind+  = FormText+  | FormNumber (Value -> Either Text ())  -- the field's own parser, discarding the value+  | FormBool+  | FormChoice [String]+  | FormChoices [String]++-- Classify a field type, or say why a form cannot hold it.+formFieldKind :: Type -> Q (Either String FormFieldKind)+formFieldKind ty = case ty of+  ConT n | nameBase n == "Text"                                    -> ok FormText+         | nameBase n == "Int"                                     -> number valueInt+         | nameBase n == "Integer"                                 -> number valueInteger+         | nameBase n == "Double"                                  -> number valueDouble+         | nameBase n == "Float"                                   -> number valueFloat+         | nameBase n == "Bool"                                    -> ok FormBool+  AppT ListT inner -> do+    kind <- formFieldKind inner+    pure $ case kind of+      Right (FormChoice choices) -> Right (FormChoices choices)+      _ -> Left "is a list of something other than an enumeration"+  ConT n -> do+    cons <- reifyCons n+    case cons of+      Just cs | not (null cs), all isNullary cs -> ok (FormChoice [snakeName (conName c) | c <- cs])+      Just [RecC _ _] -> pure (Left "is a record (forms cannot nest)")+      Just [NormalC _ [(_bang, inner)]] -> formFieldKind inner+      _ -> pure (Left ("has type " ++ nameBase n))+  AppT (ConT n) _ | nameBase n == "Maybe" -> pure (Left "is a nested Maybe")+  _ -> pure (Left ("has type " ++ pprint ty))+  where+    ok = pure . Right+    number parser = ok (FormNumber (fmap (const ()) . parser))++-- A default must be a value the field can hold.+checkFormDefault :: Name -> String -> FormFieldKind -> Value -> Q ()+checkFormDefault typeName field kind value+  | fits kind value = pure ()+  | otherwise = fail $ "deriveForm: the default for field '" ++ field ++ "' of "+      ++ nameBase typeName ++ " does not fit its type: " ++ T.unpack (jsonText value)+  where+    fits FormText (String _)              = True+    -- Numbers go through the field's parser, so an Int default must be a+    -- whole number in range, as the parsed answer would have to be.+    fits (FormNumber parse) v@(Number _)  = either (const False) (const True) (parse v)+    fits FormBool (Bool _)                = True+    fits (FormChoice cs) (String c)       = T.unpack c `elem` cs+    fits (FormChoices cs) (Array xs)      = all (fits (FormChoice cs)) xs+    fits _ _                              = False++-- JSON as text, decoded as UTF-8 (the encoding is always valid UTF-8).+jsonText :: Value -> Text+jsonText = TE.decodeUtf8 . BSL.toStrict . encode
src/MCP/Server/Derive/Internal.hs view
@@ -38,9 +38,11 @@   , resolveToolOutput   , omitNothing   , canonicalJsonText+    -- * Forms+  , formFieldSchema   ) where -import           Data.Aeson           (ToJSON (..), Value (..), encode)+import           Data.Aeson           (Value (..), decodeStrict, encode, toJSON) import qualified Data.Aeson.Key       as Key import qualified Data.Aeson.KeyMap    as KM import           Data.Aeson.Types     (Pair)@@ -56,7 +58,9 @@ import           Network.URI          (unEscapeString) import           Text.Read            (readMaybe) -import           MCP.Server.Types     (Content (..), Error (..),+import           MCP.Server.Internal.Json (canonicalJsonText)+import           MCP.Server.Types     (Content (..), Error (..), Schema (..),+                                       SchemaType (..),                                        ToolOutput (..), ToolResult (..),                                        toolError, toolResult) @@ -225,8 +229,8 @@ -- Structured output -- --------------------------------------------------------------------------- --- | Turn a 'ToolOutput' into the 'ToolResult' that goes on the wire, given--- the generated serializer for the output type. A plain 'ToolOutput' also+-- | Turn a t'ToolOutput' into the t'ToolResult' that goes on the wire, given+-- the generated serializer for the output type. A plain v'ToolOutput' also -- carries the serialized JSON as a text content block, per the spec's -- recommendation for clients that predate structured output. resolveToolOutput :: (o -> Value) -> ToolOutput o -> ToolResult@@ -239,28 +243,26 @@   ToolOutputError msg -> toolError msg   ToolOutputRaw result -> result --- | Compact JSON with objects in sorted-key order, independent of the--- aeson\/hashable pair in the build plan. Used wherever JSON ends up--- /inside a string/ (the structured-output text block): structural--- comparison cannot see through a string, so those bytes must be--- deterministic — which also keeps repeated tool results stable for client--- prompt caching.-canonicalJsonText :: Value -> Text-canonicalJsonText = TE.decodeUtf8 . BSL.toStrict . encode . Canonical---- Encoding-only wrapper: objects are emitted via a sorted 'Map', arrays--- and atoms recurse. 'encode' uses 'toEncoding', so no KeyMap is rebuilt.-newtype Canonical = Canonical Value--instance ToJSON Canonical where-  toJSON (Canonical v) = v-  toEncoding (Canonical (Object o)) =-    toEncoding (Map.fromList [(Key.toText k, Canonical v) | (k, v) <- KM.toList o])-  toEncoding (Canonical (Array xs)) = toEncoding (fmap Canonical xs)-  toEncoding (Canonical v) = toEncoding v- -- | A record field for the generated serializers: absent when 'Nothing', -- mirroring how the generated schema marks 'Maybe' fields optional. omitNothing :: Text -> (a -> Value) -> Maybe a -> [Pair] omitNothing _ _ Nothing        = [] omitNothing name f (Just x)    = [(Key.fromText name, f x)]++-- ---------------------------------------------------------------------------+-- Forms+-- ---------------------------------------------------------------------------++-- | One property of a derived form: its shape, with a title and a default+-- (JSON, checked when the form was derived) added as raw keywords, since+-- t'Schema' has no place for them.+formFieldSchema :: Maybe Text -> Maybe Text -> Maybe Text -> SchemaType -> Schema+formFieldSchema title description defaultJson shape+  | null extras = Schema description shape+  | otherwise = Schema description (SchemaRaw (KM.union (KM.fromList extras) base))+  where+    base = case toJSON (Schema Nothing shape) of+      Object o -> o+      _        -> KM.empty+    extras = [("title", String t) | Just t <- [title]]+          ++ [("default", v) | Just j <- [defaultJson], Just v <- [decodeStrict (TE.encodeUtf8 j)]]
src/MCP/Server/Handlers.hs view
@@ -1,6 +1,10 @@ {-# LANGUAGE OverloadedStrings   #-} {-# LANGUAGE ScopedTypeVariables #-} +-- | Protocol dispatch: routes each JSON-RPC message to the matching+-- user handler and builds the response for the request's protocol era.+-- The transports call 'handleMcpMessage'; the per-method handlers are+-- exported for testing and custom transports. module MCP.Server.Handlers   ( -- * Core Message Handling     handleMcpMessage@@ -43,8 +47,11 @@ import qualified Data.Text              as T import           System.IO              (hPutStrLn, stderr) +import           MCP.Server.Internal.InputRound (beginRound,+                                                 inputRequiredResult) import           MCP.Server.JsonRpc import           MCP.Server.Protocol+import           MCP.Server.RequestState        (StateCodec) import           MCP.Server.Types  -- | Convert JSON Value to Text representation suitable for handlers@@ -152,15 +159,19 @@             }  -- | 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.+-- @resultType: \"complete\"@ (unless the result already carries one, as+-- an @input_required@ result does), the server's identity in result+-- @_meta@, and (for complete results of 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+    decorate o = case KM.lookup "resultType" o of+      Just _  -> insertMeta o+      Nothing -> insertCache $ KM.insert "resultType" (String "complete") $ insertMeta o     insertMeta o = KM.insert "_meta" (Object meta) o       where meta = case KM.lookup "_meta" o of               Just (Object m) -> KM.insert serverInfoKey serverInfoVal m@@ -184,19 +195,23 @@ -- result envelope; a request that does not is served exactly as before -- under the revision negotiated by @initialize@. --+-- @codec@ seals and opens the @requestState@ of multi round-trip requests+-- (see "MCP.Server.RequestState").+-- -- @emit@ is the transport's request-scoped notification sink: progress and -- client-log notifications for /this request/ are delivered through it, -- interleaved before the response (stdio's shared channel, or the -- request's SSE response stream on HTTP). handleMcpMessage :: McpServerInfo                  -> CacheHints+                 -> StateCodec                  -> NotificationSupport                  -> (JsonRpcNotification -> IO ())                  -> McpServerHandlers                  -> ClientContext                  -> JsonRpcMessage                  -> IO (Maybe JsonRpcMessage)-handleMcpMessage serverInfo hints notifSupport emit handlers ctx0 (JsonRpcMessageRequest req) = do+handleMcpMessage serverInfo hints codec notifSupport emit handlers ctx0 (JsonRpcMessageRequest req) = do   let params = requestParams req       declaredVersion = metaProtocolVersion params   case declaredVersion of@@ -231,12 +246,12 @@         "ping" -> handlePing req         "server/discover" -> handleServerDiscover serverInfo notifSupport handlers req         "prompts/list" -> handlePromptsList handlers ctx req-        "prompts/get" -> handlePromptsGet handlers ctx req+        "prompts/get" -> handlePromptsGet codec handlers ctx req         "resources/list" -> handleResourcesList handlers ctx req-        "resources/read" -> handleResourcesRead handlers ctx req+        "resources/read" -> handleResourcesRead codec handlers ctx req         "resources/templates/list" -> handleResourcesTemplatesList handlers ctx req         "tools/list" -> handleToolsList handlers ctx req-        "tools/call" -> handleToolsCall handlers ctx req+        "tools/call" -> handleToolsCall codec handlers ctx req         "completion/complete" -> handleCompletionComplete handlers ctx req         method -> return $ makeErrorResponse (requestId req) $ JsonRpcError           { errorCode = -32601@@ -248,7 +263,7 @@             else response       return $ Just $ JsonRpcMessageResponse response' -handleMcpMessage _ _ _ _ _ _ (JsonRpcMessageNotification notif) = do+handleMcpMessage _ _ _ _ _ _ _ (JsonRpcMessageNotification notif) = do   case notificationMethod notif of     "notifications/initialized" ->       hPutStrLn stderr "Received initialized notification - server is ready for operation"@@ -256,7 +271,7 @@       hPutStrLn stderr $ "Received unknown notification: " ++ T.unpack (notificationMethod notif)   return Nothing -handleMcpMessage _ _ _ _ _ _ (JsonRpcMessageResponse _) =+handleMcpMessage _ _ _ _ _ _ _ (JsonRpcMessageResponse _) =   return Nothing  -- | The 'reportProgress' action for a request: emits@@ -362,7 +377,7 @@ -- 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'.+-- 'handleMcpMessage'. handleServerDiscover :: McpServerInfo -> NotificationSupport -> McpServerHandlers -> JsonRpcRequest -> IO JsonRpcResponse handleServerDiscover serverInfo notifSupport handlers req = do   -- Modern clients receive notifications via subscriptions/listen, so both@@ -393,8 +408,8 @@       return $ makeSuccessResponse (requestId req) (toJSON response)  -- | Handle prompts/get request-handlePromptsGet :: McpServerHandlers -> ClientContext -> JsonRpcRequest -> IO JsonRpcResponse-handlePromptsGet handlers ctx req =+handlePromptsGet :: StateCodec -> McpServerHandlers -> ClientContext -> JsonRpcRequest -> IO JsonRpcResponse+handlePromptsGet codec handlers ctx req =   case prompts handlers of     Nothing -> return $ makeErrorResponse (requestId req) $ JsonRpcError       { errorCode = -32601@@ -419,20 +434,13 @@               -- 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)+              runRound codec ctx req+                (\c -> getHandler c (promptsGetName getReq) args)+                (\promptRes -> toJSON PromptsGetResponse+                    { promptsGetDescription = promptResultDescription promptRes+                    , promptsGetMessages = promptResultMessages promptRes+                    , promptsGetMeta = Nothing+                    })  -- | Handle resources/list request handleResourcesList :: McpServerHandlers -> ClientContext -> JsonRpcRequest -> IO JsonRpcResponse@@ -457,8 +465,8 @@       return $ makeSuccessResponse (requestId req) (toJSON response)  -- | Handle resources/read request-handleResourcesRead :: McpServerHandlers -> ClientContext -> JsonRpcRequest -> IO JsonRpcResponse-handleResourcesRead handlers ctx req =+handleResourcesRead :: StateCodec -> McpServerHandlers -> ClientContext -> JsonRpcRequest -> IO JsonRpcResponse+handleResourcesRead codec handlers ctx req =   case resources handlers of     Nothing -> return $ makeErrorResponse (requestId req) $ JsonRpcError       { errorCode = -32601@@ -479,19 +487,12 @@               , 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)+            Success readReq ->+              runRound codec ctx req+                (\c -> readHandler c (resourcesReadUri readReq))+                (\resourceContent -> toJSON ResourcesReadResponse+                    { resourcesReadContents = [resourceContent]+                    })  -- | Handle resources/templates/list request handleResourcesTemplatesList :: McpServerHandlers -> ClientContext -> JsonRpcRequest -> IO JsonRpcResponse@@ -564,8 +565,8 @@       return $ makeSuccessResponse (requestId req) (toJSON response)  -- | Handle tools/call request-handleToolsCall :: McpServerHandlers -> ClientContext -> JsonRpcRequest -> IO JsonRpcResponse-handleToolsCall handlers ctx req =+handleToolsCall :: StateCodec -> McpServerHandlers -> ClientContext -> JsonRpcRequest -> IO JsonRpcResponse+handleToolsCall codec handlers ctx req =   case tools handlers of     Nothing -> return $ makeErrorResponse (requestId req) $ JsonRpcError       { errorCode = -32601@@ -589,21 +590,44 @@             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)+              runRound codec ctx req+                (\c -> callHandler c (toolsCallName callReq) args)+                (\toolRes -> toJSON ToolsCallResponse+                    { toolsCallContent = toolResultContent toolRes+                    , toolsCallIsError = if toolResultIsError toolRes then Just True else Nothing+                    , toolsCallStructuredContent = toolResultStructured toolRes+                    , toolsCallMeta = toolResultMeta toolRes+                    })++-- | Run a handler that may ask the client for input (multi round-trip+-- requests): read a retry's answers and verified state into the context,+-- run the handler, and answer with its result, its error, or an+-- @input_required@ result.+runRound :: StateCodec+         -> ClientContext+         -> JsonRpcRequest+         -> (ClientContext -> IO (Either Error (Outcome a)))+         -> (a -> Value)+         -> IO JsonRpcResponse+runRound codec ctx0 req run render = do+  let rid = requestId req+      method = requestMethod req+      params = requestParams req+  prepared <- beginRound codec method params ctx0+  case prepared of+    Left err -> pure (makeErrorResponse rid err)+    Right ctx -> do+      result <- run ctx+      case result of+        Left err -> pure $ makeErrorResponse rid JsonRpcError+          { errorCode = errorCodeFromMcpError err+          , errorMessage = errorMessageFromMcpError err+          , errorData = Nothing+          }+        Right (Complete a) -> pure (makeSuccessResponse rid (render a))+        Right (InputRequired requests state) -> do+          answer <- inputRequiredResult codec method params ctx requests state+          pure (either (makeErrorResponse rid) (makeSuccessResponse rid) answer)  -- | Convert MCP error to JSON-RPC error code errorCodeFromMcpError :: Error -> Int
+ src/MCP/Server/Internal/InputRound.hs view
@@ -0,0 +1,182 @@+{-# LANGUAGE OverloadedStrings #-}++-- | The library's side of multi round-trip requests: reading a retry's+-- @inputResponses@ and @requestState@ into the handler's context, and+-- turning a handler's 'InputRequired' into an @input_required@ result.+--+-- The sealed state carries, besides the handler's own state, what the spec+-- asks servers to bind it to: the caller (a digest of the principal), the+-- originating request (the method and a digest of its parameters) and an+-- expiry. All four are checked before the handler runs again.+module MCP.Server.Internal.InputRound+  ( beginRound+  , inputRequiredResult+  ) where++import           Control.Monad            (unless, when)+import           Data.Aeson+import qualified Data.Aeson.Key           as Key+import qualified Data.Aeson.KeyMap        as KM+import           Data.Aeson.Types         (parseEither)+import qualified Data.ByteString.Lazy     as BSL+import           Data.Map                 (Map)+import qualified Data.Map                 as Map+import           Data.Text                (Text)+import qualified Data.Text                as T+import           Data.Time.Clock.POSIX    (getPOSIXTime)+import           System.IO                (hPutStrLn, stderr)++import           MCP.Server.Internal.Json (jsonDigest)+import           MCP.Server.JsonRpc       (JsonRpcError (..))+import           MCP.Server.RequestState+import           MCP.Server.Types++-- | What is sealed into a request state.+data Payload = Payload+  { payloadMethod    :: Text+  , payloadParams    :: Text        -- ^ Digest of the request parameters+  , payloadPrincipal :: Text        -- ^ Digest of the caller's principal+  , payloadExpires   :: Integer     -- ^ POSIX seconds+  , payloadKeys      :: [Text]      -- ^ The keys of the requests issued+  , payloadState     :: Maybe Text  -- ^ The handler's own state+  }++instance ToJSON Payload where+  toJSON p = object+    [ "v" .= (1 :: Int)+    , "m" .= payloadMethod p+    , "d" .= payloadParams p+    , "p" .= payloadPrincipal p+    , "e" .= payloadExpires p+    , "k" .= payloadKeys p+    , "s" .= payloadState p+    ]++instance FromJSON Payload where+  parseJSON = withObject "Payload" $ \o -> do+    version <- o .: "v"+    unless (version == (1 :: Int)) $ fail "unsupported state version"+    Payload <$> o .: "m" <*> o .: "d" <*> o .: "p" <*> o .: "e" <*> o .: "k" <*> o .:? "s"++-- | Prepare the context for a call that may be a retry. Legacy requests+-- have no multi round-trip semantics, so their context is left alone.+--+-- Answers are only taken from a retry that carries the state of the round+-- that asked, and only for the keys that round issued: anything else in+-- @inputResponses@ is ignored, as the spec asks, so a client cannot+-- volunteer answers the handler never requested. A retry's state is+-- verified before anything else; a failure is a @-32602@ the handler never+-- sees.+beginRound :: StateCodec -> Text -> Maybe Value -> ClientContext+           -> IO (Either JsonRpcError ClientContext)+beginRound codec method params ctx+  | Nothing <- clientProtocolVersion ctx = pure (Right ctx)+  | otherwise = case field "requestState" of+      Nothing -> pure (Right ctx)+      Just (String sealed) -> do+        opened <- openPayload codec method params ctx sealed+        pure $ do+          (keys, state) <- opened+          responses <- issuedResponses keys+          Right ctx { clientInputResponses = responses, clientRequestState = state }+      Just _ -> pure $ Left $ invalidParams "Invalid requestState: expected a string"+  where+    field key = case params of+      Just (Object o) -> KM.lookup key o+      _               -> Nothing+    issuedResponses keys = case field "inputResponses" of+      Nothing          -> Right Map.empty+      Just (Object rs) -> Map.fromList <$> traverse parseIssued+        [ (key, v) | key <- keys, Just v <- [KM.lookup (Key.fromText key) rs] ]+      Just _           -> Left $ invalidParams "Invalid inputResponses: expected an object"+    parseIssued (key, v) = case parseEither parseJSON v of+      Right response -> Right (key, response)+      Left err -> Left $ invalidParams $ "Invalid inputResponses: " <> key <> ": " <> T.pack err++-- | The @input_required@ result for a handler's requests and state, or a+-- @-32603@ when the handler asked for input this client cannot provide+-- (a server bug: handlers should check 'clientSupportsInput').+inputRequiredResult :: StateCodec -> Text -> Maybe Value -> ClientContext+                    -> Map Text InputRequest -> Maybe Text+                    -> IO (Either JsonRpcError Value)+inputRequiredResult codec method params ctx requests state+  | Nothing <- clientProtocolVersion ctx =+      pure $ Left $ internalError+        "the handler requested input, which the client's protocol revision does not support"+  | unsupported@(_ : _) <- [key | (key, request) <- Map.toList requests+                                , not (clientSupportsInput ctx request)] =+      pure $ Left $ internalError $+        "the handler requested input the client cannot provide ("+          <> T.intercalate ", " unsupported <> ")"+  | otherwise = do+      now <- getPOSIXTime+      let payload = Payload+            { payloadMethod = method+            , payloadParams = paramsDigest params+            , payloadPrincipal = principalDigest ctx+            , payloadExpires = floor (now + stateLifetime codec)+            , payloadKeys = Map.keys requests+            , payloadState = state+            }+      sealed <- sealState codec (BSL.toStrict (encode payload))+      pure $ Right $ object+        [ "resultType" .= ("input_required" :: Text)+        , "inputRequests" .= requests+        , "requestState" .= sealed+        ]+  where+    internalError message = JsonRpcError+      { errorCode = -32603+      , errorMessage = "Internal error: " <> message+      , errorData = Nothing+      }++openPayload :: StateCodec -> Text -> Maybe Value -> ClientContext -> Text+            -> IO (Either JsonRpcError ([Text], Maybe Text))+openPayload codec method params ctx sealed = do+  opened <- openState codec sealed+  now <- getPOSIXTime+  let check = do+        bytes <- case opened of+          Left StateUnknownKey -> Left (unknownKey, "unknown key id")+          Left StateInvalid    -> Left (invalid, "could not be opened")+          Right b              -> Right b+        payload <- either (\e -> Left (invalid, "bad payload: " <> T.pack e)) Right $+          eitherDecodeStrict bytes+        when (payloadMethod payload /= method) $+          Left (invalid, "issued for " <> payloadMethod payload <> ", presented on " <> method)+        when (payloadParams payload /= paramsDigest params) $+          Left (invalid, "issued for different request parameters")+        when (payloadPrincipal payload /= principalDigest ctx) $+          Left (invalid, "issued to a different principal")+        when (fromInteger (payloadExpires payload) < now) $+          Left (expired, "expired")+        pure (payloadKeys payload, payloadState payload)+  case check of+    Right issued -> pure (Right issued)+    Left (err, reason) -> do+      hPutStrLn stderr $ "Rejected requestState on " <> T.unpack method <> ": " <> T.unpack reason+      pure (Left err)+  where+    invalid = invalidParams "Invalid requestState"+    expired = invalidParams "requestState has expired: retry the request without it"+    unknownKey = invalidParams+      "requestState was issued by another server instance or under a retired key"++invalidParams :: Text -> JsonRpcError+invalidParams message = JsonRpcError+  { errorCode = -32602+  , errorMessage = message+  , errorData = Nothing+  }++-- | Digest of the request parameters that define the request: everything+-- but the round's own fields and @_meta@.+paramsDigest :: Maybe Value -> Text+paramsDigest params = jsonDigest $ case params of+  Just (Object o) -> Object (foldr KM.delete o ["inputResponses", "requestState", "_meta"])+  Just v          -> v+  Nothing         -> Null++principalDigest :: ClientContext -> Text+principalDigest = jsonDigest . maybe Null id . clientPrincipal
+ src/MCP/Server/Internal/Json.hs view
@@ -0,0 +1,50 @@+{-# LANGUAGE OverloadedStrings #-}++-- | Deterministic JSON encoding, for JSON that ends up inside a string or a+-- digest, where structural comparison cannot see through the bytes.+module MCP.Server.Internal.Json+  ( canonicalJson+  , canonicalJsonText+  , jsonDigest+  ) where++import qualified Crypto.Hash          as Hash++import           Data.Aeson           (ToJSON (..), Value (..), encode)+import qualified Data.Aeson.Key       as Key+import qualified Data.Aeson.KeyMap    as KM+import qualified Data.ByteArray       as BA+import qualified Data.ByteString.Base64.URL as B64+import qualified Data.ByteString.Lazy as BSL+import qualified Data.Map             as Map+import           Data.Text            (Text)+import qualified Data.Text.Encoding   as TE++-- | Compact JSON with objects in sorted-key order, independent of the+-- aeson\/hashable pair in the build plan.+canonicalJson :: Value -> BSL.ByteString+canonicalJson = encode . Canonical++-- | 'canonicalJson' as text. Used wherever JSON ends up /inside a string/+-- (the structured-output text block): structural comparison cannot see+-- through a string, so those bytes must be deterministic — which also+-- keeps repeated tool results stable for client prompt caching.+canonicalJsonText :: Value -> Text+canonicalJsonText = TE.decodeUtf8 . BSL.toStrict . canonicalJson++-- | SHA-256 of 'canonicalJson', base64url-encoded: equal for structurally+-- equal values.+jsonDigest :: Value -> Text+jsonDigest v = TE.decodeUtf8 $ B64.encode $ BA.convert+  (Hash.hashlazy (canonicalJson v) :: Hash.Digest Hash.SHA256)++-- Encoding-only wrapper: objects are emitted via a sorted 'Map', arrays+-- and atoms recurse. 'encode' uses 'toEncoding', so no KeyMap is rebuilt.+newtype Canonical = Canonical Value++instance ToJSON Canonical where+  toJSON (Canonical v) = v+  toEncoding (Canonical (Object o)) =+    toEncoding (Map.fromList [(Key.toText k, Canonical v) | (k, v) <- KM.toList o])+  toEncoding (Canonical (Array xs)) = toEncoding (fmap Canonical xs)+  toEncoding (Canonical v) = toEncoding v
src/MCP/Server/JsonRpc.hs view
@@ -1,6 +1,8 @@ {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE OverloadedStrings #-} +-- | JSON-RPC 2.0 messages: requests, responses, errors and notifications,+-- with parsing and encoding. module MCP.Server.JsonRpc   ( -- * JSON-RPC Types     JsonRpcRequest(..)
src/MCP/Server/Notifications.hs view
@@ -2,9 +2,9 @@  -- | Server-initiated change notifications. ----- Create a notifier with 'newMcpNotifier', hand the 'NotificationSource' to+-- Create a notifier with 'newMcpNotifier', hand the t'NotificationSource' to -- a transport via its configuration ('MCP.Server.Transport.Stdio.StdioConfig'--- \/ 'MCP.Server.Transport.Http.HttpConfig'), keep the 'McpNotifier', and+-- \/ 'MCP.Server.Transport.Http.HttpConfig'), keep the t'McpNotifier', and -- call its actions whenever your tool\/prompt\/resource lists (or a specific -- resource) change: --
src/MCP/Server/Protocol.hs view
@@ -1,6 +1,8 @@ {-# LANGUAGE DeriveGeneric     #-} {-# LANGUAGE OverloadedStrings #-} +-- | Request and response payloads for each MCP method, and the protocol+-- revisions this library supports. module MCP.Server.Protocol   ( -- * MCP Protocol Messages     InitializeRequest(..)@@ -85,7 +87,7 @@   ]  -- | Every revision this library implements, newest first — what--- @server\/discover@ and 'UnsupportedProtocolVersionError' report.+-- @server\/discover@ and @UnsupportedProtocolVersionError@ report. allVersions :: [Text] allVersions = modernVersions ++ supportedVersions 
+ src/MCP/Server/RequestState.hs view
@@ -0,0 +1,157 @@+{-# LANGUAGE OverloadedStrings #-}++-- | Protection for the @requestState@ of multi round-trip requests.+--+-- When a handler answers with 'MCP.Server.Types.InputRequired', the+-- client must echo the request state back on its retry. The specification+-- treats that state as attacker-controlled, so the library never hands it+-- to the client in the clear: it binds the state to the caller, the+-- original request and an expiry, then seals it with a t'StateCodec'.+--+-- The default codec, 'ephemeralStateCodec', encrypts with a random key+-- created when the transport starts. That is right for stdio and for a+-- single HTTP instance. Deployments with several instances behind a load+-- balancer must share a key, with 'aeadStateCodec':+--+-- > Just key = stateKey "2026-10" keyBytes   -- 32 secret bytes+-- > config = defaultHttpConfig { httpStateCodec = Just (aeadStateCodec key []) }+--+-- To rotate keys, seal with the new key and keep accepting the old one+-- until its states have expired: @aeadStateCodec newKey [oldKey]@.+module MCP.Server.RequestState+  ( -- * Codecs+    StateCodec(..)+  , StateError(..)+  , defaultStateLifetime+  , aeadStateCodec+  , ephemeralStateCodec+    -- * Keys+  , StateKey+  , stateKey+  , stateKeyId+  ) where++import           Crypto.Cipher.ChaChaPoly1305 (aeadChacha20poly1305Init)+import           Crypto.Cipher.Types          (AuthTag (..), aeadSimpleDecrypt,+                                               aeadSimpleEncrypt)+import           Crypto.Error                 (maybeCryptoError)+import           Crypto.Random                (getRandomBytes)+import qualified Data.ByteArray               as BA+import           Data.ByteString              (ByteString)+import qualified Data.ByteString              as BS+import qualified Data.ByteString.Base64.URL   as B64+import           Data.Char                    (isAsciiLower, isAsciiUpper,+                                               isDigit)+import           Data.Text                    (Text)+import qualified Data.Text                    as T+import qualified Data.Text.Encoding           as TE+import           Data.Time.Clock              (NominalDiffTime)+import           Numeric                      (showHex)++-- | How sealed request state is produced and checked. The library builds+-- the plaintext (the caller, the request, the expiry and the handler's+-- state) and verifies it after 'openState'; a codec only has to keep it+-- confidential and tamper-evident.+--+-- Replace the codec to manage keys elsewhere, or to keep states on the+-- server and hand the client only a reference.+data StateCodec = StateCodec+  { sealState :: ByteString -> IO Text+    -- ^ Seal plaintext into the opaque string sent to the client.+  , openState :: Text -> IO (Either StateError ByteString)+    -- ^ Recover the plaintext from a string the client sent back.+  , stateLifetime :: NominalDiffTime+    -- ^ How long a state stays valid after it is issued.+  }++-- | Why a state could not be opened.+data StateError+  = StateUnknownKey+    -- ^ Sealed under a key this codec does not have: issued by another+    --   server instance, or under a key that has since been retired.+  | StateInvalid+    -- ^ Malformed, or altered after it was sealed.+  deriving (Show, Eq)++-- | 60 minutes: long enough for a user to fill in a form.+defaultStateLifetime :: NominalDiffTime+defaultStateLifetime = 3600++-- | A 256-bit key for 'aeadStateCodec', with an id that is stored next to+-- each sealed state so the right key is used to open it.+data StateKey = StateKey Text ByteString++-- | A key from its id and 32 secret bytes. The id is 1–32 characters of+-- ASCII letters, digits, @-@ and @_@; it is visible to clients, so it+-- should not reveal anything. 'Nothing' if either is invalid.+stateKey :: Text -> ByteString -> Maybe StateKey+stateKey kid secret+  | validId && BS.length secret == 32 = Just (StateKey kid secret)+  | otherwise = Nothing+  where+    validId = not (T.null kid) && T.length kid <= 32 && T.all idChar kid+    idChar c = isAsciiLower c || isAsciiUpper c || isDigit c || c == '-' || c == '_'++-- | The id of a key.+stateKeyId :: StateKey -> Text+stateKeyId (StateKey kid _) = kid++-- | Seal with ChaCha20-Poly1305 under the first key, with a random nonce+-- for every state; open with that key or any of the others. States last+-- 'defaultStateLifetime'; set 'stateLifetime' to change that.+aeadStateCodec :: StateKey -> [StateKey] -> StateCodec+aeadStateCodec current older = StateCodec+  { sealState = sealWith current+  , openState = pure . openWith (current : older)+  , stateLifetime = defaultStateLifetime+  }++-- | 'aeadStateCodec' with a random key, created afresh by each call. States+-- cannot be opened by another process, or after a restart.+ephemeralStateCodec :: IO StateCodec+ephemeralStateCodec = do+  secret <- getRandomBytes 32+  idBytes <- getRandomBytes 6 :: IO ByteString+  let kid = "ephemeral-" <> T.pack (concatMap hex (BS.unpack idBytes))+      hex b = let s = showHex b "" in if length s < 2 then '0' : s else s+  case stateKey kid secret of+    Just key -> pure (aeadStateCodec key [])+    Nothing  -> error "ephemeralStateCodec: generated an invalid key"++-- Wire format: @<key id>.<base64url(nonce || ciphertext || tag)>@. The key+-- id and a format version are authenticated as associated data.++nonceSize, tagSize :: Int+nonceSize = 12+tagSize = 16++associatedData :: Text -> ByteString+associatedData kid = "mcp-server/requestState/v1:" <> TE.encodeUtf8 kid++sealWith :: StateKey -> ByteString -> IO Text+sealWith (StateKey kid secret) plaintext = do+  nonce <- getRandomBytes nonceSize :: IO ByteString+  case maybeCryptoError (aeadChacha20poly1305Init secret nonce) of+    Nothing   -> error "aeadStateCodec: invalid key or nonce"+    Just aead -> do+      let (AuthTag tag, ciphertext) =+            aeadSimpleEncrypt aead (associatedData kid) plaintext tagSize+          blob = nonce <> ciphertext <> BA.convert tag+      pure (kid <> "." <> TE.decodeUtf8 (B64.encode blob))++openWith :: [StateKey] -> Text -> Either StateError ByteString+openWith keys sealed = do+  let (kid, rest) = T.breakOn "." sealed+  StateKey _ secret <- maybe (Left StateUnknownKey) Right $+    lookup kid [(k, key) | key@(StateKey k _) <- keys]+  blob <- either (const (Left StateInvalid)) Right $+    B64.decode (TE.encodeUtf8 (T.drop 1 rest))+  if BS.length blob < nonceSize + tagSize+    then Left StateInvalid+    else do+      let (nonce, body) = BS.splitAt nonceSize blob+          (ciphertext, tag) = BS.splitAt (BS.length body - tagSize) body+      aead <- maybe (Left StateInvalid) Right $+        maybeCryptoError (aeadChacha20poly1305Init secret nonce)+      maybe (Left StateInvalid) Right $+        aeadSimpleDecrypt aead (associatedData kid) ciphertext (AuthTag (BA.convert tag))
src/MCP/Server/Transport/Http.hs view
@@ -1,6 +1,9 @@ {-# LANGUAGE OverloadedStrings   #-} {-# LANGUAGE ScopedTypeVariables #-} +-- | The Streamable HTTP transport, as a standalone Warp server+-- ('transportRunHttp') or a WAI application to mount in an existing one+-- ('mcpApplication'). module MCP.Server.Transport.Http   ( -- * HTTP Transport     HttpConfig(..)@@ -44,13 +47,14 @@ import           MCP.Server.Handlers import           MCP.Server.JsonRpc import           MCP.Server.Notifications+import           MCP.Server.RequestState (StateCodec, ephemeralStateCodec) 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+-- Note: t'HttpConfig' has no 'Show'/'Eq' instances because 'httpAuthorize' is a -- function. data HttpConfig = HttpConfig   { httpPort      :: Int      -- ^ Port to listen on@@ -63,7 +67,7 @@       --   '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+      --   handler t'ClientContext' as 'clientPrincipal' — while 'Nothing' rejects       --   the request with @401@. Validation and principal assignment are left       --   entirely to the caller.   , httpAllowedOrigins :: Maybe [Text]@@ -78,6 +82,12 @@   , httpCacheHints :: CacheHints       -- ^ Cacheability hints stamped onto modern (2026-07-28+) list/read       --   results.+  , httpStateCodec :: Maybe StateCodec+      -- ^ Seals the @requestState@ of multi round-trip requests (see+      --   "MCP.Server.RequestState"). 'Nothing' uses a random key created+      --   by 'mcpApplication', which only that application can open: a+      --   deployment with several instances must configure a shared key+      --   with 'MCP.Server.RequestState.aeadStateCodec'.   , httpNotifications :: Maybe NotificationSource       -- ^ When configured, @subscriptions\/listen@ (2026-07-28) is served as       --   a long-lived SSE stream delivering the change notifications the@@ -97,6 +107,7 @@   , httpAuthorize = Nothing   , httpAllowedOrigins = Nothing   , httpCacheHints = defaultCacheHints+  , httpStateCodec = Nothing   , httpNotifications = Nothing   } @@ -144,7 +155,8 @@                  Warp.defaultSettings    putStrLn $ "Starting MCP HTTP server on " ++ httpHost config ++ ":" ++ show (httpPort config) ++ httpEndpoint config-  Warp.runSettings settings (mcpApplication config serverInfo handlers)+  app <- mcpApplication config serverInfo handlers+  Warp.runSettings settings app  -- | The MCP endpoint as a plain WAI 'Wai.Application', for embedding into -- an existing WAI stack (your own Warp settings, TLS, middleware, or a@@ -157,8 +169,17 @@ -- Origin validation and bearer authentication run per 'httpAllowedOrigins' -- and 'httpAuthorize', and @subscriptions\/listen@ streams work as long as -- the surrounding stack does not buffer streaming responses.-mcpApplication :: HttpConfig -> McpServerInfo -> McpServerHandlers -> Wai.Application-mcpApplication config serverInfo handlers req respond0 = do+--+-- Building the application creates the 'httpStateCodec' default (a random+-- key) when none is configured, so build it once and serve every request+-- with the same application.+mcpApplication :: HttpConfig -> McpServerInfo -> McpServerHandlers -> IO Wai.Application+mcpApplication config serverInfo handlers = do+  codec <- maybe ephemeralStateCodec pure (httpStateCodec config)+  pure (mcpApplicationWith codec config serverInfo handlers)++mcpApplicationWith :: StateCodec -> HttpConfig -> McpServerInfo -> McpServerHandlers -> Wai.Application+mcpApplicationWith codec config serverInfo handlers req respond0 = do   -- Log the request   logVerbose config $ "HTTP " ++ show (Wai.requestMethod req) ++ " " ++ T.unpack (TE.decodeUtf8 $ Wai.rawPathInfo req) @@ -203,7 +224,7 @@           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+            then handleMcpRequest codec 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@@ -228,8 +249,8 @@     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+handleMcpRequest :: StateCodec -> HttpConfig -> McpServerInfo -> McpServerHandlers -> ClientContext -> Wai.Request -> (Wai.Response -> IO Wai.ResponseReceived) -> IO Wai.ResponseReceived+handleMcpRequest codec 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,@@ -261,10 +282,10 @@           -- but only for dispatchable request methods, so unknown methods           -- keep their 404 and notification bodies keep their 202           | wantsStreamingResponse peek-          -> handleStreamingRequest config serverInfo handlers ctx body respond+          -> handleStreamingRequest codec config serverInfo handlers ctx 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+              handleJsonRpcRequest codec config serverInfo handlers ctx modern body respond      -- OPTIONS for CORS preflight     "OPTIONS" -> respond $ Wai.responseLBS@@ -324,6 +345,8 @@ peekIsModern peek =   peekMetaVersion peek /= Nothing || peekMethod peek == Just "server/discover" +-- | Extract the t'BodyPeek' fields from a raw request body. A body that is+-- not a JSON object yields a peek with every field empty. peekBody :: BSL.ByteString -> BodyPeek peekBody body = case decode body of   Just (Object o) -> BodyPeek@@ -465,8 +488,8 @@ -- doubles as the disconnect detector: when the client closes the stream the -- next keep-alive write throws, the race cancels the handler task, and -- nothing further is produced for the request.-handleStreamingRequest :: HttpConfig -> McpServerInfo -> McpServerHandlers -> ClientContext -> BSL.ByteString -> (Wai.Response -> IO Wai.ResponseReceived) -> IO Wai.ResponseReceived-handleStreamingRequest config serverInfo handlers ctx body respond =+handleStreamingRequest :: StateCodec -> HttpConfig -> McpServerInfo -> McpServerHandlers -> ClientContext -> BSL.ByteString -> (Wai.Response -> IO Wai.ResponseReceived) -> IO Wai.ResponseReceived+handleStreamingRequest codec config serverInfo handlers ctx body respond =   respond $ Wai.responseStream status200 sseHeaders $ \write flush -> do     -- One writer at a time, and a cancellation arriving mid-write cannot     -- split an event in half@@ -496,7 +519,7 @@                 threadDelay 5000000                 sendChunk $ B.byteString ": keep-alive\n\n"           outcome <- race-            (handleMcpMessage serverInfo (httpCacheHints config)+            (handleMcpMessage serverInfo (httpCacheHints config) codec               (httpNotifSupport config) emit handlers ctx message)             keepAlive           case outcome of@@ -505,8 +528,8 @@             Right ()                -> pure ()  -- | 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+handleJsonRpcRequest :: StateCodec -> HttpConfig -> McpServerInfo -> McpServerHandlers -> ClientContext -> Bool -> BSL.ByteString -> (Wai.Response -> IO Wai.ResponseReceived) -> IO Wai.ResponseReceived+handleJsonRpcRequest codec config serverInfo handlers ctx modern body respond = do   case eitherDecode body of     Left err -> do       hPutStrLn stderr $ "JSON parse error: " ++ err@@ -519,11 +542,11 @@           , errorData = Nothing           }) -    Right jsonValue -> handleSingleJsonRpc config serverInfo handlers ctx modern jsonValue respond+    Right jsonValue -> handleSingleJsonRpc codec 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+handleSingleJsonRpc :: StateCodec -> HttpConfig -> McpServerInfo -> McpServerHandlers -> ClientContext -> Bool -> Value -> (Wai.Response -> IO Wai.ResponseReceived) -> IO Wai.ResponseReceived+handleSingleJsonRpc codec config serverInfo handlers ctx modern jsonValue respond = do   case parseJsonRpcMessage jsonValue of     Left err -> do       hPutStrLn stderr $ "JSON-RPC parse error: " ++ err@@ -540,7 +563,7 @@       logVerbose config $ "Processing HTTP message: " ++ show (getMessageSummary message)       -- Single-JSON responses have no channel for request-scoped       -- notifications; requests that want them take the streaming path.-      maybeResponse <- handleMcpMessage serverInfo (httpCacheHints config)+      maybeResponse <- handleMcpMessage serverInfo (httpCacheHints config) codec         (httpNotifSupport config) (\_ -> pure ()) handlers ctx message        case maybeResponse of
src/MCP/Server/Transport/Stdio.hs view
@@ -1,6 +1,8 @@ {-# LANGUAGE OverloadedStrings   #-} {-# LANGUAGE ScopedTypeVariables #-} +-- | The stdio transport: newline-delimited JSON-RPC on stdin and stdout,+-- with logging on stderr. module MCP.Server.Transport.Stdio   ( -- * STDIO Transport     transportRunStdio@@ -32,6 +34,7 @@ import           MCP.Server.JsonRpc import           MCP.Server.Notifications import           MCP.Server.Protocol    (modernVersions)+import           MCP.Server.RequestState (StateCodec, ephemeralStateCodec) import           MCP.Server.Types  -- | STDIO transport configuration@@ -43,6 +46,10 @@   , stdioCacheHints :: CacheHints     -- ^ Cacheability hints stamped onto modern (2026-07-28+) list/read     -- results.+  , stdioStateCodec :: Maybe StateCodec+    -- ^ Seals the @requestState@ of multi round-trip requests (see+    -- "MCP.Server.RequestState"). 'Nothing' uses a random key created when+    -- the transport starts, which is all a single stdio process needs.   , stdioNotifications :: Maybe NotificationSource     -- ^ When configured, change notifications are delivered: modern clients     -- via @subscriptions\/listen@, legacy clients as spontaneous@@ -56,6 +63,7 @@ defaultStdioConfig = StdioConfig   { stdioVerbose = False   , stdioCacheHints = defaultCacheHints+  , stdioStateCodec = Nothing   , stdioNotifications = Nothing   } @@ -81,6 +89,8 @@   hSetEncoding stderr utf8   hSetEncoding stdout utf8 +  codec <- maybe ephemeralStateCodec pure (stdioStateCodec config)+   -- Subscription threads, request tasks and the main loop share stdout:   -- one line at a time.   writeLock <- newMVar ()@@ -169,7 +179,7 @@     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+      response <- handleMcpMessage serverInfo (stdioCacheHints config) codec notifSupport sendNotification handlers anonymousContext message       case response of         Just responseMsg -> do           logLine $ "Sending response for: " <> T.pack (show (getMessageSummary message))
src/MCP/Server/Types.hs view
@@ -1,7 +1,11 @@+{-# LANGUAGE DeriveFunctor     #-} {-# LANGUAGE DeriveGeneric     #-} {-# LANGUAGE DeriveLift        #-} {-# LANGUAGE OverloadedStrings #-} +-- | The library's core types: the content, results, definitions and JSON+-- Schemas that handlers produce, the handler signatures themselves, and the+-- server information and capabilities advertised to clients. module MCP.Server.Types   ( -- * Content Types     Content(..)@@ -75,6 +79,13 @@   , CompletionCapabilities(..)   , LoggingCapabilities(..) +    -- * Asking the client for input+  , Outcome(..)+  , InputRequest(..)+  , InputResponse(..)+  , ElicitAction(..)+  , clientSupportsInput+     -- * Handler Types   , PromptListHandler   , PromptGetHandler@@ -104,9 +115,13 @@ import           Language.Haskell.TH.Syntax (Lift) import           Network.URI      (URI, parseURI) +-- | The name of a prompt, as in @prompts\/get@. type PromptName = Text+-- | The name of a tool, as in @tools\/call@. type ToolName = Text+-- | The name of a prompt or tool argument. type ArgumentName = Text+-- | A prompt argument's value; prompt arguments are always strings. type ArgumentValue = Text  -- | Content that can be returned by prompts, resources, and tools@@ -119,7 +134,7 @@   | ContentResourceLink ResourceDefinition     -- ^ A reference to a resource the client can read separately   | ContentAnnotated Annotations Content-    -- ^ A content block carrying 'Annotations'; the annotations are merged+    -- ^ A content block carrying t'Annotations'; the annotations are merged     -- into the inner block's JSON object. Do not nest.   deriving (Show, Eq, Generic) @@ -202,11 +217,13 @@     <*> o .:? "priority"     <*> o .:? "lastModified" +-- | The payload of an image content block. data ContentImageData = ContentImageData   { contentImageData     :: Text  -- ^ base64-encoded image data   , contentImageMimeType :: Text   } deriving (Show, Eq, Generic) +-- | The payload of an audio content block. data ContentAudioData = ContentAudioData   { contentAudioData     :: Text  -- ^ base64-encoded audio data   , contentAudioMimeType :: Text@@ -394,7 +411,7 @@ toolError msg = (toolResult [ContentText msg]) { toolResultIsError = True }  -- | Types a tool handler may return. Returning plain 'Content' (or 'Text')--- keeps simple handlers simple; return a full 'ToolResult' for multiple+-- keeps simple handlers simple; return a full t'ToolResult' for multiple -- content blocks, structured content, or execution errors. class ToToolResult a where   toToolResult :: a -> ToolResult@@ -406,7 +423,7 @@   toToolResult c = toolResult [c]  -- | Concatenates content; a merged result is an error if any element is--- ('toolResultIsError' is OR-ed), and the first present 'structuredContent'+-- ('toolResultIsError' is OR-ed), and the first present @structuredContent@ -- and @_meta@ are kept. instance ToToolResult a => ToToolResult [a] where   toToolResult xs = ToolResult@@ -438,7 +455,7 @@   | ToolOutputError Text     -- ^ An execution failure, reported with @isError@ (see 'toolError').   | ToolOutputRaw ToolResult-    -- ^ Full control: return this 'ToolResult' as-is, with no structured+    -- ^ Full control: return this t'ToolResult' as-is, with no structured     -- content implied.   deriving (Show, Eq) @@ -450,7 +467,7 @@   } deriving (Show, Eq, Generic)  -- | Types a prompt handler may return. Plain 'Content' (or 'Text') becomes a--- single user message; return 'PromptResult' or @['PromptMessage']@ for+-- single user message; return t'PromptResult' or @[t'PromptMessage']@ for -- multi-message conversations or assistant roles. class ToPromptResult a where   toPromptResult :: a -> PromptResult@@ -577,7 +594,7 @@   } deriving (Show, Eq, Generic)  -- | A prompt definition with only the required fields set; record-update--- the optional ones (constructing 'PromptDefinition' directly breaks when+-- the optional ones (constructing t'PromptDefinition' directly breaks when -- fields are added). mkPromptDefinition :: Text -> Text -> [ArgumentDefinition] -> PromptDefinition mkPromptDefinition name description args = PromptDefinition@@ -756,6 +773,8 @@     [ fmap ("listChanged" .=) (promptListChanged caps)     ] +-- | The @resources@ capability: whether the server supports subscriptions+-- and list-change notifications. data ResourceCapabilities = ResourceCapabilities   { resourceSubscribe   :: Maybe Bool   , resourceListChanged :: Maybe Bool@@ -767,6 +786,8 @@     , fmap ("listChanged" .=) (resourceListChanged caps)     ] +-- | The @tools@ capability: whether the server sends list-change+-- notifications. data ToolCapabilities = ToolCapabilities   { toolListChanged :: Maybe Bool   } deriving (Show, Eq, Generic)@@ -776,6 +797,7 @@     [ fmap ("listChanged" .=) (toolListChanged caps)     ] +-- | The @completions@ capability, which has no options. data CompletionCapabilities = CompletionCapabilities   { -- No sub-capabilities defined   } deriving (Show, Eq, Generic)@@ -783,6 +805,7 @@ instance ToJSON CompletionCapabilities where   toJSON _ = object [] +-- | The @logging@ capability, which has no options. data LoggingCapabilities = LoggingCapabilities   { -- No specific sub-capabilities for logging yet   } deriving (Show, Eq, Generic)@@ -838,6 +861,15 @@       --   (@notifications\/message@). A no-op unless the request declared       --   @io.modelcontextprotocol\/logLevel@ (the spec forbids emitting       --   otherwise); messages below the declared level are dropped.+  , clientInputResponses :: Map Text InputResponse+      -- ^ On a retry after 'InputRequired': the client's answers, keyed as+      --   the handler keyed its requests. Only answers to the requests that+      --   round issued are passed on; anything else the client sends is+      --   ignored. Empty otherwise. Only the latest round's answers arrive;+      --   carry earlier ones in the state.+  , clientRequestState :: Maybe Text+      -- ^ On a retry after 'InputRequired': the state the handler returned,+      --   verified and decrypted by the library. 'Nothing' otherwise.   }  -- | A context carrying no transport- or request-level information: what@@ -851,6 +883,8 @@   , clientCapabilities = Nothing   , reportProgress = \_ _ _ -> pure ()   , logToClient = \_ _ -> pure ()+  , clientInputResponses = mempty+  , clientRequestState = Nothing   }  -- | Cacheability hints stamped onto modern (2026-07-28+) list/read results,@@ -888,20 +922,143 @@   , supportsListen = False   } --- | Handler type definitions. Every handler receives the request's--- 'ClientContext' as its first argument.+-- | What a handler that may ask the client for input produces+-- (@tools\/call@, @prompts\/get@ and @resources\/read@, the requests the+-- specification allows to do so). --+-- 'InputRequired' answers the request with an @input_required@ result:+-- the client gathers the requested input and retries the request, and the+-- handler then sees the answers in 'clientInputResponses' and its state in+-- 'clientRequestState'. The library protects the state (see+-- "MCP.Server.RequestState"), so a handler can put anything in it.+data Outcome a+  = Complete a+    -- ^ The request is done.+  | InputRequired (Map Text InputRequest) (Maybe Text)+    -- ^ Requests for the client, keyed by ids the handler chooses (the+    --   same keys come back in 'clientInputResponses'), and state to+    --   receive back in 'clientRequestState' on the retry.+  deriving (Show, Eq, Functor)++-- | A request for the client to fulfil before retrying.+data InputRequest+  = ElicitForm Text Schema+    -- ^ Ask the user to fill in a form (@elicitation\/create@ in @form@+    --   mode): the message, and the schema of an object whose properties+    --   are strings, numbers, booleans or enumerations.+  | ElicitUrl Text URI+    -- ^ Send the user to a URL to complete an interaction out of band+    --   (@elicitation\/create@ in @url@ mode): the message and the URL.+  | RawInputRequest Text Value+    -- ^ Any other request, by method and params, such as+    --   @sampling\/createMessage@ or @roots\/list@ (both deprecated in+    --   2026-07-28).+  deriving (Show, Eq)++instance ToJSON InputRequest where+  toJSON (ElicitForm message requested) = object+    [ "method" .= ("elicitation/create" :: Text)+    , "params" .= object+        [ "mode" .= ("form" :: Text)+        , "message" .= message+        , "requestedSchema" .= requested+        ]+    ]+  toJSON (ElicitUrl message url) = object+    [ "method" .= ("elicitation/create" :: Text)+    , "params" .= object+        [ "mode" .= ("url" :: Text)+        , "message" .= message+        , "url" .= show url+        ]+    ]+  toJSON (RawInputRequest method params) = object+    [ "method" .= method+    , "params" .= params+    ]++-- | The client's answer to an 'InputRequest'.+data InputResponse+  = ElicitResponse ElicitAction (Maybe (Map Text Value))+    -- ^ The user's action, and the submitted form content when they+    --   accepted a form.+  | RawInputResponse Value+    -- ^ Any other result, such as a sampling or roots result.+  deriving (Show, Eq)++-- | How the user responded to an elicitation.+data ElicitAction+  = ElicitAccept   -- ^ Submitted the form, or confirmed the action+  | ElicitDecline  -- ^ Explicitly declined+  | ElicitCancel   -- ^ Dismissed without choosing+  deriving (Show, Eq, Ord, Enum, Bounded)++instance FromJSON InputResponse where+  parseJSON = withObject "InputResponse" $ \o ->+    case KM.lookup "action" o of+      Just (String a) | Just action <- lookup a actions ->+        ElicitResponse action <$> o .:? "content"+      _ -> pure (RawInputResponse (Object o))+    where+      actions =+        [ ("accept", ElicitAccept)+        , ("decline", ElicitDecline)+        , ("cancel", ElicitCancel)+        ]++-- | Whether the client making this request can fulfil an input request:+-- it must speak a revision with multi round-trip requests (2026-07-28+)+-- and have declared the matching capability. Elicitation is declared per+-- mode; an empty @elicitation@ capability means @form@ only.+clientSupportsInput :: ClientContext -> InputRequest -> Bool+clientSupportsInput ctx request = case clientProtocolVersion ctx of+  Nothing -> False+  Just _  -> case request of+    ElicitForm _ _ -> elicitForm+    ElicitUrl _ _  -> elicitUrl+    RawInputRequest "elicitation/create" params+      | modeOf params == Just "url" -> elicitUrl+      | otherwise                   -> elicitForm+    RawInputRequest "sampling/createMessage" _ -> declared "sampling"+    RawInputRequest "roots/list" _             -> declared "roots"+    RawInputRequest _ _                        -> False+  where+    caps = case clientCapabilities ctx of+      Just (Object o) -> o+      _               -> KM.empty+    declared key = KM.member key caps+    elicitation = case KM.lookup "elicitation" caps of+      Just (Object e) -> Just e+      _               -> Nothing+    elicitForm = case elicitation of+      Just e -> KM.member "form" e || not (KM.member "url" e)+      Nothing -> False+    elicitUrl = maybe False (KM.member "url") elicitation+    modeOf (Object p) = case KM.lookup "mode" p of+      Just (String m) -> Just m+      _               -> Nothing+    modeOf _ = Nothing++-- | List the available prompts (@prompts\/list@). Every handler receives+-- the request's t'ClientContext' as its first argument.+-- -- Prompt arguments are string-valued per the MCP specification; tool -- arguments are full JSON values. type PromptListHandler = ClientContext -> IO [PromptDefinition]-type PromptGetHandler = ClientContext -> PromptName -> Map Text Text -> IO (Either Error PromptResult)+-- | Render a prompt from its name and arguments (@prompts\/get@).+type PromptGetHandler = ClientContext -> PromptName -> Map Text Text -> IO (Either Error (Outcome PromptResult)) +-- | List the available resources (@resources\/list@). type ResourceListHandler = ClientContext -> IO [ResourceDefinition]-type ResourceReadHandler = ClientContext -> URI -> IO (Either Error ResourceContent)+-- | Read a resource by URI (@resources\/read@).+type ResourceReadHandler = ClientContext -> URI -> IO (Either Error (Outcome ResourceContent))+-- | List the resource templates (@resources\/templates\/list@). type ResourceTemplateListHandler = ClientContext -> IO [ResourceTemplateDefinition] +-- | List the available tools (@tools\/list@). type ToolListHandler = ClientContext -> IO [ToolDefinition]-type ToolCallHandler = ClientContext -> ToolName -> Map Text Value -> IO (Either Error ToolResult)+-- | Call a tool by name with its JSON arguments (@tools\/call@).+type ToolCallHandler = ClientContext -> ToolName -> Map Text Value -> IO (Either Error (Outcome ToolResult))  -- | Completion handler: given what is being completed ('CompletionRef'), the -- argument name, the partial value typed so far, and any already-resolved
+ test/AskTypes.hs view
@@ -0,0 +1,135 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TemplateHaskell   #-}++-- | Forms and handlers for the derived-interface tests of 'Ask' (ADR 0009,+-- phase 3). Splices that derive handlers live in "Spec.DerivedAsk".+module AskTypes where++import           Control.Monad.IO.Class (liftIO)+import           Data.Text              (Text)+import qualified Data.Text              as T+import           Language.Haskell.TH    (recover)+import           MCP.Server+import           MCP.Server.Derive++data Environment = Staging | Production+  deriving (Show, Eq)++data Tag = Urgent | Routine+  deriving (Show, Eq)++newtype Ticket = Ticket Text+  deriving (Show, Eq)++data DeployTarget = DeployTarget+  { environment :: Environment+  , reason      :: Text+  , notify      :: Maybe Bool+  , replicas    :: Int+  , tags        :: [Tag]+  , ticket      :: Maybe Ticket+  } deriving (Show, Eq)++newtype Confirm = Confirm { proceed :: Bool }+  deriving (Show, Eq)++data Styled = Styled { size :: Double, label :: Text, mode :: Environment }+  deriving (Show, Eq)++data Counted = Counted { count :: Int, greeting :: Text }+  deriving (Show, Eq)++$(deriveFormWithDescription ''DeployTarget [("reason", "Why deploy now?")])+$(deriveForm ''Confirm)+$(deriveFormWithOptions ''Styled+    [ ("size", defaultFormFieldOptions { formFieldTitle = Just "Size", formFieldDefault = Just (Number 1.5) })+    , ("mode", defaultFormFieldOptions { formFieldDefault = Just "production", formFieldDescription = Just "Where" })+    ])++$(deriveFormWithOptions ''Counted+    [ ("count", defaultFormFieldOptions { formFieldDefault = Just (Number 3) })+    , ("greeting", defaultFormFieldOptions { formFieldDefault = Just "café 東京" })+    ])++-- Types a form cannot hold, for the compile-time rejection tests.+data Inner = Inner { innerName :: Text } deriving (Show, Eq)+data Nested = Nested { inner :: Inner } deriving (Show, Eq)+data TextList = TextList { names :: [Text] } deriving (Show, Eq)+data Unit = Unit deriving (Show, Eq)++-- | Which derivations were rejected at compile time.+rejectedNested, rejectedTextList, rejectedUnit, rejectedUnknownField, rejectedBadDefault, rejectedBadChoice,+  rejectedFractionalInt, rejectedOutOfRangeInt :: Bool+rejectedNested = $(recover [| True |] (deriveForm ''Nested >> [| False |]))+rejectedTextList = $(recover [| True |] (deriveForm ''TextList >> [| False |]))+rejectedUnit = $(recover [| True |] (deriveForm ''Unit >> [| False |]))+rejectedUnknownField = $(recover [| True |]+  (deriveFormWithDescription ''Confirm [("procede", "typo")] >> [| False |]))+rejectedBadDefault = $(recover [| True |]+  (deriveFormWithOptions ''Confirm [("proceed", defaultFormFieldOptions { formFieldDefault = Just "yes" })] >> [| False |]))+rejectedFractionalInt = $(recover [| True |]+  (deriveFormWithOptions ''Counted [("count", defaultFormFieldOptions { formFieldDefault = Just (Number 1.5) })] >> [| False |]))+rejectedOutOfRangeInt = $(recover [| True |]+  (deriveFormWithOptions ''Counted [("count", defaultFormFieldOptions { formFieldDefault = Just (Number 1e30) })] >> [| False |]))+rejectedBadChoice = $(recover [| True |]+  (deriveFormWithOptions ''Styled [("mode", defaultFormFieldOptions { formFieldDefault = Just "prod" })] >> [| False |]))++-- Tools: one asks, one never does.+data OpsTool+  = Deploy { service :: Text }+  | Status { service :: Text }+  deriving (Show, Eq)++handleOps :: ClientContext -> OpsTool -> Ask Content+handleOps _ (Deploy svc) = do+  target <- elicit ("Where should " <> svc <> " deploy to?")+  case target of+    Accepted t -> do+      plan <- step (pure (T.pack (show (environment t)) <> " x" <> T.pack (show (replicas t))))+      ok <- elicit ("Apply " <> plan <> "?")+      pure $ ContentText $ case ok of+        Accepted (Confirm True) -> "deployed " <> svc <> ": " <> plan+        _                       -> "cancelled"+    other -> pure (ContentText ("no target: " <> T.pack (show (() <$ other))))+handleOps _ (Status svc) = ContentText <$> liftIO (pure (svc <> " is up"))++-- An IO handler still derives unchanged.+handleOpsIO :: ClientContext -> OpsTool -> IO Content+handleOpsIO _ (Deploy svc) = pure (ContentText ("deployed " <> svc))+handleOpsIO _ (Status svc) = pure (ContentText (svc <> " is up"))++-- Structured output from an asking handler.+newtype DeployReport = DeployReport { deployed :: Bool }++handleOpsOutput :: ClientContext -> OpsTool -> Ask (ToolOutput DeployReport)+handleOpsOutput _ _ = do+  ok <- elicit "Go?"+  pure $ ToolOutput $ DeployReport $ case ok of+    Accepted (Confirm b) -> b+    _                    -> False++-- Derived here, where only 'Value' is imported from aeson: the generated+-- serializer must not rely on names from the user's imports.+$(pure [])++outputTools :: (ToolListHandler, ToolCallHandler)+outputTools = $(deriveToolHandlerWithOutput ''OpsTool 'handleOpsOutput ''DeployReport)++-- Prompts and resources can ask too.+newtype OpsPrompt = Briefing { team :: Text }++handlePrompt :: ClientContext -> OpsPrompt -> Ask Content+handlePrompt _ (Briefing t) = do+  ok <- elicit ("Include incidents for " <> t <> "?")+  pure $ ContentText $ case ok of+    Accepted (Confirm True) -> "briefing with incidents"+    _                       -> "briefing"++data OpsResource = Secrets++handleResource :: ClientContext -> URI -> OpsResource -> Ask ResourceContent+handleResource _ uri Secrets = do+  ok <- elicit "Reveal secrets?"+  pure $ ResourceText uri "text/plain" $ case ok of+    Accepted (Confirm True) -> "s3cret"+    _                       -> "hidden"
test/HspecMain.hs view
@@ -7,13 +7,16 @@ import qualified Spec.BasicDerivation import qualified Spec.SchemaValidation import qualified Spec.AdvancedDerivation+import qualified Spec.AskMonad import qualified Spec.Cancellation import qualified Spec.UnicodeHandling import qualified Spec.DefinitionMetadata+import qualified Spec.DerivedAsk import qualified Spec.DerivedOutput import qualified Spec.GoldenWire import qualified Spec.HandlerExceptions import qualified Spec.HttpTransport+import qualified Spec.InputRequired import qualified Spec.ModernEra import qualified Spec.Progress import qualified Spec.ProtocolVersionNegotiation@@ -30,13 +33,16 @@     Spec.BasicDerivation.spec     Spec.SchemaValidation.spec     Spec.AdvancedDerivation.spec+    Spec.AskMonad.spec     Spec.Cancellation.spec     Spec.UnicodeHandling.spec     Spec.DefinitionMetadata.spec+    Spec.DerivedAsk.spec     Spec.DerivedOutput.spec     Spec.GoldenWire.spec     Spec.HandlerExceptions.spec     Spec.HttpTransport.spec+    Spec.InputRequired.spec     Spec.ModernEra.spec     Spec.Progress.spec     Spec.ProtocolVersionNegotiation.spec
+ test/Spec/AskMonad.hs view
@@ -0,0 +1,258 @@+{-# LANGUAGE DeriveGeneric     #-}+{-# LANGUAGE OverloadedStrings #-}++-- | Coverage for the 'Ask' monad (ADR 0009, phase 2): handlers replayed+-- against a journal across rounds, through the real dispatcher and state+-- codec.+module Spec.AskMonad (spec) where++import           Control.Monad.IO.Class (liftIO)+import           Data.Aeson+import qualified Data.Aeson.KeyMap      as KM+import           Data.Aeson.Types       (Pair)+import           Data.IORef+import           Data.Text              (Text)+import qualified Data.Text              as T+import           GHC.Generics           (Generic)+import           MCP.Server+import           MCP.Server.Handlers    (handleMcpMessage)+import           MCP.Server.JsonRpc+import           Test.Hspec+import           TestHelpers            (testStateCodec)++newtype Target = Target { environment :: Text } deriving (Show, Eq, Generic)+instance FromJSON Target+instance Form Target where+  formSchema _ = schema $ SchemaObject+    [("environment", schema (SchemaString (Just ["staging", "production"])))]+    ["environment"]++newtype Confirm = Confirm { ok :: Bool } deriving (Show, Eq, Generic)+instance FromJSON Confirm+instance Form Confirm where+  formSchema _ = schema $ SchemaObject [("ok", schema SchemaBoolean)] ["ok"]++-- | Counters for what ran: the journaled planning step, and every round.+data Counters = Counters { planRuns :: IORef Int, rounds :: IORef Int }++-- | Ask for a target, plan once, ask for confirmation.+deploy :: Counters -> Ask Text+deploy counters = do+  liftIO (modifyIORef (rounds counters) (+ 1))+  target <- elicit "Where to?"+  case target of+    Accepted (Target env) -> do+      plan <- step $ do+        modifyIORef (planRuns counters) (+ 1)+        pure ("plan-" <> env)+      confirmed <- elicit ("Apply " <> plan <> "?")+      pure $ case confirmed of+        Accepted (Confirm True) -> "applied " <> plan+        other                   -> "not applied: " <> T.pack (show other)+    other -> pure ("no target: " <> T.pack (show other))++-- | Asks a question that changes every round, which replay must refuse.+fickle :: IORef Int -> Ask Text+fickle counter = do+  n <- liftIO (atomicModifyIORef counter (\c -> (c + 1, c)))+  reply <- elicit ("Question " <> T.pack (show n))+  pure (T.pack (show (reply :: Answer Confirm)))++-- | A step whose result is JSON null, a question, and a step after it.+nullStep :: Ask Text+nullStep = do+  nothing <- step (pure (Nothing :: Maybe Text))+  reply <- elicit "Still there?"+  later <- step (pure (2 :: Int))+  pure (T.pack (show (nothing, reply :: Answer Confirm, later)))++handlersFor :: Counters -> IORef Int -> McpServerHandlers+handlersFor counters fickleCounter = noHandlers+  { tools = Just+      ( \_ -> pure []+      , \ctx name _ -> fmap (fmap (\t -> toolResult [ContentText t])) <$>+          runAsk (case name of+                    "fickle" -> fickle fickleCounter+                    "null"   -> nullStep+                    _        -> deploy counters) ctx+      )+  }++newCounters :: IO (Counters, IORef Int)+newCounters = (,) <$> (Counters <$> newIORef 0 <*> newIORef 0) <*> newIORef 0++call :: (Counters, IORef Int) -> Text -> Value -> [Pair] -> IO Value+call (counters, fickleCounter) name caps extra = do+  response <- handleMcpMessage (McpServerInfo "T" "1" "") defaultCacheHints testStateCodec+    noNotificationSupport (\_ -> pure ()) (handlersFor counters fickleCounter) anonymousContext $+      JsonRpcMessageRequest JsonRpcRequest+        { requestJsonrpc = "2.0"+        , requestId = RequestIdNumber 1+        , requestMethod = "tools/call"+        , requestParams = Just $ object $ extra +++            [ "name" .= name+            , "arguments" .= object []+            , "_meta" .= object+                [ "io.modelcontextprotocol/protocolVersion" .= ("2026-07-28" :: Text)+                , "io.modelcontextprotocol/clientCapabilities" .= caps+                ]+            ]+        }+  case response of+    Just (JsonRpcMessageResponse r) -> pure (toJSON r)+    other -> fail ("expected a response, got " ++ show other)++formCaps, noCaps :: Value+formCaps = object ["elicitation" .= object ["form" .= object []]]+noCaps = object []++at :: [Key] -> Value -> Maybe Value+at [] v = Just v+at (k : ks) (Object o) = KM.lookup k o >>= at ks+at _ _ = Nothing++stateOf :: Value -> Text+stateOf v = case at ["result", "requestState"] v of+  Just (String s) -> s+  _               -> error ("no requestState in " ++ show v)++-- | The keys of an @input_required@ result's requests, and their messages.+questionsOf :: Value -> [(Key, Value)]+questionsOf v = case at ["result", "inputRequests"] v of+  Just (Object rs) -> [(k, m) | (k, r) <- KM.toList rs, Just m <- [at ["params", "message"] r]]+  _                -> []++textOf :: Value -> Maybe Value+textOf v = case at ["result", "content"] v of+  Just (Array xs) | [c] <- foldr (:) [] xs -> at ["text"] c+  _                                       -> Nothing++errorOf :: Value -> Maybe (Int, Text)+errorOf v = do+  Number code <- at ["error", "code"] v+  String msg <- at ["error", "message"] v+  pure (round code, msg)++answer :: Key -> Value -> Pair+answer key response = "inputResponses" .= object [key .= response]++accepted :: [Pair] -> Value+accepted content = object ["action" .= ("accept" :: Text), "content" .= object content]++retrying :: Value -> [Pair]+retrying previous = ["requestState" .= stateOf previous]++spec :: Spec+spec = describe "The Ask monad" $ do+  it "asks, replays answers and steps, and completes" $ do+    env <- newCounters+    r1 <- call env "deploy" formCaps []+    questionsOf r1 `shouldBe` [("0", String "Where to?")]+    r2 <- call env "deploy" formCaps $+      answer "0" (accepted ["environment" .= ("staging" :: Text)]) : retrying r1+    questionsOf r2 `shouldBe` [("2", String "Apply plan-staging?")]+    r3 <- call env "deploy" formCaps $+      answer "2" (accepted ["ok" .= True]) : retrying r2+    textOf r3 `shouldBe` Just (String "applied plan-staging")+    readIORef (planRuns (fst env)) `shouldReturn` 1+    readIORef (rounds (fst env)) `shouldReturn` 3++  it "keeps earlier answers in the journal, not in the retry" $ do+    env <- newCounters+    r1 <- call env "deploy" formCaps []+    r2 <- call env "deploy" formCaps $+      answer "0" (accepted ["environment" .= ("production" :: Text)]) : retrying r1+    -- The last round's answers only: the first answer comes from the state.+    r3 <- call env "deploy" formCaps $ answer "2" (accepted ["ok" .= False]) : retrying r2+    textOf r3 `shouldBe` Just (String "not applied: Accepted (Confirm {ok = False})")++  it "asks again when the retry carries no answer" $ do+    env <- newCounters+    r1 <- call env "deploy" formCaps []+    r2 <- call env "deploy" formCaps (retrying r1)+    questionsOf r2 `shouldBe` [("0", String "Where to?")]++  it "returns a declined or cancelled answer to the handler" $ do+    env <- newCounters+    r1 <- call env "deploy" formCaps []+    declined <- call env "deploy" formCaps $+      answer "0" (object ["action" .= ("decline" :: Text)]) : retrying r1+    textOf declined `shouldBe` Just (String "no target: Declined")+    cancelled <- call env "deploy" formCaps $+      answer "0" (object ["action" .= ("cancel" :: Text)]) : retrying r1+    textOf cancelled `shouldBe` Just (String "no target: Cancelled")++  it "answers Unavailable without a round trip when the client cannot ask" $ do+    env <- newCounters+    r <- call env "deploy" noCaps []+    textOf r `shouldBe` Just (String "no target: Unavailable")+    legacy <- runAsk (deploy (fst env)) anonymousContext+    legacy `shouldBe` Right (Complete "no target: Unavailable")++  it "runs a step again when a round is retried from an earlier state" $ do+    env <- newCounters+    r1 <- call env "deploy" formCaps []+    let staging = answer "0" (accepted ["environment" .= ("staging" :: Text)]) : retrying r1+    _ <- call env "deploy" formCaps staging+    _ <- call env "deploy" formCaps staging+    readIORef (planRuns (fst env)) `shouldReturn` 2++  it "refuses a handler whose questions change between rounds" $ do+    env <- newCounters+    r1 <- call env "fickle" formCaps []+    questionsOf r1 `shouldBe` [("0", String "Question 0")]+    r2 <- call env "fickle" formCaps $ answer "0" (accepted ["ok" .= True]) : retrying r1+    fmap fst (errorOf r2) `shouldBe` Just (-32603)+    fmap (T.isInfixOf "position 0" . snd) (errorOf r2) `shouldBe` Just True++  it "rejects content that does not fit the form" $ do+    env <- newCounters+    r1 <- call env "deploy" formCaps []+    r2 <- call env "deploy" formCaps $+      answer "0" (accepted ["environment" .= (3 :: Int)]) : retrying r1+    fmap fst (errorOf r2) `shouldBe` Just (-32602)++  it "rejects an answer that is not an elicitation result" $ do+    env <- newCounters+    r1 <- call env "deploy" formCaps []+    r2 <- call env "deploy" formCaps $+      answer "0" (object ["roots" .= ([] :: [Value])]) : retrying r1+    fmap fst (errorOf r2) `shouldBe` Just (-32602)++  it "completes without state when nothing is asked" $ do+    result <- runAsk (step (pure (42 :: Int))) anonymousContext+    result `shouldBe` Right (Complete 42)++  it "fails a run whose journal belongs to a different handler" $ do+    env <- newCounters+    Right (InputRequired _ journal) <- runAsk (deploy (fst env)) anonymousContext+      { clientProtocolVersion = Just "2026-07-28"+      , clientCapabilities = Just formCaps+      }+    result <- runAsk (step (pure (1 :: Int))) anonymousContext { clientRequestState = journal }+    case result of+      Left (InternalError msg) -> msg `shouldSatisfy` T.isInfixOf "position 0"+      other -> expectationFailure ("expected a mismatch, got " ++ show other)++  it "keeps the pending question's key stable across unrelated rounds" $ do+    env <- newCounters+    r1 <- call env "deploy" formCaps []+    let unrelated = answer "1" (accepted ["environment" .= ("staging" :: Text)]) : retrying r1+    r2 <- call env "deploy" formCaps unrelated+    questionsOf r2 `shouldBe` [("0", String "Where to?")]++  it "replays a step whose result is null" $ do+    env <- newCounters+    r1 <- call env "null" formCaps []+    questionsOf r1 `shouldBe` [("1", String "Still there?")]+    r2 <- call env "null" formCaps $ answer "1" (accepted ["ok" .= True]) : retrying r1+    textOf r2 `shouldBe` Just (String "(Nothing,Accepted (Confirm {ok = True}),2)")++  it "settles a pending question as Unavailable when the client can no longer ask" $ do+    env <- newCounters+    r1 <- call env "deploy" formCaps []+    r2 <- call env "deploy" noCaps (retrying r1)+    textOf r2 `shouldBe` Just (String "no target: Unavailable")+    r3 <- call env "null" formCaps []+    r4 <- call env "null" noCaps (retrying r3)+    textOf r4 `shouldBe` Just (String "(Nothing,Unavailable,2)")
test/Spec/BasicDerivation.hs view
@@ -33,7 +33,7 @@   case parseURI uriString of     Just uri -> do       result <- handler anonCtx uri-      case result of+      case completed result of         Right (ResourceText _ _ content) -> content `shouldBe` expected         Right (ResourceBlob _ _ _) -> expectationFailure "Expected ResourceText but got ResourceBlob"         Left err -> expectationFailure $ "Expected success but got error: " ++ show err@@ -64,7 +64,7 @@             if useSubstringMatch testCase               then do                 result <- readHandler anonCtx uri-                case result of+                case completed result of                   Right (ResourceText _ _ content) ->                     T.isInfixOf (resourceExpectedContent testCase) content `shouldBe` True                   Right (ResourceBlob _ _ _) -> expectationFailure "Expected ResourceText but got ResourceBlob"
test/Spec/Cancellation.hs view
@@ -19,11 +19,14 @@ import MCP.Server.Handlers (handleMcpMessage) import MCP.Server.JsonRpc import Test.Hspec+import TestHelpers (testStateCodec)  -- A slow tools/call carrying a progressToken (so emissions are live),--- driven from an async that the test cancels once the handler signals--- it has started.-runCancelled :: (ClientContext -> IO ()) -> IO [JsonRpcNotification]+-- driven from an async that the test cancels once the handler body signals+-- it is ready: the body gets the signal as an action, and calls it once it+-- has reached the point the test is about (inside a bracket, say), so+-- cancellation cannot arrive earlier.+runCancelled :: (IO () -> ClientContext -> IO ()) -> IO [JsonRpcNotification] runCancelled handlerBody = do   sink <- newIORef []   started <- newEmptyMVar@@ -33,9 +36,8 @@             ( \_ -> pure []             , \ctx _ _ -> do                 reportProgress ctx 0 (Just 1) Nothing-                putMVar started ()-                handlerBody ctx-                pure $ Right $ toToolResult ("done" :: Text)+                handlerBody (putMVar started ()) ctx+                pure $ Right $ Complete $ toToolResult ("done" :: Text)             )         }       params = object@@ -44,7 +46,7 @@         , "_meta" .= object ["progressToken" .= ("t" :: Text)]         ]   task <- async $ do-    resp <- handleMcpMessage (McpServerInfo "T" "1" "") defaultCacheHints+    resp <- handleMcpMessage (McpServerInfo "T" "1" "") defaultCacheHints testStateCodec       noNotificationSupport (\n -> modifyIORef' sink (++ [n]))       handlers anonymousContext       (JsonRpcMessageRequest (JsonRpcRequest "2.0" (RequestIdNumber 1) "tools/call" (Just params)))@@ -62,7 +64,8 @@    it "a cancelled handler stops emitting and never yields a response" $ do     lateEmit <- newIORef False-    ns <- runCancelled $ \ctx -> do+    ns <- runCancelled $ \ready ctx -> do+      ready       threadDelay 5000000       writeIORef lateEmit True       reportProgress ctx 1 (Just 1) Nothing@@ -73,8 +76,9 @@   it "bracket releases handler resources on cancellation" $ do     acquired <- newIORef False     released <- newIORef False-    _ <- runCancelled $ \_ ->-      bracket_ (writeIORef acquired True) (writeIORef released True) $+    _ <- runCancelled $ \ready _ ->+      bracket_ (writeIORef acquired True) (writeIORef released True) $ do+        ready         threadDelay 5000000     readIORef acquired `shouldReturn` True     readIORef released `shouldReturn` True
+ test/Spec/DerivedAsk.hs view
@@ -0,0 +1,200 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TemplateHaskell   #-}++-- | Coverage for the derived interface of multi round-trip requests (ADR+-- 0009, phase 3): derived forms, and derived handlers that return 'Ask'.+module Spec.DerivedAsk (spec) where++import           Data.Aeson+import qualified Data.Aeson.KeyMap   as KM+import           Data.Aeson.Types    (Pair)+import qualified Data.Map            as Map+import           Data.Proxy          (Proxy (..))+import           Data.Text           (Text)+import           MCP.Server+import           MCP.Server.Derive+import           MCP.Server.Handlers (handleMcpMessage)+import           MCP.Server.JsonRpc+import           Test.Hspec+import           TestHelpers         (testStateCodec)++import           AskTypes++handlers :: McpServerHandlers+handlers = noHandlers+  { tools = Just $(deriveToolHandler ''OpsTool 'handleOps)+  , prompts = Just $(derivePromptHandler ''OpsPrompt 'handlePrompt)+  , resources = Just $(deriveResourceHandler ''OpsResource 'handleResource)+  }++ioTools :: (ToolListHandler, ToolCallHandler)+ioTools = $(deriveToolHandler ''OpsTool 'handleOpsIO)++request :: McpServerHandlers -> Text -> Value -> [Pair] -> IO Value+request hs method caps params = do+  response <- handleMcpMessage (McpServerInfo "T" "1" "") defaultCacheHints testStateCodec+    noNotificationSupport (\_ -> pure ()) hs anonymousContext $+      JsonRpcMessageRequest JsonRpcRequest+        { requestJsonrpc = "2.0"+        , requestId = RequestIdNumber 1+        , requestMethod = method+        , requestParams = Just $ object $ params +++            [ "_meta" .= object+                [ "io.modelcontextprotocol/protocolVersion" .= ("2026-07-28" :: Text)+                , "io.modelcontextprotocol/clientCapabilities" .= caps+                ]+            ]+        }+  case response of+    Just (JsonRpcMessageResponse r) -> pure (toJSON r)+    other -> fail ("expected a response, got " ++ show other)++formCaps, noCaps :: Value+formCaps = object ["elicitation" .= object ["form" .= object []]]+noCaps = object []++at :: [Key] -> Value -> Maybe Value+at [] v = Just v+at (k : ks) (Object o) = KM.lookup k o >>= at ks+at _ _ = Nothing++-- | The single request of an @input_required@ result: its key and params.+questionOf :: Value -> Maybe (Key, Value)+questionOf v = case at ["result", "inputRequests"] v of+  Just (Object rs) | [(k, r)] <- KM.toList rs -> (,) k <$> at ["params"] r+  _ -> Nothing++-- | The params of a retry answering the given result's question.+answering :: Value -> [Pair] -> [Pair]+answering previous content = case (questionOf previous, at ["result", "requestState"] previous) of+  (Just (key, _), Just st) ->+    [ "inputResponses" .= object [key .= object ["action" .= ("accept" :: Text), "content" .= object content]]+    , "requestState" .= st+    ]+  _ -> error ("not an input_required result: " ++ show previous)++firstText :: Value -> Maybe Value+firstText v = case at ["result", "content"] v of+  Just (Array xs) | (c : _) <- foldr (:) [] xs -> at ["text"] c+  _ -> case at ["result", "messages"] v of+    Just (Array xs) | (m : _) <- foldr (:) [] xs -> at ["content", "text"] m+    _ -> case at ["result", "contents"] v of+      Just (Array xs) | (c : _) <- foldr (:) [] xs -> at ["text"] c+      _ -> Nothing++callDeploy :: [Pair] -> [Pair]+callDeploy extra = ["name" .= ("deploy" :: Text), "arguments" .= object ["service" .= ("billing" :: Text)]] ++ extra++spec :: Spec+spec = describe "Derived handlers that ask" $ do+  describe "deriveForm" $ do+    it "derives a flat schema from a record" $+      toJSON (formSchema (Proxy :: Proxy DeployTarget)) `shouldBe` object+        [ "type" .= ("object" :: Text)+        , "properties" .= object+            [ "environment" .= object ["type" .= ("string" :: Text), "enum" .= (["staging", "production"] :: [Text])]+            , "reason" .= object ["type" .= ("string" :: Text), "description" .= ("Why deploy now?" :: Text)]+            , "notify" .= object ["type" .= ("boolean" :: Text)]+            , "replicas" .= object ["type" .= ("integer" :: Text)]+            , "tags" .= object+                [ "type" .= ("array" :: Text)+                , "items" .= object ["type" .= ("string" :: Text), "enum" .= (["urgent", "routine"] :: [Text])]+                ]+            , "ticket" .= object ["type" .= ("string" :: Text)]+            ]+        , "required" .= (["environment", "reason", "replicas", "tags"] :: [Text])+        ]++    it "adds titles and defaults from the options" $+      toJSON (formSchema (Proxy :: Proxy Styled)) `shouldBe` object+        [ "type" .= ("object" :: Text)+        , "properties" .= object+            [ "size" .= object ["type" .= ("number" :: Text), "title" .= ("Size" :: Text), "default" .= (1.5 :: Double)]+            , "label" .= object ["type" .= ("string" :: Text)]+            , "mode" .= object+                [ "type" .= ("string" :: Text), "enum" .= (["staging", "production"] :: [Text])+                , "default" .= ("production" :: Text), "description" .= ("Where" :: Text)+                ]+            ]+        , "required" .= (["size", "label", "mode"] :: [Text])+        ]++    it "parses submitted content" $+      formParse (Map.fromList+        [ ("environment", "production"), ("reason", "hotfix"), ("replicas", Number 3)+        , ("tags", toJSON ["urgent" :: Text]), ("ticket", "OPS-1") ])+        `shouldBe` Right (DeployTarget Production "hotfix" Nothing 3 [Urgent] (Just (Ticket "OPS-1")))++    it "names the field when content does not fit" $+      (formParse (Map.fromList [("proceed", "maybe")]) :: Either Text Confirm)+        `shouldBe` Left "field 'proceed': Failed to parse Bool from: maybe"++    it "rejects types a form cannot hold at compile time" $ do+      rejectedNested `shouldBe` True+      rejectedTextList `shouldBe` True+      rejectedUnit `shouldBe` True+      rejectedUnknownField `shouldBe` True+      rejectedBadDefault `shouldBe` True+      rejectedBadChoice `shouldBe` True+      rejectedFractionalInt `shouldBe` True+      rejectedOutOfRangeInt `shouldBe` True++    it "keeps whole-number and non-ASCII defaults intact" $+      toJSON (formSchema (Proxy :: Proxy Counted)) `shouldBe` object+        [ "type" .= ("object" :: Text)+        , "properties" .= object+            [ "count" .= object ["type" .= ("integer" :: Text), "default" .= (3 :: Int)]+            , "greeting" .= object ["type" .= ("string" :: Text), "default" .= ("café 東京" :: Text)]+            ]+        , "required" .= (["count", "greeting"] :: [Text])+        ]++  describe "derive*Handler with Ask" $ do+    it "runs a derived tool across rounds" $ do+      r1 <- request handlers "tools/call" formCaps (callDeploy [])+      fmap fst (questionOf r1) `shouldBe` Just "0"+      fmap (at ["message"] . snd) (questionOf r1) `shouldBe` Just (Just "Where should billing deploy to?")+      fmap (at ["requestedSchema", "required"] . snd) (questionOf r1)+        `shouldBe` Just (Just (toJSON (["environment", "reason", "replicas", "tags"] :: [Text])))+      r2 <- request handlers "tools/call" formCaps $ callDeploy $ answering r1+        [ "environment" .= ("staging" :: Text), "reason" .= ("fix" :: Text)+        , "replicas" .= (2 :: Int), "tags" .= ([] :: [Text]) ]+      fmap (at ["message"] . snd) (questionOf r2) `shouldBe` Just (Just "Apply Staging x2?")+      r3 <- request handlers "tools/call" formCaps $ callDeploy $ answering r2 ["proceed" .= True]+      firstText r3 `shouldBe` Just "deployed billing: Staging x2"++    it "answers a tool that never asks in one round" $ do+      r <- request handlers "tools/call" formCaps+        ["name" .= ("status" :: Text), "arguments" .= object ["service" .= ("billing" :: Text)]]+      firstText r `shouldBe` Just "billing is up"++    it "falls back without a round trip when the client cannot ask" $ do+      r <- request handlers "tools/call" noCaps (callDeploy [])+      firstText r `shouldBe` Just "no target: Unavailable"++    it "still derives IO handlers" $ do+      r <- request noHandlers { tools = Just ioTools } "tools/call" formCaps (callDeploy [])+      firstText r `shouldBe` Just "deployed billing"++    it "asks from a handler with structured output" $ do+      let hs = noHandlers { tools = Just outputTools }+      r1 <- request hs "tools/call" formCaps (callDeploy [])+      fmap (at ["message"] . snd) (questionOf r1) `shouldBe` Just (Just "Go?")+      r2 <- request hs "tools/call" formCaps $ callDeploy $ answering r1 ["proceed" .= True]+      at ["result", "structuredContent"] r2 `shouldBe` Just (object ["deployed" .= True])++    it "asks from a derived prompt" $ do+      let get extra = request handlers "prompts/get" formCaps $+            ["name" .= ("briefing" :: Text), "arguments" .= object ["team" .= ("ops" :: Text)]] ++ extra+      r1 <- get []+      fmap (at ["message"] . snd) (questionOf r1) `shouldBe` Just (Just "Include incidents for ops?")+      r2 <- get (answering r1 ["proceed" .= True])+      firstText r2 `shouldBe` Just "briefing with incidents"++    it "asks from a derived resource" $ do+      let readIt extra = request handlers "resources/read" formCaps $+            ["uri" .= ("resource://secrets" :: Text)] ++ extra+      r1 <- readIt []+      fmap (at ["message"] . snd) (questionOf r1) `shouldBe` Just (Just "Reveal secrets?")+      r2 <- readIt (answering r1 ["proceed" .= False])+      firstText r2 `shouldBe` Just "hidden"
test/Spec/DerivedOutput.hs view
@@ -19,7 +19,7 @@ weatherHandlers = $(deriveToolHandlerWithOutputDescription ''WeatherTool 'handleWeatherTool ''WeatherReport   [ ("GetWeather", "Current weather"), ("wrTemperature", "Degrees Celsius") ]) -callWeather :: Text -> [(Text, Value)] -> IO (Either Error ToolResult)+callWeather :: Text -> [(Text, Value)] -> IO (Either Error (Outcome ToolResult)) callWeather name args = snd weatherHandlers anonCtx name (valueArgs args)  -- The serialized form the derivation guarantees for 'sampleReport'@@ -64,7 +64,7 @@   describe "ToolOutput results" $ do     it "serializes the typed value into structuredContent with a matching text block" $ do       result <- callWeather "get_weather" [("wtCity", String "Cape Town")]-      case result of+      case completed result of         Right tr -> do           toolResultIsError tr `shouldBe` False           toolResultStructured tr `shouldBe` Just (expectedReport "Cape Town" (Just 60))@@ -77,26 +77,26 @@      it "renders the text block canonically (sorted keys, build-plan independent)" $ do       result <- callWeather "get_weather" [("wtCity", String "Cape Town")]-      case result of+      case completed result of         Right tr -> toolResultContent tr `shouldBe`           [ContentText "{\"wrAlerts\":[\"wind advisory for Cape Town\"],\"wrHumidity\":60,\"wrSky\":\"sky_cloudy\",\"wrTemperature\":21,\"wrWind\":{\"windGusting\":false,\"windSpeedKph\":12.5}}"]         Left err -> expectationFailure $ "expected result, got error: " ++ show err      it "omits Nothing fields from the serialized value" $ do       result <- callWeather "custom_report" [("wtRegion", String "Karoo")]-      case result of+      case completed result of         Right tr -> toolResultStructured tr `shouldBe` Just (expectedReport "Karoo" Nothing)         Left err -> expectationFailure $ "expected result, got error: " ++ show err      it "keeps caller-supplied content blocks with ToolOutputWith" $ do       result <- callWeather "custom_report" [("wtRegion", String "Karoo")]-      case result of+      case completed result of         Right tr -> toolResultContent tr `shouldBe` [ContentText "Report for Karoo"]         Left err -> expectationFailure $ "expected result, got error: " ++ show err      it "reports ToolOutputError as an isError result" $ do       result <- callWeather "broken_station" [("wtStation", String "X1")]-      case result of+      case completed result of         Right tr -> do           toolResultIsError tr `shouldBe` True           toolResultStructured tr `shouldBe` Nothing@@ -105,7 +105,7 @@      it "passes ToolOutputRaw through untouched" $ do       result <- callWeather "plain_forecast" [("wtNote", String "sunny")]-      case result of+      case completed result of         Right tr -> do           toolResultStructured tr `shouldBe` Nothing           toolResultContent tr `shouldBe` [ContentText "sunny"]
test/Spec/GoldenWire.hs view
@@ -30,7 +30,9 @@  import Control.Monad (forM_) import Data.Aeson+import qualified Data.Aeson.KeyMap as KM import qualified Data.ByteString.Lazy as BSL+import Data.Foldable (toList) import qualified Data.Map as Map import Data.Text (Text) import qualified Data.Text as T@@ -41,6 +43,7 @@ import System.Directory (doesFileExist) import System.Environment (lookupEnv) import Test.Hspec+import TestHelpers (testStateCodec)  goldenServerInfo :: McpServerInfo goldenServerInfo = McpServerInfo@@ -64,7 +67,7 @@           [ArgumentDefinition "name" "Who to greet" True]       ]     promptGet _ name args = case name of-      "greet" -> pure $ Right $ PromptResult (Just "A greeting")+      "greet" -> pure $ Right $ Complete $ PromptResult (Just "A greeting")         [PromptMessage RoleUser (ContentText ("Hello, " <> Map.findWithDefault "?" "name" args <> "!"))]       _ -> pure $ Left $ InvalidPromptName name @@ -75,7 +78,7 @@           }       ]     resourceRead _ uri-      | show uri == "resource://info" = pure $ Right $ ResourceText uri "text/plain" "The golden info"+      | show uri == "resource://info" = pure $ Right $ Complete $ ResourceText uri "text/plain" "The golden info"       | otherwise = pure $ Left $ ResourceNotFound $ T.pack $ show uri      toolList _ = pure@@ -85,9 +88,9 @@       ]     toolCall _ name args = case name of       "echo" -> case Map.lookup "text" args of-        Just (String t) -> pure $ Right $ toolResult [ContentText ("echo: " <> t)]+        Just (String t) -> pure $ Right $ Complete $ toolResult [ContentText ("echo: " <> t)]         _               -> pure $ Left $ MissingRequiredParams "field 'text' is missing"-      "boom" -> pure $ Right $ toolError "kaboom"+      "boom" -> pure $ Right $ Complete $ toolError "kaboom"       _      -> pure $ Left $ UnknownTool name  -- The extended set's structured-output tool, derived so the corpus pins@@ -174,11 +177,37 @@            )   } +-- | 'goldenHandlers' with one tool that asks for input (multi round-trip+-- requests): @confirm@ asks for a boolean @ok@ under the key @proceed@,+-- with a request state, until it receives an answer.+mrtrHandlers :: McpServerHandlers+mrtrHandlers = goldenHandlers+  { tools = Just+      ( \_ -> pure []+      , \ctx _name _args -> pure $ Right $+          case Map.lookup "proceed" (clientInputResponses ctx) of+            Nothing -> InputRequired+              (Map.singleton "proceed" (ElicitForm "Proceed?"+                (schema (SchemaObject [("ok", schema SchemaBoolean)] ["ok"]))))+              (Just "confirming")+            Just _ -> Complete (toolResult [ContentText "confirmed"])+      )+  }++-- | A fixture value of @"<opaque>"@ matches any string: used for values a+-- server generates afresh, such as a sealed @requestState@. Returns the+-- actual value with those strings replaced, ready for an equality check.+matchOpaque :: Value -> Value -> Value+matchOpaque (String "<opaque>") (String _) = String "<opaque>"+matchOpaque (Object f) (Object a) = Object (KM.mapWithKey (\k v -> maybe v (`matchOpaque` v) (KM.lookup k f)) a)+matchOpaque (Array f) (Array a) | length f == length a = toJSON (zipWith matchOpaque (toList f) (toList a))+matchOpaque _ actual = actual+ -- | One manifest entry: which case, and which reference-server variant -- answers it. data GoldenCase = GoldenCase   { caseName          :: FilePath-  , caseHandlers      :: Text  -- ^ "base" or "extended"+  , caseHandlers      :: Text  -- ^ "base", "extended" or "mrtr"   , caseNotifications :: Bool   } @@ -205,11 +234,14 @@ runCase gc raw = do   jsonValue <- either (fail . ("request does not parse: " ++)) pure (eitherDecode raw)   message <- either (fail . ("request is not JSON-RPC: " ++)) pure (parseJsonRpcMessage jsonValue)-  let handlers = if caseHandlers gc == "extended" then extendedHandlers else goldenHandlers+  let handlers = case caseHandlers gc of+        "extended" -> extendedHandlers+        "mrtr"     -> mrtrHandlers+        _          -> goldenHandlers       support = if caseNotifications gc         then NotificationSupport { supportsLegacyPush = True, supportsListen = True }         else noNotificationSupport-  maybeResponse <- handleMcpMessage goldenServerInfo defaultCacheHints support (\_ -> pure ()) handlers anonymousContext message+  maybeResponse <- handleMcpMessage goldenServerInfo defaultCacheHints testStateCodec support (\_ -> pure ()) handlers anonymousContext message   case maybeResponse of     Just responseMsg -> pure $ encodeJsonRpcMessage responseMsg     Nothing          -> fail "expected a response"@@ -229,7 +261,7 @@         fixtureBytes <- BSL.readFile responsePath         case eitherDecode fixtureBytes :: Either String Value of           Left err      -> expectationFailure $ "fixture does not parse: " ++ err-          Right fixture -> actual `shouldBe` fixture+          Right fixture -> matchOpaque fixture actual `shouldBe` fixture  spec :: Spec spec = describe "Golden wire-format fixtures" $ do
test/Spec/HandlerExceptions.hs view
@@ -14,6 +14,7 @@ import MCP.Server.Handlers (handleMcpMessage) import MCP.Server.JsonRpc import Test.Hspec+import TestHelpers (testStateCodec)  throwingServer :: McpServerHandlers throwingServer = noHandlers@@ -29,7 +30,7 @@  call :: T.Text -> Value -> IO (Maybe JsonRpcMessage) call method params =-  handleMcpMessage (McpServerInfo "T" "1" "") defaultCacheHints+  handleMcpMessage (McpServerInfo "T" "1" "") defaultCacheHints testStateCodec     noNotificationSupport (\_ -> pure ()) throwingServer anonymousContext     (JsonRpcMessageRequest (JsonRpcRequest "2.0" (RequestIdNumber 7) method (Just params))) 
test/Spec/HttpTransport.hs view
@@ -17,25 +17,37 @@ import Data.IORef import Data.Text (Text) import qualified Data.Text as T+import qualified Data.Text.Encoding as TE import MCP.Server import qualified Network.HTTP.Types as HTTP import qualified Network.Wai as Wai import Network.Wai.Internal (ResponseReceived (..)) import Test.Hspec+import TestHelpers (testStateCodec)+import qualified Data.Map as Map  server :: McpServerHandlers server = noHandlers   { tools = Just       ( \_ -> pure []-      , \_ name _ -> case name of+      , \ctx name _ -> case name of           "boom" -> throwIO (ErrorCall "tool exploded")-          "ok"   -> pure $ Right $ toToolResult ("fine" :: Text)+          "ok"   -> pure $ Right $ Complete $ toToolResult ("fine" :: Text)+          "ask"  -> pure $ Right $ case Map.lookup "q" (clientInputResponses ctx) of+            Nothing -> InputRequired+              (Map.singleton "q" (ElicitForm "Sure?" (schema (SchemaObject [] []))))+              (Just "asked")+            Just _ -> Complete $ toToolResult+              ("answered after " <> maybe "nothing" id (clientRequestState ctx))           _      -> pure $ Left $ UnknownTool name       )   } -app :: Wai.Application-app = mcpApplication defaultHttpConfig { httpAllowedOrigins = Nothing }+app :: IO Wai.Application+app = mcpApplication defaultHttpConfig+        { httpAllowedOrigins = Nothing+        , httpStateCodec = Just testStateCodec  -- shared across 'post's+        }         (McpServerInfo "T" "1" "") server  -- | POST a JSON body to /mcp and collect the full response, whether it was@@ -57,7 +69,8 @@         }   out <- newIORef mempty   result <- newIORef Nothing-  _ <- app req $ \resp -> do+  application <- app+  _ <- application req $ \resp -> do     let (status, headers, withBody) = Wai.responseToStream resp     withBody $ \streamingBody ->       streamingBody (\b -> modifyIORef' out (<> b)) (pure ())@@ -90,8 +103,49 @@   String m <- KM.lookup "message" e   pure (round c, m) +-- | A modern tools/call of the "ask" tool, with extra params.+askCall :: [(Key, Value)] -> Value+askCall extra = object+  [ "jsonrpc" .= ("2.0" :: Text)+  , "id" .= (8 :: Int)+  , "method" .= ("tools/call" :: Text)+  , "params" .= object (+      [ "name" .= ("ask" :: Text)+      , "_meta" .= object+          [ "io.modelcontextprotocol/protocolVersion" .= ("2026-07-28" :: Text)+          , "io.modelcontextprotocol/clientCapabilities" .= object ["elicitation" .= object []]+          ]+      ] ++ extra)+  ]++modernHeaders :: Text -> [HTTP.Header]+modernHeaders name =+  [ ("MCP-Protocol-Version", "2026-07-28")+  , ("Mcp-Method", "tools/call")+  , ("Mcp-Name", TE.encodeUtf8 name)+  ]++resultIn :: BS.ByteString -> Maybe Object+resultIn raw = do+  Object o <- decodeStrict raw+  Object r <- KM.lookup "result" o+  pure r+ spec :: Spec spec = describe "HTTP transport (in-process WAI)" $ do+  it "carries a multi round-trip request across two POSTs" $ do+    (status1, _, body1) <- post (modernHeaders "ask") (askCall [])+    HTTP.statusCode status1 `shouldBe` 200+    let first = resultIn body1+    (first >>= KM.lookup "resultType") `shouldBe` Just (String "input_required")+    Just (String sealed) <- pure (first >>= KM.lookup "requestState")+    (status2, _, body2) <- post (modernHeaders "ask") $ askCall+      [ "inputResponses" .= object ["q" .= object ["action" .= ("accept" :: Text), "content" .= object []]]+      , "requestState" .= sealed+      ]+    HTTP.statusCode status2 `shouldBe` 200+    body2 `shouldSatisfy` ("answered after asked" `BS.isInfixOf`)+   it "answers a normal tool call with 200 and a result" $ do     (status, _, body) <- post [] (toolCall "ok" [])     HTTP.statusCode status `shouldBe` 200
+ test/Spec/InputRequired.hs view
@@ -0,0 +1,369 @@+{-# LANGUAGE OverloadedStrings #-}++-- | Coverage for multi round-trip requests (ADR 0009, phase 1): the+-- request-state codec, and dispatch of 'InputRequired' outcomes — the+-- @input_required@ result, verified retries, and every way a retry's state+-- is rejected.+module Spec.InputRequired (spec) where++import           Data.Aeson+import qualified Data.Aeson.KeyMap   as KM+import           Data.Aeson.Types    (Pair)+import qualified Data.ByteString     as BS+import qualified Data.Map            as Map+import           Data.Maybe          (fromMaybe)+import           Data.Text           (Text)+import qualified Data.Text           as T+import           MCP.Server+import           MCP.Server.Handlers (handleMcpMessage)+import           MCP.Server.JsonRpc+import           Test.Hspec+import           TestHelpers         (testStateCodec)++-- | A tool that asks for confirmation, then reports what it was given.+confirmTool :: ToolCallHandler+confirmTool ctx _name args = pure $ Right $+  case Map.lookup "proceed" (clientInputResponses ctx) of+    Nothing -> InputRequired+      (Map.singleton "proceed" (ElicitForm ("Deploy " <> target <> "?") confirmSchema))+      (Just ("planned:" <> target))+    Just (ElicitResponse ElicitAccept (Just content)) -> Complete $ toolResult+      [ ContentText $ "state=" <> fromMaybe "none" (clientRequestState ctx)+          <> " answer=" <> T.pack (show (Map.lookup "ok" content))+      ]+    Just other -> Complete $ toolError ("not confirmed: " <> T.pack (show other))+  where+    target = case Map.lookup "target" args of+      Just (String t) -> t+      _               -> "nowhere"++-- | A tool that sends the user to a URL.+linkTool :: ToolCallHandler+linkTool _ _ _ = pure $ Right $+  InputRequired (Map.singleton "login" (ElicitUrl "Sign in" loginUri)) Nothing+  where+    loginUri = fromMaybe (error "bad URI") (parseURI "https://example.com/login")++confirmSchema :: Schema+confirmSchema = schema $ SchemaObject [("ok", schema SchemaBoolean)] ["ok"]++handlers :: McpServerHandlers+handlers = noHandlers+  { tools = Just+      ( \_ -> pure []+      , \ctx name args -> case name of+          "link" -> linkTool ctx name args+          _      -> confirmTool ctx name args+      )+  , resources = Just+      ( \_ -> pure []+      , \ctx uri -> pure $ Right $+          if Map.null (clientInputResponses ctx)+            then InputRequired (Map.singleton "q" (ElicitForm "Which?" confirmSchema)) Nothing+            else Complete (ResourceText uri "text/plain" "read after asking")+      )+  , prompts = Just+      ( \_ -> pure []+      , \ctx _ _ -> pure $ Right $+          if Map.null (clientInputResponses ctx)+            then InputRequired (Map.singleton "q" (ElicitForm "Tone?" confirmSchema)) (Just "p")+            else Complete (PromptResult Nothing [PromptMessage RoleUser (ContentText "asked")])+      )+  }++serverInfo :: McpServerInfo+serverInfo = McpServerInfo "T" "1" ""++-- | Dispatch one request through the given codec and context.+runWith :: StateCodec -> ClientContext -> Text -> Value -> IO Value+runWith codec ctx method params = do+  response <- handleMcpMessage serverInfo defaultCacheHints codec noNotificationSupport+    (\_ -> pure ()) handlers ctx $ JsonRpcMessageRequest JsonRpcRequest+      { requestJsonrpc = "2.0"+      , requestId = RequestIdNumber 1+      , requestMethod = method+      , requestParams = Just params+      }+  case response of+    Just (JsonRpcMessageResponse r) -> pure (toJSON r)+    other -> fail ("expected a response, got " ++ show other)++run :: Text -> Value -> IO Value+run = runWith testStateCodec anonymousContext++-- | Modern params with the given client capabilities.+withCaps :: Value -> [Pair] -> Value+withCaps caps extra = object $ extra +++  [ "_meta" .= object+      [ "io.modelcontextprotocol/protocolVersion" .= ("2026-07-28" :: Text)+      , "io.modelcontextprotocol/clientCapabilities" .= caps+      ]+  ]++formCaps, urlCaps, noCaps :: Value+formCaps = object ["elicitation" .= object ["form" .= object []]]+urlCaps = object ["elicitation" .= object ["url" .= object []]]+noCaps = object []++callParams :: Value -> Text -> [Pair] -> Value+callParams caps target extra = withCaps caps $+  [ "name" .= ("deploy" :: Text)+  , "arguments" .= object ["target" .= target]+  ] ++ extra++at :: [Key] -> Value -> Maybe Value+at [] v = Just v+at (k : ks) (Object o) = KM.lookup k o >>= at ks+at _ _ = Nothing++stateOf :: Value -> Text+stateOf v = case at ["result", "requestState"] v of+  Just (String s) -> s+  _               -> error ("no requestState in " ++ show v)++errorOf :: Value -> Maybe (Int, Text)+errorOf v = do+  Number code <- at ["error", "code"] v+  String msg <- at ["error", "message"] v+  pure (round code, msg)++accept :: Value+accept = object ["proceed" .= object ["action" .= ("accept" :: Text), "content" .= object ["ok" .= True]]]++-- | The first round, then the retry with the given answers and state.+retry :: Value -> Text -> [Pair] -> IO Value+retry caps target extra = run "tools/call" (callParams caps target extra)++spec :: Spec+spec = describe "Multi round-trip requests" $ do+  describe "The AEAD state codec" $ do+    let key kid n = fromMaybe (error "bad key") (stateKey kid (BS.replicate 32 n))+        keyA = key "a" 1+        keyB = key "b" 2++    it "opens what it sealed" $ do+      let codec = aeadStateCodec keyA []+      sealed <- sealState codec "hello"+      openState codec sealed `shouldReturn` Right "hello"++    it "uses a fresh nonce for every state" $ do+      let codec = aeadStateCodec keyA []+      s1 <- sealState codec "same"+      s2 <- sealState codec "same"+      s1 `shouldNotBe` s2++    it "rejects a state altered after sealing" $ do+      let codec = aeadStateCodec keyA []+      sealed <- sealState codec "hello"+      -- Change one base64 character well inside the payload+      let (prefix, rest) = T.splitAt (T.length (fst (T.breakOn "." sealed)) + 10) sealed+          swapped = if T.head rest == 'A' then 'B' else 'A'+          tampered = prefix <> T.cons swapped (T.tail rest)+      openState codec tampered `shouldReturn` Left StateInvalid++    it "reports a state sealed under a key it does not have" $ do+      sealed <- sealState (aeadStateCodec keyA []) "hello"+      openState (aeadStateCodec keyB []) sealed `shouldReturn` Left StateUnknownKey++    it "opens states sealed under a retired key during rotation" $ do+      sealed <- sealState (aeadStateCodec keyA []) "hello"+      openState (aeadStateCodec keyB [keyA]) sealed `shouldReturn` Right "hello"++    it "gives each ephemeral codec its own key" $ do+      c1 <- ephemeralStateCodec+      c2 <- ephemeralStateCodec+      sealed <- sealState c1 "hello"+      openState c1 sealed `shouldReturn` Right "hello"+      openState c2 sealed `shouldReturn` Left StateUnknownKey++    it "validates keys and key ids" $ do+      fmap stateKeyId (stateKey "k-1_x" (BS.replicate 32 0)) `shouldBe` Just "k-1_x"+      fmap stateKeyId (stateKey "k" (BS.replicate 31 0)) `shouldBe` Nothing+      fmap stateKeyId (stateKey "has.dot" (BS.replicate 32 0)) `shouldBe` Nothing+      fmap stateKeyId (stateKey "" (BS.replicate 32 0)) `shouldBe` Nothing++  describe "clientSupportsInput" $ do+    let modern caps = anonymousContext+          { clientProtocolVersion = Just "2026-07-28", clientCapabilities = Just caps }+        form = ElicitForm "?" confirmSchema+        url = ElicitUrl "?" (fromMaybe (error "bad URI") (parseURI "https://example.com"))+    it "requires a modern client" $+      clientSupportsInput anonymousContext { clientCapabilities = Just formCaps } form `shouldBe` False+    it "treats an empty elicitation capability as form only" $ do+      let ctx = modern (object ["elicitation" .= object []])+      clientSupportsInput ctx form `shouldBe` True+      clientSupportsInput ctx url `shouldBe` False+    it "checks each elicitation mode" $ do+      clientSupportsInput (modern urlCaps) url `shouldBe` True+      clientSupportsInput (modern urlCaps) form `shouldBe` False+    it "checks sampling and roots, and refuses other methods" $ do+      let ctx = modern (object ["sampling" .= object [], "roots" .= object []])+      clientSupportsInput ctx (RawInputRequest "sampling/createMessage" Null) `shouldBe` True+      clientSupportsInput ctx (RawInputRequest "roots/list" Null) `shouldBe` True+      clientSupportsInput ctx (RawInputRequest "tools/call" Null) `shouldBe` False+      clientSupportsInput (modern noCaps) (RawInputRequest "roots/list" Null) `shouldBe` False++  describe "The first round" $ do+    it "answers with an input_required result" $ do+      v <- run "tools/call" (callParams formCaps "prod" [])+      at ["result", "resultType"] v `shouldBe` Just (String "input_required")+      at ["result", "inputRequests", "proceed"] v `shouldBe` Just (object+        [ "method" .= ("elicitation/create" :: Text)+        , "params" .= object+            [ "mode" .= ("form" :: Text)+            , "message" .= ("Deploy prod?" :: Text)+            , "requestedSchema" .= confirmSchema+            ]+        ])+      stateOf v `shouldSatisfy` T.isPrefixOf "test."+      at ["result", "_meta", "io.modelcontextprotocol/serverInfo", "name"] v+        `shouldBe` Just (String "T")++    it "does not reveal the handler's state" $ do+      v <- run "tools/call" (callParams formCaps "prod" [])+      stateOf v `shouldNotSatisfy` T.isInfixOf "planned"++    it "sends URL-mode elicitation" $ do+      v <- run "tools/call" (withCaps urlCaps ["name" .= ("link" :: Text)])+      at ["result", "inputRequests", "login", "params"] v `shouldBe` Just (object+        [ "mode" .= ("url" :: Text)+        , "message" .= ("Sign in" :: Text)+        , "url" .= ("https://example.com/login" :: Text)+        ])++    it "omits cacheability fields from input_required results" $ do+      v <- run "resources/read" (withCaps formCaps ["uri" .= ("resource://x" :: Text)])+      at ["result", "resultType"] v `shouldBe` Just (String "input_required")+      at ["result", "ttlMs"] v `shouldBe` Nothing+      at ["result", "cacheScope"] v `shouldBe` Nothing++    it "works for prompts/get" $ do+      v <- run "prompts/get" (withCaps formCaps ["name" .= ("p" :: Text)])+      at ["result", "resultType"] v `shouldBe` Just (String "input_required")++  describe "The retry" $ do+    it "completes with the answers and the handler's own state" $ do+      first <- run "tools/call" (callParams formCaps "prod" [])+      v <- retry formCaps "prod" ["inputResponses" .= accept, "requestState" .= stateOf first]+      at ["result", "resultType"] v `shouldBe` Just (String "complete")+      at ["result", "content"] v `shouldBe` Just (toJSON+        [ContentText "state=planned:prod answer=Just (Bool True)"])++    it "passes declined answers to the handler" $ do+      first <- run "tools/call" (callParams formCaps "prod" [])+      v <- retry formCaps "prod"+        [ "inputResponses" .= object ["proceed" .= object ["action" .= ("decline" :: Text)]]+        , "requestState" .= stateOf first+        ]+      at ["result", "isError"] v `shouldBe` Just (Bool True)++    it "completes a resource read after asking" $ do+      first <- run "resources/read" (withCaps formCaps ["uri" .= ("resource://x" :: Text)])+      v <- run "resources/read" $ withCaps formCaps+        [ "uri" .= ("resource://x" :: Text)+        , "inputResponses" .= object ["q" .= object ["action" .= ("accept" :: Text), "content" .= object ["ok" .= True]]]+        , "requestState" .= stateOf first+        ]+      at ["result", "resultType"] v `shouldBe` Just (String "complete")+      at ["result", "ttlMs"] v `shouldSatisfy` (/= Nothing)++  describe "Rejected state" $ do+    it "rejects a state presented with different arguments" $ do+      first <- run "tools/call" (callParams formCaps "prod" [])+      v <- retry formCaps "staging" ["inputResponses" .= accept, "requestState" .= stateOf first]+      errorOf v `shouldBe` Just (-32602, "Invalid requestState")++    it "rejects a state presented on another method" $ do+      first <- run "prompts/get" (withCaps formCaps ["name" .= ("p" :: Text)])+      v <- run "resources/read" (withCaps formCaps ["uri" .= ("resource://x" :: Text), "requestState" .= stateOf first])+      errorOf v `shouldBe` Just (-32602, "Invalid requestState")++    it "rejects a state presented by another principal" $ do+      let alice = anonymousContext { clientPrincipal = Just (String "alice") }+          bob = anonymousContext { clientPrincipal = Just (String "bob") }+      first <- runWith testStateCodec alice "tools/call" (callParams formCaps "prod" [])+      v <- runWith testStateCodec bob "tools/call"+        (callParams formCaps "prod" ["inputResponses" .= accept, "requestState" .= stateOf first])+      errorOf v `shouldBe` Just (-32602, "Invalid requestState")+      ok <- runWith testStateCodec alice "tools/call"+        (callParams formCaps "prod" ["inputResponses" .= accept, "requestState" .= stateOf first])+      at ["result", "resultType"] ok `shouldBe` Just (String "complete")++    it "rejects an expired state" $ do+      let shortLived = testStateCodec { stateLifetime = -1 }+      first <- runWith shortLived anonymousContext "tools/call" (callParams formCaps "prod" [])+      v <- runWith shortLived anonymousContext "tools/call"+        (callParams formCaps "prod" ["inputResponses" .= accept, "requestState" .= stateOf first])+      fmap fst (errorOf v) `shouldBe` Just (-32602)+      fmap snd (errorOf v) `shouldSatisfy` maybe False (T.isInfixOf "expired")++    it "explains a state from another instance" $ do+      other <- ephemeralStateCodec+      first <- runWith other anonymousContext "tools/call" (callParams formCaps "prod" [])+      v <- retry formCaps "prod" ["inputResponses" .= accept, "requestState" .= stateOf first]+      fmap snd (errorOf v) `shouldSatisfy` maybe False (T.isInfixOf "another server instance")++    it "rejects a state that is not a string" $ do+      v <- retry formCaps "prod" ["requestState" .= (42 :: Int)]+      fmap fst (errorOf v) `shouldBe` Just (-32602)++    it "rejects a malformed answer to an issued request" $ do+      first <- run "tools/call" (callParams formCaps "prod" [])+      v <- retry formCaps "prod"+        ["inputResponses" .= object ["proceed" .= (1 :: Int)], "requestState" .= stateOf first]+      fmap fst (errorOf v) `shouldBe` Just (-32602)+      fmap snd (errorOf v) `shouldSatisfy` maybe False (T.isInfixOf "proceed")++    it "rejects inputResponses that are not an object" $ do+      first <- run "tools/call" (callParams formCaps "prod" [])+      v <- retry formCaps "prod" ["inputResponses" .= (1 :: Int), "requestState" .= stateOf first]+      fmap fst (errorOf v) `shouldBe` Just (-32602)++  describe "Answers the handler did not ask for" $ do+    it "ignores answers sent without a request state" $ do+      v <- retry formCaps "prod" ["inputResponses" .= accept]+      at ["result", "resultType"] v `shouldBe` Just (String "input_required")++    it "ignores keys the previous round did not issue" $ do+      first <- run "resources/read" (withCaps formCaps ["uri" .= ("resource://x" :: Text)])+      v <- run "resources/read" $ withCaps formCaps+        [ "uri" .= ("resource://x" :: Text)+        , "inputResponses" .= object ["sneaky" .= object ["action" .= ("accept" :: Text)]]+        , "requestState" .= stateOf first+        ]+      -- The handler saw no answers, so it asked again.+      at ["result", "resultType"] v `shouldBe` Just (String "input_required")++    it "ignores unknown keys whatever their value" $ do+      first <- run "tools/call" (callParams formCaps "prod" [])+      v <- retry formCaps "prod"+        [ "inputResponses" .= object+            [ "proceed" .= object ["action" .= ("accept" :: Text), "content" .= object ["ok" .= True]]+            , "extra" .= (42 :: Int)+            ]+        , "requestState" .= stateOf first+        ]+      at ["result", "resultType"] v `shouldBe` Just (String "complete")++  describe "Clients that cannot answer" $ do+    it "is a server error to ask a client without the capability" $ do+      v <- run "tools/call" (callParams noCaps "prod" [])+      fmap fst (errorOf v) `shouldBe` Just (-32603)+      fmap snd (errorOf v) `shouldSatisfy` maybe False (T.isInfixOf "proceed")++    it "is a server error to ask for a mode the client lacks" $ do+      v <- run "tools/call" (withCaps formCaps ["name" .= ("link" :: Text)])+      fmap fst (errorOf v) `shouldBe` Just (-32603)++    it "is a server error to ask a legacy client" $ do+      v <- run "tools/call" (object ["name" .= ("deploy" :: Text)])+      fmap fst (errorOf v) `shouldBe` Just (-32603)++    it "ignores round fields on legacy requests" $ do+      v <- run "tools/call" (object+        [ "name" .= ("deploy" :: Text)+        , "inputResponses" .= accept+        , "requestState" .= ("anything" :: Text)+        ])+      -- The handler saw no answers, so it asked, which legacy cannot carry.+      fmap fst (errorOf v) `shouldBe` Just (-32603)
test/Spec/ModernEra.hs view
@@ -18,6 +18,7 @@                                   wantsStreamingResponse) import qualified Network.Wai as Wai import Test.Hspec+import TestHelpers (testStateCodec)  testServerInfo :: McpServerInfo testServerInfo = McpServerInfo@@ -32,13 +33,13 @@ testHandlers = noHandlers   { tools = Just       ( \_ctx -> pure []-      , \ctx _name _args -> pure $ Right $ toToolResult+      , \ctx _name _args -> pure $ Right $ Complete $ toToolResult           ("version=" <> maybe "none" id (clientProtocolVersion ctx))       )   }  run :: JsonRpcMessage -> IO (Maybe JsonRpcMessage)-run = handleMcpMessage testServerInfo defaultCacheHints noNotificationSupport (\_ -> pure ()) testHandlers anonymousContext+run = handleMcpMessage testServerInfo defaultCacheHints testStateCodec noNotificationSupport (\_ -> pure ()) testHandlers anonymousContext  request :: Text -> Maybe Value -> JsonRpcMessage request method params = JsonRpcMessageRequest $ JsonRpcRequest
test/Spec/Progress.hs view
@@ -14,6 +14,7 @@ import MCP.Server.Handlers (handleMcpMessage) import MCP.Server.JsonRpc import Test.Hspec+import TestHelpers (testStateCodec)  progressServer :: McpServerHandlers progressServer = noHandlers@@ -23,11 +24,11 @@           "work" -> do             reportProgress ctx 0.2 (Just 1.0) (Just "starting")             reportProgress ctx 0.9 Nothing Nothing-            pure $ Right $ toToolResult ("done" :: Text)+            pure $ Right $ Complete $ toToolResult ("done" :: Text)           "chatty" -> do             logToClient ctx LogInfo (String "fyi")             logToClient ctx LogError (String "bad")-            pure $ Right $ toToolResult ("ok" :: Text)+            pure $ Right $ Complete $ toToolResult ("ok" :: Text)           _ -> pure $ Left $ UnknownTool name       )   }@@ -41,7 +42,7 @@       params = object $         ["name" .= tool, "arguments" .= object []]           ++ (if null metaFields then [] else ["_meta" .= object metaFields])-  resp <- handleMcpMessage (McpServerInfo "T" "1" "") defaultCacheHints+  resp <- handleMcpMessage (McpServerInfo "T" "1" "") defaultCacheHints testStateCodec     noNotificationSupport emit progressServer anonymousContext     (JsonRpcMessageRequest (JsonRpcRequest "2.0" (RequestIdNumber 1) "tools/call" (Just params)))   notifications <- readIORef sink
test/Spec/Subscriptions.hs view
@@ -13,6 +13,7 @@ import MCP.Server.JsonRpc import MCP.Server.Notifications import Test.Hspec+import TestHelpers (testStateCodec)  subId :: RequestId subId = RequestIdNumber 7@@ -119,7 +120,7 @@           }         run support method params = handleMcpMessage           (McpServerInfo "T" "1" "")-          defaultCacheHints+          defaultCacheHints testStateCodec           support           (\_ -> pure ())           toolsServer
test/Spec/TemplatesCompletions.hs view
@@ -15,6 +15,7 @@ import MCP.Server.Handlers (handleMcpMessage) import MCP.Server.JsonRpc import Test.Hspec+import TestHelpers (testStateCodec) import TestTypes  templResourceHandlers :: (ResourceListHandler, ResourceReadHandler)@@ -24,13 +25,13 @@ templTemplates = $(deriveResourceTemplatesWithDescription ''TemplResource   [("MemberProfile", "A member's profile"), ("OrderItem", "One item of an order")]) -readUri :: String -> IO (Either Error ResourceContent)+readUri :: String -> IO (Either Error (Outcome ResourceContent)) readUri s = case parseURI s of   Just uri -> snd templResourceHandlers anonCtx uri   Nothing  -> fail ("bad test URI: " ++ s) -expectText :: Either Error ResourceContent -> Text -> Expectation-expectText (Right (ResourceText _ _ content)) expected = content `shouldBe` expected+expectText :: Either Error (Outcome ResourceContent) -> Text -> Expectation+expectText (Right (Complete (ResourceText _ _ content))) expected = content `shouldBe` expected expectText other expected =   expectationFailure $ "Expected ResourceText '" ++ T.unpack expected ++ "' but got: " ++ show other @@ -51,7 +52,7 @@ runReq handlers method params =   handleMcpMessage     (McpServerInfo "T" "1" "")-    defaultCacheHints+    defaultCacheHints testStateCodec     noNotificationSupport     (\_ -> pure ())     handlers
test/Spec/ToolCallParsing.hs view
@@ -3,7 +3,6 @@  module Spec.ToolCallParsing (spec) where -import Data.Aeson (Value (..)) import Data.Text (Text) import qualified Data.Text as T import MCP.Server@@ -15,15 +14,15 @@ allTypesHandlers :: (ToolListHandler, ToolCallHandler) allTypesHandlers = $(deriveToolHandler ''AllTypesTool 'handleAllTypesTool) -callTool :: Text -> [(Text, Text)] -> IO (Either Error ToolResult)+callTool :: Text -> [(Text, Text)] -> IO (Either Error (Outcome ToolResult)) callTool name args = snd allTypesHandlers anonCtx name (stringArgs args) -shouldBeRight :: IO (Either Error ToolResult) -> Text -> IO ()+shouldBeRight :: IO (Either Error (Outcome ToolResult)) -> Text -> IO () shouldBeRight action expected = do   result <- action   result `shouldBeTextResult` expected -shouldBeInvalidParams :: IO (Either Error ToolResult) -> Text -> IO ()+shouldBeInvalidParams :: IO (Either Error (Outcome ToolResult)) -> Text -> IO () shouldBeInvalidParams action expectedSubstring = do   result <- action   case result of@@ -31,7 +30,7 @@       T.isInfixOf expectedSubstring msg `shouldBe` True     other -> expectationFailure $ "Expected InvalidParams containing '" ++ T.unpack expectedSubstring ++ "' but got: " ++ show other -shouldBeMissingParams :: IO (Either Error ToolResult) -> Text -> IO ()+shouldBeMissingParams :: IO (Either Error (Outcome ToolResult)) -> Text -> IO () shouldBeMissingParams action expectedSubstring = do   result <- action   case result of
test/Spec/TypedArgs.hs view
@@ -6,7 +6,7 @@ -- prompts. module Spec.TypedArgs (spec) where -import Data.Aeson (Value (..), object, toJSON, (.=))+import Data.Aeson (object, toJSON, (.=)) import Data.Text (Text) import qualified Data.Text as T import MCP.Server@@ -21,7 +21,7 @@ convHandlers :: (PromptListHandler, PromptGetHandler) convHandlers = $(derivePromptHandler ''ConvPrompt 'handleConvPrompt) -callTyped :: Text -> [(Text, Value)] -> IO (Either Error ToolResult)+callTyped :: Text -> [(Text, Value)] -> IO (Either Error (Outcome ToolResult)) callTyped name args = snd typedHandlers anonCtx name (valueArgs args)  toolNamed :: Text -> [ToolDefinition] -> ToolDefinition@@ -29,7 +29,7 @@   (d:_) -> d   []    -> error $ "tool not found: " ++ T.unpack n -expectInvalidParams :: Either Error ToolResult -> Text -> Expectation+expectInvalidParams :: Either Error (Outcome ToolResult) -> Text -> Expectation expectInvalidParams (Left (InvalidParams msg)) expectedSubstring =   msg `shouldSatisfy` T.isInfixOf expectedSubstring expectInvalidParams other expectedSubstring =@@ -113,7 +113,7 @@   describe "Tool execution errors" $ do     it "reports handler failures as isError results, not protocol errors" $ do       result <- callTyped "always_fails" [("reason", String "boom")]-      case result of+      case completed result of         Right tr -> do           toolResultIsError tr `shouldBe` True           toolResultContent tr `shouldBe` [ContentText "boom"]@@ -128,7 +128,7 @@   describe "Multi-message prompts" $ do     it "returns a described multi-message conversation with roles" $ do       result <- snd convHandlers anonCtx "conversation" (promptArgsMap [("topic", "haskell")])-      case result of+      case completed result of         Right (PromptResult desc msgs) -> do           desc `shouldBe` Just "About haskell"           map promptMessageRole msgs `shouldBe` [RoleUser, RoleAssistant]
test/Spec/UnicodeHandling.hs view
@@ -178,7 +178,7 @@        let promptGetHandler name args = case name of             "math_formula" -> case Map.lookup "formula" args of-              Just formula -> return $ Right $ toPromptResult $ ContentText $ "Formula: " <> formula <> " → √(solution)"+              Just formula -> return $ Right $ Complete $ toPromptResult $ ContentText $ "Formula: " <> formula <> " → √(solution)"               Nothing -> return $ Left $ MissingRequiredParams "formula"             _ -> return $ Left $ InvalidPromptName name @@ -191,7 +191,7 @@        let resourceReadHandler uri =             if show uri == "resource://unicode_symbols"-              then return $ Right $ ResourceText uri "text/plain" "Symbols: ∀∃∈∉∪∩⊆⊇√∑∏∞≤≥≠≈π∅"+              then return $ Right $ Complete $ ResourceText uri "text/plain" "Symbols: ∀∃∈∉∪∩⊆⊇√∑∏∞≤≥≠≈π∅"               else return $ Left $ ResourceNotFound $ T.pack $ show uri        let toolListHandler = return [@@ -203,7 +203,7 @@        let toolCallHandler name args = case name of             "calculate" -> case Map.lookup "expression" args of-              Just (String expr) -> return $ Right $ toToolResult $ ContentText $ "Result: " <> expr <> " → √answer"+              Just (String expr) -> return $ Right $ Complete $ toToolResult $ ContentText $ "Result: " <> expr <> " → √answer"               _ -> return $ Left $ MissingRequiredParams "expression"             _ -> return $ Left $ UnknownTool name @@ -228,7 +228,7 @@       -- Test actual Unicode handling       promptResult <- snd (case prompts handlers of Just h -> h; Nothing -> error "No prompts") ctx "math_formula" (Map.fromList [("formula", "√(x²+y²)")])       case promptResult of-        Right (PromptResult _ [PromptMessage _ (ContentText txt)]) -> txt `shouldSatisfy` T.isInfixOf "√"+        Right (Complete (PromptResult _ [PromptMessage _ (ContentText txt)])) -> txt `shouldSatisfy` T.isInfixOf "√"         _ -> expectationFailure "Expected successful prompt result"        uri <- case parseURI "resource://unicode_symbols" of@@ -236,12 +236,12 @@         Nothing -> fail "Invalid URI"       resourceResult <- snd (case resources handlers of Just h -> h; Nothing -> error "No resources") ctx uri       case resourceResult of-        Right (ResourceText _ _ txt) -> txt `shouldSatisfy` T.isInfixOf "∀∃∈∉"+        Right (Complete (ResourceText _ _ txt)) -> txt `shouldSatisfy` T.isInfixOf "∀∃∈∉"         _ -> expectationFailure "Expected successful resource result"        callResult <- snd (case tools handlers of Just h -> h; Nothing -> error "No tools") ctx "calculate" (Map.fromList [("expression", String "∫₀^∞ e^(-x²) dx = √π/2")])       case callResult of-        Right tr | [ContentText txt] <- toolResultContent tr -> do+        Right (Complete tr) | [ContentText txt] <- toolResultContent tr -> do           txt `shouldSatisfy` T.isInfixOf "√"           txt `shouldSatisfy` T.isInfixOf "∫"         _ -> expectationFailure "Expected successful tool result"
test/TestHelpers.hs view
@@ -7,9 +7,12 @@   , valueArgs   , promptArgsMap     -- * Result expectations+  , completed   , shouldBeTextResult   , shouldBeTextResultContaining   , shouldBePromptText+    -- * Request state+  , testStateCodec     -- * Schema access   , schemaTypeName   , schemaProps@@ -18,6 +21,7 @@   ) where  import           Data.Aeson (Value (..))+import qualified Data.ByteString as BS import qualified Data.Aeson.KeyMap as KM import           Data.Map   (Map) import qualified Data.Map   as Map@@ -39,17 +43,24 @@ promptArgsMap :: [(Text, Text)] -> Map Text Text promptArgsMap = Map.fromList +-- | A handler result that should be complete, with an unexpected request+-- for input turned into an error so that a @case@ on it stays exhaustive.+completed :: Either Error (Outcome a) -> Either Error a+completed (Right (Complete a))        = Right a+completed (Right (InputRequired _ _)) = Left (InternalError "unexpected request for input")+completed (Left err)                  = Left err+ -- | Expect a successful, non-error tool result with exactly one text block.-shouldBeTextResult :: Either Error ToolResult -> Text -> Expectation-shouldBeTextResult (Right tr) expected+shouldBeTextResult :: Either Error (Outcome ToolResult) -> Text -> Expectation+shouldBeTextResult (Right (Complete tr)) expected   | not (toolResultIsError tr)   , [ContentText content] <- toolResultContent tr = content `shouldBe` expected shouldBeTextResult other expected =   expectationFailure $ "Expected text result '" ++ T.unpack expected ++ "' but got: " ++ show other  -- | Like 'shouldBeTextResult' but with substring matching.-shouldBeTextResultContaining :: Either Error ToolResult -> Text -> Expectation-shouldBeTextResultContaining (Right tr) expected+shouldBeTextResultContaining :: Either Error (Outcome ToolResult) -> Text -> Expectation+shouldBeTextResultContaining (Right (Complete tr)) expected   | not (toolResultIsError tr)   , [ContentText content] <- toolResultContent tr =       content `shouldSatisfy` T.isInfixOf expected@@ -57,11 +68,18 @@   expectationFailure $ "Expected text result containing '" ++ T.unpack expected ++ "' but got: " ++ show other  -- | Expect a prompt result of a single user message with the given text.-shouldBePromptText :: Either Error PromptResult -> Text -> Expectation-shouldBePromptText (Right (PromptResult _ [PromptMessage RoleUser (ContentText content)])) expected =+shouldBePromptText :: Either Error (Outcome PromptResult) -> Text -> Expectation+shouldBePromptText (Right (Complete (PromptResult _ [PromptMessage RoleUser (ContentText content)]))) expected =   content `shouldBe` expected shouldBePromptText other expected =   expectationFailure $ "Expected prompt text '" ++ T.unpack expected ++ "' but got: " ++ show other++-- | A request-state codec with a fixed key, so tests need no IO to build+-- one and can open what the server sealed.+testStateCodec :: StateCodec+testStateCodec = case stateKey "test" (BS.replicate 32 7) of+  Just key -> aeadStateCodec key []+  Nothing  -> error "testStateCodec: invalid key"  -- | The JSON Schema type keyword for a schema. schemaTypeName :: Schema -> Text
test/golden/README.md view
@@ -12,7 +12,9 @@ compared as **parsed JSON** (structural equality), not raw bytes, so object key order never matters — with one deliberate exception: JSON embedded *inside a string* (the structured-output text block) is compared as part of-the string, so those bytes are canonical: compact, object keys sorted.+the string, so those bytes are canonical: compact, object keys sorted. A+fixture string `"<opaque>"` matches any string: it stands for values a+server generates afresh, such as a sealed `requestState`.  The corpus is deliberately **API-agnostic**: nothing in it refers to this library's types or Haskell at all. Any MCP server implementation that@@ -55,6 +57,13 @@ | Completions | any ref/argument | values = `["alpha", "beta"]` filtered by prefix of the partial value | | Tool `echo_structured` | input schema: object, required `input` (string, "The text"); output schema: object, required `echoedText` (string, "The echoed text") and `echoedLength` (integer, "Its length"); description `Echo with structured output` | returns `structuredContent` `{"echoedText": <input>, "echoedLength": <length>}` plus one text content block containing the same JSON | | Tool `annotated_probe` | input schema: object, required `probe` (string, "What to probe"); description `A read-only probe`; title `Probe`; annotations `readOnlyHint`/`idempotentHint` true; one icon `https://example.com/probe.png` | returns one text block `probed: <probe>` annotated with audience `["user"]`, priority `0.5` |++The `mrtr` handler set is the `base` set with one tool, which asks for+input (multi round-trip requests, 2026-07-28):++| Feature | Definition | Behavior |+|---|---|---|+| Tool `confirm` | not listed | without an answer under `proceed`: an `input_required` result asking, under the key `proceed`, for an `elicitation/create` form (message `Proceed?`, schema: object, required boolean `ok`), with a request state; with one: one text block `confirmed` |  Cases with `"notifications": true` are answered as if the transport can deliver change notifications (stdio with a configured notifier: legacy push
+ test/golden/legacy/tools-call-input-required.request.json view
@@ -0,0 +1,1 @@+{"jsonrpc":"2.0","id":43,"method":"tools/call","params":{"name":"confirm","arguments":{}}}
+ test/golden/legacy/tools-call-input-required.response.json view
@@ -0,0 +1,1 @@+{"error":{"code":-32603,"message":"Internal error: the handler requested input, which the client's protocol revision does not support"},"id":43,"jsonrpc":"2.0"}
test/golden/manifest.json view
@@ -63,6 +63,11 @@       "notifications": false     },     {+      "name": "legacy/tools-call-input-required",+      "handlers": "mrtr",+      "notifications": false+    },+    {       "name": "legacy/tools-call-structured",       "handlers": "extended",       "notifications": false@@ -130,6 +135,21 @@     {       "name": "modern/tools-call-echo",       "handlers": "base",+      "notifications": false+    },+    {+      "name": "modern/tools-call-input-required",+      "handlers": "mrtr",+      "notifications": false+    },+    {+      "name": "modern/tools-call-input-unsupported",+      "handlers": "mrtr",+      "notifications": false+    },+    {+      "name": "modern/tools-call-invalid-request-state",+      "handlers": "mrtr",       "notifications": false     },     {
+ test/golden/modern/tools-call-input-required.request.json view
@@ -0,0 +1,1 @@+{"jsonrpc":"2.0","id":40,"method":"tools/call","params":{"name":"confirm","arguments":{},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"golden-client","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{"elicitation":{}}}}}
+ test/golden/modern/tools-call-input-required.response.json view
@@ -0,0 +1,1 @@+{"id":40,"jsonrpc":"2.0","result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"Golden Server","version":"1.0.0"}},"inputRequests":{"proceed":{"method":"elicitation/create","params":{"message":"Proceed?","mode":"form","requestedSchema":{"properties":{"ok":{"type":"boolean"}},"required":["ok"],"type":"object"}}}},"requestState":"<opaque>","resultType":"input_required"}}
+ test/golden/modern/tools-call-input-unsupported.request.json view
@@ -0,0 +1,1 @@+{"jsonrpc":"2.0","id":42,"method":"tools/call","params":{"name":"confirm","arguments":{},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"golden-client","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}
+ test/golden/modern/tools-call-input-unsupported.response.json view
@@ -0,0 +1,1 @@+{"error":{"code":-32603,"message":"Internal error: the handler requested input the client cannot provide (proceed)"},"id":42,"jsonrpc":"2.0"}
+ test/golden/modern/tools-call-invalid-request-state.request.json view
@@ -0,0 +1,1 @@+{"jsonrpc":"2.0","id":41,"method":"tools/call","params":{"name":"confirm","arguments":{},"inputResponses":{"proceed":{"action":"accept","content":{"ok":true}}},"requestState":"test.not-a-sealed-state","_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"golden-client","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{"elicitation":{}}}}}
+ test/golden/modern/tools-call-invalid-request-state.response.json view
@@ -0,0 +1,1 @@+{"error":{"code":-32602,"message":"Invalid requestState"},"id":41,"jsonrpc":"2.0"}