BSD-3-Clause licensed by Tom Wells
Maintained by [email protected]
This version can be pinned in stack with:mcp-server-0.3.0.1@sha256:e2e87c85b92371323df478b6232b6b90643f49c255a8b74932467f04c2de42b7,7674

mcp-server

Hackage CI

Build Model Context Protocol servers in Haskell from plain data types. Declare your tools, prompts and resources as ADTs, write one handler per type, and the library derives the JSON schemas, argument decoding, validation and wire protocol for you — then serves it over stdio or Streamable HTTP to Claude Code, Codex, Claude Desktop, Cursor and any other MCP client.

{-# LANGUAGE OverloadedStrings, TemplateHaskell #-}

import Data.Text (Text)
import MCP.Server
import MCP.Server.Derive

data Units = Celsius | Fahrenheit

data WeatherTool
    = CurrentWeather { city :: Text, units :: Maybe Units }
    | Forecast       { city :: Text, days :: Int }

handleTool :: ClientContext -> WeatherTool -> IO Content
handleTool _ (CurrentWeather c _) = pure $ ContentText $ "Sunny in " <> c
handleTool _ (Forecast c n)       = pure $ ContentText $ "Forecast for " <> c

$(pure [])  -- end the declaration group so the splice below can see the types

main :: IO ()
main = runMcpServerStdio serverInfo noHandlers
    { tools = Just $(deriveToolHandler ''WeatherTool 'handleTool) }
  where
    serverInfo = McpServerInfo
      { serverName = "weather", serverVersion = "1.0.0"
      , serverInstructions = "Weather lookups" }

That is a complete, working MCP server exposing two tools, current_weather and forecast, each with a JSON schema derived from its constructor’s fields.

What you get for free

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:

data ShippingSpeed = Standard | Express | Overnight

data Address = Address
    { street  :: Text
    , city    :: Text
    , zipCode :: Maybe Text
    }

data MyTool
    = Checkout { speed :: ShippingSpeed, shipTo :: Address }
    | ...

tools/list returns exactly this (captured from the running example; a field without a description is described by its name):

{
  "name": "checkout",
  "description": "Checkout",
  "annotations": { "destructiveHint": true },
  "inputSchema": {
    "type": "object",
    "required": ["speed", "shipTo"],
    "properties": {
      "speed":  { "type": "string", "enum": ["standard", "express", "overnight"],
                  "description": "speed" },
      "shipTo": {
        "type": "object",
        "description": "shipTo",
        "required": ["street", "city"],
        "properties": {
          "street":  { "type": "string", "description": "street" },
          "city":    { "type": "string", "description": "city" },
          "zipCode": { "type": "string", "description": "zipCode" }
        }
      }
    }
  }
}

and a tools/call with matching arguments arrives in your handler as a fully decoded Checkout Express (Address "1 Main St" "Springfield" Nothing). Malformed arguments never reach you; the library answers with the appropriate JSON-RPC error. The same machinery works in reverse for typed results (see Structured output).

Beyond the derivation, the library handles:

  • Both protocol eras. Legacy revisions 2024-11-05 through 2025-11-25 negotiated via initialize, and the stateless 2026-07-28 revision declared per request in _meta, from one server binary.
  • Two transports. stdio, and Streamable HTTP with bearer auth, Origin validation, per-request SSE and a plain WAI application you can embed.
  • Long-running tools. Progress notifications, per-request client logging, and cancellation of in-flight requests on both transports.
  • Live servers. listChanged and resource-update pushes over subscriptions/listen.
  • Conformance fixtures. A language-agnostic corpus of request/response pairs the test suite replays against every protocol era.

Installation

Add mcp-server to your build-depends:

build-depends:
  base, text, mcp-server

Tested against GHC 9.6 through 9.14 in CI. The HTTP transport requires ghc-options: -threaded (Warp needs the threaded runtime).

Connecting to a client

Build your server, then register it with your client of choice. The examples below use the simple-example executable from this repository; substitute your own.

Claude Code

# stdio: everything after -- is the command Claude Code spawns
claude mcp add my-server -- "$(cabal list-bin exe:simple-example)"

# pass environment variables with --env
claude mcp add my-server --env API_KEY=secret -- /path/to/my-server

# Streamable HTTP
claude mcp add --transport http my-server http://localhost:3000/mcp

Verify with claude mcp list or /mcp inside a session. Add --scope project to write a .mcp.json you can commit for your team:

{
  "mcpServers": {
    "my-server": {
      "type": "stdio",
      "command": "/path/to/my-server",
      "env": { "API_KEY": "${API_KEY}" }
    }
  }
}

Cursor and several other clients read the same mcpServers shape.

Codex

codex mcp add my-server --env API_KEY=secret -- /path/to/my-server

Or in ~/.codex/config.toml, which is also where HTTP servers go:

[mcp_servers.my-server]
command = "/path/to/my-server"

[mcp_servers.my-http-server]
url = "http://localhost:3000/mcp"

Claude Desktop

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 builds all the examples:

docker build -t haskell-mcp-server .
{
  "mcpServers": {
    "simple-example": {
      "command": "docker",
      "args": ["run", "-i", "--entrypoint=/usr/local/bin/simple-example", "haskell-mcp-server"]
    }
  }
}

Keep stdout clean

On stdio, stdout carries only JSON-RPC. The library writes nothing else there, and your handlers must not either: log to stderr.

Defining tools

Naming and arguments

Constructor names become snake_case tool names; record fields become named arguments. The generated inputSchema mirrors the field types:

data Color = Red | Green | Blue          -- all-nullary type: string enum
data Filters = Filters                   -- record: nested JSON object
  { tags     :: [Text]                   -- list: JSON array
  , maxCount :: Maybe Int                -- Maybe: optional field
  }

data MyTool
    = SearchItems                        -- "search_items"
      { query   :: Text
      , color   :: Color                 -- "red" | "green" | "blue"
      , filters :: Filters               -- { "tags": [...], "maxCount": ... }
      , limit   :: Maybe Int
      }

Primitive fields (Int, Integer, Double, Float, Bool, Text) are parsed leniently: 42 and "42" are both accepted, since many clients send numbers and booleans as strings.

A constructor may also wrap a single record type, which is unwrapped recursively until a record is found:

data SetValueParams = SetValueParams { setKey :: Text, setValue :: Text }

data SimpleTool
    = GetValue { key :: Text }
    | SetValue SetValueParams              -- fields of SetValueParams are the arguments

Positional (unnamed) fields are not supported, because they have no names to put in the schema:

data SimpleTool = GetValue Int | SetValue Int Text   -- ❌ rejected

Results and errors

Simple handlers return Content (or Text). Return a ToolResult for multiple content blocks or to report an execution failure with isError, which the spec prefers over a protocol error so the model can see what went wrong and react:

handleTool :: ClientContext -> MyTool -> IO ToolResult
handleTool _ (SearchItems q _ _ _)
  | T.null q  = pure $ toolError "query must not be empty"
  | otherwise = pure $ toolResult [ContentText ("Results for " <> q)]

Content blocks can carry annotations (audience, priority, lastModified) via the ContentAnnotated wrapper:

ContentAnnotated defaultAnnotations { annotationsPriority = Just 0.9 }
                 (ContentText "important result")

Structured output

Give the derivation a result type and it derives the tool’s outputSchema (same field rules as inputs) and serializes your value into structuredContent, guaranteed to match. Per the spec’s recommendation the JSON is also returned as a text block for clients that predate structured output:

data WeatherReport = WeatherReport
    { temperature :: Int
    , sky         :: Sky          -- enum
    , alerts      :: [Text]
    , humidity    :: Maybe Int    -- omitted when Nothing
    }

handleTool :: ClientContext -> MyTool -> IO (ToolOutput WeatherReport)
handleTool _ (GetWeather city) = pure $ ToolOutput (lookupWeather city)
handleTool _ (BrokenTool _)    = pure $ ToolOutputError "sensor offline"

tools = Just $(deriveToolHandlerWithOutput ''MyTool 'handleTool ''WeatherReport)

ToolOutputWith supplies custom content blocks alongside the structured value; ToolOutputRaw is the escape hatch back to a plain ToolResult.

Descriptions, annotations and icons

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 read-only tools) and argument descriptions scoped to that constructor.

descriptions =
  [ ("SearchItems", "Search the catalog")     -- constructor
  , ("query",       "Search terms")           -- field
  ]
tools = Just $(deriveToolHandlerWithDescription ''MyTool 'handleTool descriptions)
tools = Just $(deriveToolHandlerWithOptions ''MyTool 'handleTool
  [ ("SearchItems", defaultDefinitionOptions
      { optDescription = Just "Search the catalog"
      , optToolAnnotations = Just defaultToolAnnotations
          { toolReadOnlyHint = Just True, toolIdempotentHint = Just True }
      , optIcons = [icon "https://example.com/search.png"]
      , optFieldDescriptions = [("query", "Search terms")]
      })
  ])

Defining prompts

Prompts derive the same way. Arguments are string-valued per the spec, so prompt records are limited to primitive and enumeration fields:

data MyPrompt = Recipe { idea :: Text } | Shopping { items :: Text }

handlePrompt :: ClientContext -> MyPrompt -> IO Content
handlePrompt _ (Recipe idea)    = pure $ ContentText $ "Recipe for " <> idea
handlePrompt _ (Shopping items) = pure $ ContentText $ "Shopping list: " <> items

prompts = Just $(derivePromptHandler ''MyPrompt 'handlePrompt)

Return a PromptResult instead of Content for a description and a multi-message conversation with user and assistant roles.

Defining resources

Nullary constructors become static resources with resource:// URIs; record constructors become resource templates (RFC 6570), one percent-decoded path segment per field:

data MyResource
    = Menu                                            -- resource://menu
    | ProductDetail { sku :: Text }                   -- resource://product_detail/{sku}
    | OrderItem { orderId :: Int, itemName :: Text }  -- resource://order_item/{orderId}/{itemName}

handleResource :: ClientContext -> URI -> MyResource -> IO ResourceContent
handleResource _ uri Menu                = pure $ ResourceText uri "text/plain" "Today's menu..."
handleResource _ uri (ProductDetail sku) = pure $ ResourceText uri "text/plain" ("Details for " <> sku)
handleResource _ uri (OrderItem o i)     = ...

resources         = Just $(deriveResourceHandler ''MyResource 'handleResource)
resourceTemplates = Just $(deriveResourceTemplates ''MyResource)

The read handler matches template URIs such as resource://product_detail/ABC123 and decodes the segments into the constructor’s fields; typed fields like Int are parsed, and a failing segment yields an invalid-params error.

Argument completion

Provide a completions handler to serve completion/complete for prompt arguments and resource-template parameters. The capability is advertised automatically when the handler is present:

handleComplete :: ClientContext -> CompletionRef -> ArgumentName -> Text -> Map Text Text
               -> IO (Either Error CompletionResult)
handleComplete _ (CompletionRefPrompt "recipe") "idea" partial _ =
    pure $ Right $ completionResult $
        filter (T.isPrefixOf partial) ["pancakes", "pasta", "pizza"]
handleComplete _ _ _ _ _ = pure $ Right $ completionResult []

handlers = noHandlers { completions = Just handleComplete, ... }

Assembling the server

Start from noHandlers and record-update the features you provide. Constructing McpServerHandlers directly is discouraged: the library grows new handler slots over time, and a missed field fails at runtime rather than compile time.

handlers = noHandlers
  { prompts           = Just $(derivePromptHandler ''MyPrompt 'handlePrompt)
  , resources         = Just $(deriveResourceHandler ''MyResource 'handleResource)
  , resourceTemplates = Just $(deriveResourceTemplates ''MyResource)
  , tools             = Just $(deriveToolHandler ''MyTool 'handleTool)
  , completions       = Just handleComplete
  }

Two Template Haskell details to know:

  • A derive* splice can only see types declared in an earlier declaration group. Either put the types in their own module (as the 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 client identity for modern clients, and the reportProgress and logToClient actions described below.

Manual handlers

The derived handlers are ordinary values, so for full control you can supply your own instead. Prompt arguments arrive as Map Text Text, tool arguments as Map Text Value:

promptListHandler :: ClientContext -> IO [PromptDefinition]
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).

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:

import Data.Aeson (object, (.=))
import qualified Data.Aeson.KeyMap as KeyMap
import Data.Text (Text)

nullableCount :: Schema
nullableCount = schema $ SchemaRaw $ KeyMap.fromList
    [ "anyOf" .= [object ["type" .= ("null" :: Text)], object ["type" .= ("integer" :: Text), "minimum" .= (0 :: Int)]] ]

A $ref such as #/$defs/node resolves against the root of the whole input schema, not the raw fragment it appears in, so a recursive schema is best written raw from the root down.

Transports

stdio

runMcpServerStdio serverInfo handlers serves JSON-RPC over stdin and stdout. runMcpServerStdioWithConfig takes a StdioConfig for verbose request logging on stderr, cacheability hints for modern clients, and a change-notification source.

Streamable HTTP

import MCP.Server

main = runMcpServerHttp serverInfo handlers            -- localhost:3000/mcp

main = runMcpServerHttpWithConfig defaultHttpConfig
    { httpPort = 8080
    , httpHost = "0.0.0.0"
    , httpEndpoint = "/api/mcp"
    , httpVerbose = True                                -- request/response logging on stderr
    , httpAllowedOrigins = Just ["https://app.example.com"]
    } serverInfo handlers

httpAllowedOrigins is DNS-rebinding protection: requests carrying an Origin outside the list get 403. Nothing disables the check and is only appropriate for servers unreachable from browsers.

Bearer-token authentication is a callback. Return Just principal (any JSON Value, such as a role) to admit the request, or Nothing for 401. The principal reaches handlers as clientPrincipal in the ClientContext; token policy lives entirely in your application.

defaultHttpConfig
  { httpAuthorize = Just $ \mtoken -> case mtoken of
      Just "secret-admin-token" -> pure $ Just (String "admin")
      Just "secret-user-token"  -> pure $ Just (String "user")
      _                         -> pure Nothing
  }

The endpoint accepts POST only. Server-to-client notifications flow over the subscriptions/listen POST response stream rather than the deprecated standalone GET stream, and CORS is enabled for web clients.

Embedding in an existing WAI stack

The MCP endpoint is a plain WAI application, exported as mcpApplication, so it can be mounted inside your own Warp settings, TLS, middleware or router:

import MCP.Server (mcpApplication, defaultHttpConfig)
import qualified Network.Wai.Handler.Warp as Warp

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

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

Progress and logging

Handlers report progress and send log messages to the calling client through actions on the ClientContext. Both are safe to call unconditionally:

handleTool ctx (ImportData file) = do
    reportProgress ctx 0.0 (Just 1.0) (Just "starting import")
    logToClient ctx LogInfo (String "opening file")
    ...
    reportProgress ctx 1.0 (Just 1.0) Nothing
  • reportProgress emits notifications/progress only when the request carried a progressToken. Progress values must increase call over call.
  • logToClient emits notifications/message only when the request declared io.modelcontextprotocol/logLevel, as the spec requires, and drops messages below the declared level.

On stdio the notifications interleave before the response. On HTTP, a request that opted in is answered with an SSE stream carrying the notifications followed by the final response; other requests keep the single-JSON response.

Cancellation

In-flight requests can be cancelled, after which the server stops work as soon as practical and sends nothing further for that request:

  • stdio: each request runs in its own task, and a notifications/cancelled naming its id cancels that task. Unknown or completed ids are ignored.
  • HTTP: closing the response stream is the cancellation signal. SSE responses detect the disconnect within one keep-alive interval. Single-JSON responses only detect it at the final write, so clients wanting cancellable calls should opt into streaming via a progressToken.

Cancellation is delivered as an asynchronous exception, the standard GHC mechanism used by timeout and cancel. Handlers are interruptible wherever they block in IO, and one that acquires resources should release them with bracket or finally:

handleTool ctx (ImportData file) =
    bracket (openFile file ReadMode) hClose $ \h -> do
        ...

Critical sections can be shielded with mask, but keep them short: cancellation waits for them.

Concurrency

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:

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:

$(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:

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:

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 change notifications. Create a notifier, hand its source to the transport, and call the notifier when things change:

main = do
    (notifier, source) <- newMcpNotifier
    _ <- forkIO $ appLogic notifier   -- calls notifyToolsListChanged etc.
    runMcpServerStdioWithConfig
        defaultStdioConfig { stdioNotifications = Just source }
        serverInfo handlers

Delivery is transport- and era-aware, and the listChanged and subscribe capabilities are advertised only where delivery is possible:

  • Modern clients (2026-07-28) open a subscriptions/listen stream (a long-lived SSE response over HTTP) and receive only the notification types they opted into, tagged with their subscription id, including notifications/resources/updated for watched URIs.
  • Legacy stdio clients receive spontaneous untagged notifications once their notifications/initialized arrives.
  • Legacy HTTP clients have no delivery channel, so nothing is advertised.

Protocol support

Feature Legacy (2024-11-05 to 2025-11-25) Modern (2026-07-28)
Version selection initialize handshake per-request _meta, server/discover
Prompts, resources, resource templates, tools ✅ ✅
Argument completion ✅ ✅
Tool annotations, icons, structured output ✅ ✅
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 (OAuth resource metadata, pagination, the tasks extension) live as ADRs under specs/, ordered by specs/ROADMAP.md.

Examples

  • examples/Simple/: a key-value store with two tools over stdio.
  • examples/Complete/: prompts, resources, a resource template, tools with enum, nested and list arguments, isError, annotations, progress and completions.
  • 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/: the key-value store over Streamable HTTP.
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/ 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 that reproduces the small reference server described there can replay the requests and diff the responses. Contributions of new cases are welcome.

Documentation

Contributing

Contributions are welcome. See the issue tracker for open issues and feature requests, and RELEASING.md for how versions reach Hackage.

AI assistance

Much of this library was written with Claude, working from a specification I wrote and iterating together until I was happy with the result. I review and maintain all of it, but parts such as the Template Haskell derivation sit outside what I would have written unaided, and I expect to keep refactoring them.

License

BSD-3-Clause

Changes

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 that serializes its keywords as given, for schemas the other shapes cannot express, such as null, anyOf, $defs/$ref or numeric bounds. A schemaDescription alongside it replaces any description among the keywords. BREAKING: exhaustive matches on SchemaType need a case for it.

0.2.0.2 - 2026-09-08

  • stdio: in-flight requests are drained at stdin EOF instead of being cancelled. Since 0.2.0.1 made stdio requests concurrent, a client that wrote its requests and closed stdin straight away (scripts, echo ... | server, conformance replays) lost the responses to whatever was still running when EOF arrived — most visibly the last request in the batch. Per the lifecycle spec the client closes stdin and then waits for the server to exit, so the server now finishes outstanding work and writes those responses before shutting down. Subscription streams are still closed at EOF as before, and notifications/cancelled still interrupts a running request.
  • A handler that throws an exception (rather than returning an error value such as toolError) now yields a -32603 internal-error response for its request id, on both transports and in both protocol eras. Previously the exception escaped the transport: on stdio the request’s task died silently and the client waited forever for that id; on HTTP Warp answered a bare text/plain 500 (single-JSON responses) or dropped the connection with an empty body (SSE responses). The exception’s first line is the error message; the full rendering (including any call stack) is logged to stderr. Asynchronous exceptions are rethrown untouched, so cancellation is unaffected.

0.2.0.1 - 2026-08-01

(Supersedes 0.2.0.0, which is deprecated on Hackage: it was published hours before this line landed and was never adopted, so rather than burning a major version on a release nobody used, 0.2.0.1 replaces it — including changes that would ordinarily demand a major bump. Anyone explicitly pinning the deprecated 0.2.0.0 should move here. The unreleased 0.2.1.0 line below is folded in as well.)

  • Request cancellation (ADR 0008): in-flight requests can now actually be interrupted, per the spec’s “stop work as soon as practical, send nothing further for that request”. On stdio every request runs in its own task and notifications/cancelled cancels the referenced one (unknown or completed ids are ignored); on HTTP, closing an SSE response stream cancels the running handler (detected within one keep-alive interval, now 5s). Single-JSON HTTP responses only detect a disconnect at the final write, so clients wanting cancellable calls should opt into streaming via a progressToken. Cancellation is delivered as an asynchronous exception, so handlers acquiring resources should use bracket — documented in the README. BREAKING (behavioral): stdio requests are now served concurrently rather than strictly sequentially — handlers touching shared mutable state must synchronize, as was already required with the HTTP transport. New dependency: async.

  • Progress notifications and per-request SSE (ADR 0007): handlers can call reportProgress and logToClient on the ClientContext — both safe unconditionally. reportProgress emits notifications/progress only when the request carried a progressToken; logToClient emits notifications/message only when the request declared io.modelcontextprotocol/logLevel (per spec MUST NOT otherwise), filtered to the declared threshold (new LogLevel type, RFC 5424 ordering). On stdio the notifications interleave before the response; on HTTP a request that opted in is answered with an SSE response stream (notifications, then the final response), while other requests keep the single-JSON response. BREAKING: ClientContext carries the two actions and loses its Show/Eq instances; MCP.Server.Handlers.handleMcpMessage takes the transport’s notification sink.

  • Definition metadata (ADR 0006):

    • ToolAnnotations — readOnlyHint/destructiveHint/idempotentHint/ openWorldHint behavioral hints (2025-03-26+) plus a title, all unset by default (defaultToolAnnotations), carried on ToolDefinition and driving client permission UX.
    • Icon lists (2025-11-25+) on tool, prompt, resource and resource-template definitions.
    • Content Annotations (audience/priority/lastModified, 2025-03-26+) attached via the new ContentAnnotated wrapper, whose annotations merge into the inner block’s JSON (and parse back out).
  • New WithOptions derivations for all five derive families, taking per-constructor DefinitionOptions (description, title, icons, tool annotations, and constructor-scoped field descriptions — two constructors can now describe a same-named field differently, fixing the global-namespace wart of the flat description list, which remains supported unchanged).

  • BREAKING: ToolDefinition, PromptDefinition, ResourceDefinition and ResourceTemplateDefinition gain fields, and Content gains the ContentAnnotated constructor. New smart constructors (mkToolDefinition, mkPromptDefinition, mkResourceDefinition, mkResourceTemplateDefinition) build definitions from required fields only — construct through them and record-update, so future optional fields stop breaking your code. All new JSON fields are omitted when unset, so wire output for existing servers is unchanged.

  • Derived output schemas and structured content (ADR 0005): the new deriveToolHandlerWithOutput (and ...WithOutputDescription) take a result record type, derive the tools’ outputSchema from it (same field rules as input derivation: primitives, Maybe, lists, all-nullary enums, nested records), and serialize the handler’s typed values into structuredContent — the generated serializer mirrors the generated schema, so the two cannot drift. Handlers return the new ToolOutput type: ToolOutput (structured value; the JSON is also returned as a text content block per the spec’s recommendation), ToolOutputWith (custom content blocks), ToolOutputError (isError), or ToolOutputRaw (plain ToolResult escape hatch). Existing ToToolResult handlers are untouched. The conformance corpus gains an echo_structured reference tool with cases in both eras.

  • The HTTP transport’s WAI application is now exported (mcpApplication, re-exported from MCP.Server), so the MCP endpoint can be embedded into an existing WAI stack — your own Warp settings, TLS, middleware or router — instead of transportRunHttp running its own server. httpPort/ httpHost are ignored when embedding; everything else (endpoint path, Origin validation, bearer auth, subscriptions/listen streaming) applies as usual.

  • The golden wire fixtures are now a self-describing, API-agnostic conformance corpus: each case under test/golden/ is a .request.json/.response.json pair on disk, enumerated by manifest.json, with the reference server documented in test/golden/README.md. Other MCP implementations can replay the requests and diff the responses without touching any Haskell; the fixtures themselves are unchanged.

0.2.0.0 - 2026-07-31

A major overhaul of the handler API. The headline changes: the handler boundary is no longer stringly typed, and the server is dual-era — it speaks both the legacy initialize-handshake revisions and the stateless 2026-07-28 revision.

Dual-era protocol support (2026-07-28)

  • Requests that declare a protocol revision in their params _meta (io.modelcontextprotocol/protocolVersion) are served statelessly with the modern result envelope: resultType: "complete", the server identity in result _meta, and — on tools/list, prompts/list, resources/list, resources/read and server/discover — the required ttlMs/cacheScope fields (configurable via CacheHints on the transport configs; default: no caching, private). Requests without modern _meta are served byte-identically to before under the revision negotiated by initialize.
  • New server/discover method (mandatory in 2026-07-28, and the backwards-compatibility probe): supported revisions of both eras, handler-gated capabilities, server identity and instructions.
  • Declaring an unsupported revision returns UnsupportedProtocolVersionError (-32022) listing the supported set.
  • A legacy client proposing 2026-07-28 via initialize negotiates down to 2025-11-25: an initializing client is legacy by definition.
  • Handlers can read the declared revision, client info and client capabilities from the ClientContext (clientProtocolVersion/clientInfo/clientCapabilities); the new anonymousContext builds an empty context.
  • HTTP: modern requests get the 2026-07-28 request-metadata validation — the MCP-Protocol-Version header must match the body’s declared revision, Mcp-Method must match the body method, and Mcp-Name must match params.name/params.uri for tools/call/resources/read/ prompts/get (with =?base64?…?= sentinel decoding); violations return 400 with HeaderMismatch (-32020). Unknown methods return HTTP 404 and unsupported revisions HTTP 400, so era-probing clients can distinguish them. Legacy requests keep the relaxed pre-2026 rules.

Change notifications and subscriptions/listen

  • New MCP.Server.Notifications: create an McpNotifier with newMcpNotifier, hand its NotificationSource to a transport (stdioNotifications/httpNotifications), and call notifyToolsListChanged/notifyPromptsListChanged/ notifyResourcesListChanged/notifyResourceUpdated when things change.
  • subscriptions/listen (2026-07-28) is served on both transports: the mandatory acknowledgment comes first with the honored filter, every message is tagged with the subscription id, only opted-into types are delivered, and streams end gracefully (closure responses at stdio EOF; closing the SSE stream cancels over HTTP, notifications/cancelled over stdio). HTTP streams send periodic keep-alive comments and X-Accel-Buffering: no.
  • Legacy stdio clients receive spontaneous untagged notifications after initialize. Capabilities are era- and transport-aware: listChanged is advertised only where delivery is possible (stdio legacy push, or modern subscriptions/listen), and subscribe only to modern clients.
  • defaultHttpConfig is now re-exported from MCP.Server.

Resource templates and completions

  • Record constructors of a resource type now derive as resource /templates/ (UserProfile { userId :: Text } → resource://user_profile/{userId}): the derived read handler matches template URIs, percent-decodes the path segments, and parses them into the constructor’s (typed) fields. deriveResourceTemplates derives the resources/templates/list handler advertising them; the method carries the modern cacheability envelope.
  • New completions handler slot serving completion/complete for prompt arguments and resource-template parameters (CompletionRef, CompletionResult, capped at 100 values per the spec). The completions capability is advertised automatically.
  • McpServerHandlers gains resourceTemplates and completions fields; the new noHandlers value lets you construct handler sets by record update so future fields don’t break your code.

Typed tool arguments and results (BREAKING)

  • Tool arguments arrive as full JSON values (Map Text Value). The Template Haskell derivation decodes records recursively and now supports list fields, enumeration fields (all-nullary data types, wired as string enums), and nested record fields in addition to the primitives. Primitive parsing is lenient: native JSON types or their string representations are both accepted (many clients send numbers/booleans as strings). Prompt arguments remain string-valued per the MCP specification.
  • inputSchema is generated as a real JSON Schema (Schema/SchemaType ADT with enum, items and nested objects), replacing the flat InputSchemaDefinition* types that silently typed every non-primitive field as a string.
  • Tool handlers produce a ToolResult: multiple content blocks, structuredContent, _meta, and isError. Tool execution failures should be reported via isError (see toolError) so the model can see them — per spec — instead of surfacing as JSON-RPC protocol errors. The ToToolResult class keeps simple handlers simple: returning Content or Text still works unchanged.
  • Prompt handlers produce a PromptResult (optional description plus a multi-message conversation with user/assistant roles) via the analogous ToPromptResult class.
  • Content gains audio and resource_link variants; embedded resources now carry their full contents as the spec requires.
  • ToolDefinition gains outputSchema; tools/call responses carry structuredContent.
  • Handler types are fixed to IO — the monad parameter was unusable through the public API (both transports required IO).

Transport fixes

  • stdio: a blank line on stdin no longer terminates the server, EOF shuts down cleanly instead of crashing, and malformed input is answered with proper JSON-RPC error responses (-32700/-32600, id: null).
  • stdio: raw request bodies are no longer logged to stderr by default (tool arguments may carry sensitive data) — only message summaries. runMcpServerStdioWithConfig with stdioVerbose = True restores full body logging.
  • JSON-RPC: messages are classified by shape (method/id presence) instead of parse-fallthrough, so a request with a malformed id is answered with an error rather than silently dropped as a notification. Request ids must be integral.
  • HTTP: new httpAllowedOrigins policy on HttpConfig (Origin validation / DNS-rebinding protection, a spec MUST); accepted notifications return 202 with no body; malformed bodies get JSON-RPC error responses; the Access-Control-Allow-Origin header is set consistently on every response and echoes the validated origin (with Vary: Origin) when a policy is configured.
  • HTTP (BREAKING): the non-spec GET “discovery” endpoint is removed — the MCP endpoint now answers GET with 405 Method Not Allowed, matching the spec (no revision defines a GET discovery response, and 2026-07-28 requires 405 here).
  • Integer tool arguments bound the scientific-notation exponent (1024, the same bound aeson uses) so a tiny payload like 1e1000000000 cannot force allocation of a gigabyte-sized Integer.

0.1.0.21 - 2026-07-31

  • BREAKING: every handler (prompt/resource/tool; list and get/read/call) now receives a ClientContext as its first argument, so a server can behave differently depending on who is calling. On stdio the context is anonymous; on HTTP it carries the request’s bearer token and the principal returned by the authorization callback.
  • BREAKING: HttpConfig gains an httpAuthorize field — an optional callback that validates the presented Authorization: Bearer token and returns an application-defined principal (Nothing rejects with 401). As it now holds a function, HttpConfig no longer derives Show/Eq.
  • HTTP transport: accept requests without an MCP-Protocol-Version header (the spec says to assume 2025-03-26), exempt initialize from the header check (it negotiates its version in the body), and keep rejecting a present but unsupported header with 400. Previously every request without the header was rejected, locking out pre-2025-06-18 clients.
  • initialize now advertises only the capabilities that actually have handlers, so strict clients no longer drop the server when e.g. prompts/list answers “not supported”.
  • CORS: preflight OPTIONS requests are exempt from authorization (browsers send no credentials on preflight) and Authorization is included in Access-Control-Allow-Headers.
  • http-simple-example is now built with -threaded, which Warp requires; previously every request crashed with a TimerManager error.

0.1.0.20 - 2026-07-31

  • Fix protocol version negotiation: echo back any compatible revision the client proposes (2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25) instead of always responding with the server’s own version. Fixes clients (e.g. Claude Code) that disconnect when they receive a different version than requested.
  • Apply the same negotiation to the HTTP transport’s MCP-Protocol-Version header check, which previously rejected anything other than 2025-06-18.
  • Default/fallback advertised version bumped to 2025-11-25.

0.1.0.19 - ???

  • Improve handler code generated by TemplateHaskell functions in MCP.Server.Derive:
    • Don’t repeat Map.fromList for each argument in map lookup
    • Properly handle argument parse errors (Return InvalidParams error instead of crashing mcp server with error)

0.1.0.18 - 2026-02-09

  • Switch default-language to GHC2021 to support broader range of GHC versions (9.6 - 9.12)

0.1.0.17 – 2026-01-28

  • Implement protocol version negotiation according to spec
  • Remove unused dependencies, fix GHC warnings
  • Add tested-with and haskell-ci generated GitHub Actions config

0.1.0.16 – 2026-01-19

  • Bump template-haskell dependency upper bound

0.1.0.15 – 2025-08-13

  • Update to MCP spec 2025-06-18

0.1.0.14 – 2025-06-26

  • Bump version bounds before adding to Stackage
  • Remove support for JSON-RPC batching

0.1.0.13 – 2025-06-17

  • Better handling of UTF-8 in logs

0.1.0.12 – 2025-06-17

  • Fix unicode handling
  • Refactor transports to remove unneeded functions
  • Add unicode handling tests

0.1.0.11 – 2025-06-17

  • Refactor transports and add HTTP streaming support
  • Add MCP.Server.Handlers module
  • Add MCP.Server.Transport.Http and MCP.Server.Transport.Stdio modules

0.1.0.10 – 2025-06-13

  • Fix resources handling

0.1.0.9 – 2025-06-13

  • Bump versions of dependencies
  • Port tests to hspec

0.1.0.8 – 2025-06-12

  • Support for nestable data types

0.1.0.7 – 2025-06-09

  • Documentation updates

0.1.0.6 – 2025-06-09

  • Remove pagination support

0.1.0.5 – 2025-06-09

  • Add descriptions to constructors and fields

0.1.0.4 – 2025-06-09

  • Clean up build configuration

0.1.0.3 – 2025-06-09

  • Refactor example modules
  • Fix JSON to Haskell type conversion

0.1.0.0 – 2025-06-05

  • First version. Released on an unsuspecting world.