mcp-server 0.2.0.0 → 0.2.0.1
raw patch · 106 files changed
+2085/−263 lines, 106 filesdep +asyncPVP: major bump suggested
API removals or changes: PVP suggests a major version bump
Dependencies added: async
API changes (from Hackage documentation)
- MCP.Server.Types: instance GHC.Classes.Eq MCP.Server.Types.ClientContext
- MCP.Server.Types: instance GHC.Internal.Show.Show MCP.Server.Types.ClientContext
+ MCP.Server: mcpApplication :: HttpConfig -> McpServerInfo -> McpServerHandlers -> Application
+ MCP.Server.Derive: DefinitionOptions :: Maybe Text -> Maybe Text -> [Icon] -> Maybe ToolAnnotations -> [(Text, Text)] -> DefinitionOptions
+ MCP.Server.Derive: [optDescription] :: DefinitionOptions -> Maybe Text
+ MCP.Server.Derive: [optFieldDescriptions] :: DefinitionOptions -> [(Text, Text)]
+ MCP.Server.Derive: [optIcons] :: DefinitionOptions -> [Icon]
+ MCP.Server.Derive: [optTitle] :: DefinitionOptions -> Maybe Text
+ MCP.Server.Derive: [optToolAnnotations] :: DefinitionOptions -> Maybe ToolAnnotations
+ MCP.Server.Derive: data DefinitionOptions
+ MCP.Server.Derive: defaultDefinitionOptions :: DefinitionOptions
+ MCP.Server.Derive: derivePromptHandlerWithOptions :: Name -> Name -> [(String, DefinitionOptions)] -> Q Exp
+ MCP.Server.Derive: deriveResourceHandlerWithOptions :: Name -> Name -> [(String, DefinitionOptions)] -> Q Exp
+ MCP.Server.Derive: deriveResourceTemplatesWithOptions :: Name -> [(String, DefinitionOptions)] -> Q Exp
+ MCP.Server.Derive: deriveToolHandlerWithOptions :: Name -> Name -> [(String, DefinitionOptions)] -> Q Exp
+ MCP.Server.Derive: deriveToolHandlerWithOutput :: Name -> Name -> Name -> Q Exp
+ MCP.Server.Derive: deriveToolHandlerWithOutputDescription :: Name -> Name -> Name -> [(String, String)] -> Q Exp
+ MCP.Server.Derive: deriveToolHandlerWithOutputOptions :: Name -> Name -> Name -> [(String, DefinitionOptions)] -> Q Exp
+ MCP.Server.Derive: instance GHC.Classes.Eq MCP.Server.Derive.DefinitionOptions
+ MCP.Server.Derive: instance GHC.Internal.Show.Show MCP.Server.Derive.DefinitionOptions
+ MCP.Server.Derive: instance Language.Haskell.TH.Syntax.Lift MCP.Server.Derive.DefinitionOptions
+ MCP.Server.Transport.Http: [peekHasId] :: BodyPeek -> Bool
+ MCP.Server.Transport.Http: [peekWantsStream] :: BodyPeek -> Bool
+ MCP.Server.Transport.Http: mcpApplication :: HttpConfig -> McpServerInfo -> McpServerHandlers -> Application
+ MCP.Server.Transport.Http: wantsStreamingResponse :: BodyPeek -> Bool
+ MCP.Server.Types: Annotations :: [MessageRole] -> Maybe Double -> Maybe Text -> Annotations
+ MCP.Server.Types: ContentAnnotated :: Annotations -> Content -> Content
+ MCP.Server.Types: Icon :: Text -> Maybe Text -> [Text] -> Icon
+ MCP.Server.Types: LogAlert :: LogLevel
+ MCP.Server.Types: LogCritical :: LogLevel
+ MCP.Server.Types: LogDebug :: LogLevel
+ MCP.Server.Types: LogEmergency :: LogLevel
+ MCP.Server.Types: LogError :: LogLevel
+ MCP.Server.Types: LogInfo :: LogLevel
+ MCP.Server.Types: LogNotice :: LogLevel
+ MCP.Server.Types: LogWarning :: LogLevel
+ MCP.Server.Types: ToolAnnotations :: Maybe Text -> Maybe Bool -> Maybe Bool -> Maybe Bool -> Maybe Bool -> ToolAnnotations
+ MCP.Server.Types: ToolOutput :: o -> ToolOutput o
+ MCP.Server.Types: ToolOutputError :: Text -> ToolOutput o
+ MCP.Server.Types: ToolOutputRaw :: ToolResult -> ToolOutput o
+ MCP.Server.Types: ToolOutputWith :: [Content] -> o -> ToolOutput o
+ MCP.Server.Types: [annotationsAudience] :: Annotations -> [MessageRole]
+ MCP.Server.Types: [annotationsLastModified] :: Annotations -> Maybe Text
+ MCP.Server.Types: [annotationsPriority] :: Annotations -> Maybe Double
+ MCP.Server.Types: [iconMimeType] :: Icon -> Maybe Text
+ MCP.Server.Types: [iconSizes] :: Icon -> [Text]
+ MCP.Server.Types: [iconSrc] :: Icon -> Text
+ MCP.Server.Types: [logToClient] :: ClientContext -> LogLevel -> Value -> IO ()
+ MCP.Server.Types: [promptDefinitionIcons] :: PromptDefinition -> [Icon]
+ MCP.Server.Types: [reportProgress] :: ClientContext -> Double -> Maybe Double -> Maybe Text -> IO ()
+ MCP.Server.Types: [resourceDefinitionIcons] :: ResourceDefinition -> [Icon]
+ MCP.Server.Types: [resourceTemplateIcons] :: ResourceTemplateDefinition -> [Icon]
+ MCP.Server.Types: [toolAnnotationsTitle] :: ToolAnnotations -> Maybe Text
+ MCP.Server.Types: [toolDefinitionAnnotations] :: ToolDefinition -> Maybe ToolAnnotations
+ MCP.Server.Types: [toolDefinitionIcons] :: ToolDefinition -> [Icon]
+ MCP.Server.Types: [toolDestructiveHint] :: ToolAnnotations -> Maybe Bool
+ MCP.Server.Types: [toolIdempotentHint] :: ToolAnnotations -> Maybe Bool
+ MCP.Server.Types: [toolOpenWorldHint] :: ToolAnnotations -> Maybe Bool
+ MCP.Server.Types: [toolReadOnlyHint] :: ToolAnnotations -> Maybe Bool
+ MCP.Server.Types: data Annotations
+ MCP.Server.Types: data Icon
+ MCP.Server.Types: data LogLevel
+ MCP.Server.Types: data ToolAnnotations
+ MCP.Server.Types: data ToolOutput o
+ MCP.Server.Types: defaultAnnotations :: Annotations
+ MCP.Server.Types: defaultToolAnnotations :: ToolAnnotations
+ MCP.Server.Types: icon :: Text -> Icon
+ MCP.Server.Types: instance Data.Aeson.Types.FromJSON.FromJSON MCP.Server.Types.Annotations
+ MCP.Server.Types: instance Data.Aeson.Types.FromJSON.FromJSON MCP.Server.Types.Icon
+ MCP.Server.Types: instance Data.Aeson.Types.FromJSON.FromJSON MCP.Server.Types.LogLevel
+ MCP.Server.Types: instance Data.Aeson.Types.FromJSON.FromJSON MCP.Server.Types.MessageRole
+ MCP.Server.Types: instance Data.Aeson.Types.ToJSON.ToJSON MCP.Server.Types.Annotations
+ MCP.Server.Types: instance Data.Aeson.Types.ToJSON.ToJSON MCP.Server.Types.Icon
+ MCP.Server.Types: instance Data.Aeson.Types.ToJSON.ToJSON MCP.Server.Types.LogLevel
+ MCP.Server.Types: instance Data.Aeson.Types.ToJSON.ToJSON MCP.Server.Types.ToolAnnotations
+ MCP.Server.Types: instance GHC.Classes.Eq MCP.Server.Types.Annotations
+ MCP.Server.Types: instance GHC.Classes.Eq MCP.Server.Types.Icon
+ MCP.Server.Types: instance GHC.Classes.Eq MCP.Server.Types.LogLevel
+ MCP.Server.Types: instance GHC.Classes.Eq MCP.Server.Types.ToolAnnotations
+ MCP.Server.Types: instance GHC.Classes.Eq o => GHC.Classes.Eq (MCP.Server.Types.ToolOutput o)
+ MCP.Server.Types: instance GHC.Classes.Ord MCP.Server.Types.LogLevel
+ MCP.Server.Types: instance GHC.Internal.Enum.Bounded MCP.Server.Types.LogLevel
+ MCP.Server.Types: instance GHC.Internal.Enum.Enum MCP.Server.Types.LogLevel
+ MCP.Server.Types: instance GHC.Internal.Generics.Generic MCP.Server.Types.Annotations
+ MCP.Server.Types: instance GHC.Internal.Generics.Generic MCP.Server.Types.Icon
+ MCP.Server.Types: instance GHC.Internal.Generics.Generic MCP.Server.Types.LogLevel
+ MCP.Server.Types: instance GHC.Internal.Generics.Generic MCP.Server.Types.ToolAnnotations
+ MCP.Server.Types: instance GHC.Internal.Show.Show MCP.Server.Types.Annotations
+ MCP.Server.Types: instance GHC.Internal.Show.Show MCP.Server.Types.Icon
+ MCP.Server.Types: instance GHC.Internal.Show.Show MCP.Server.Types.LogLevel
+ MCP.Server.Types: instance GHC.Internal.Show.Show MCP.Server.Types.ToolAnnotations
+ MCP.Server.Types: instance GHC.Internal.Show.Show o => GHC.Internal.Show.Show (MCP.Server.Types.ToolOutput o)
+ MCP.Server.Types: instance Language.Haskell.TH.Syntax.Lift MCP.Server.Types.Icon
+ MCP.Server.Types: instance Language.Haskell.TH.Syntax.Lift MCP.Server.Types.ToolAnnotations
+ MCP.Server.Types: mkPromptDefinition :: Text -> Text -> [ArgumentDefinition] -> PromptDefinition
+ MCP.Server.Types: mkResourceDefinition :: Text -> Text -> ResourceDefinition
+ MCP.Server.Types: mkResourceTemplateDefinition :: Text -> Text -> ResourceTemplateDefinition
+ MCP.Server.Types: mkToolDefinition :: Text -> Text -> Schema -> ToolDefinition
- MCP.Server.Handlers: handleMcpMessage :: McpServerInfo -> CacheHints -> NotificationSupport -> McpServerHandlers -> ClientContext -> JsonRpcMessage -> IO (Maybe JsonRpcMessage)
+ MCP.Server.Handlers: handleMcpMessage :: McpServerInfo -> CacheHints -> NotificationSupport -> (JsonRpcNotification -> IO ()) -> McpServerHandlers -> ClientContext -> JsonRpcMessage -> IO (Maybe JsonRpcMessage)
- MCP.Server.Transport.Http: BodyPeek :: Maybe Text -> Maybe Text -> Maybe Text -> RequestId -> BodyPeek
+ MCP.Server.Transport.Http: BodyPeek :: Maybe Text -> Maybe Text -> Maybe Text -> RequestId -> Bool -> Bool -> BodyPeek
- MCP.Server.Types: ClientContext :: Maybe Text -> Maybe Value -> Maybe Text -> Maybe Value -> Maybe Value -> ClientContext
+ 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: PromptDefinition :: Text -> Text -> [ArgumentDefinition] -> Maybe Text -> PromptDefinition
+ MCP.Server.Types: PromptDefinition :: Text -> Text -> [ArgumentDefinition] -> Maybe Text -> [Icon] -> PromptDefinition
- MCP.Server.Types: ResourceDefinition :: Text -> Text -> Maybe Text -> Maybe Text -> Maybe Text -> ResourceDefinition
+ MCP.Server.Types: ResourceDefinition :: Text -> Text -> Maybe Text -> Maybe Text -> Maybe Text -> [Icon] -> ResourceDefinition
- MCP.Server.Types: ResourceTemplateDefinition :: Text -> Text -> Maybe Text -> Maybe Text -> Maybe Text -> ResourceTemplateDefinition
+ MCP.Server.Types: ResourceTemplateDefinition :: Text -> Text -> Maybe Text -> Maybe Text -> Maybe Text -> [Icon] -> ResourceTemplateDefinition
- MCP.Server.Types: ToolDefinition :: Text -> Text -> Schema -> Maybe Schema -> Maybe Text -> ToolDefinition
+ MCP.Server.Types: ToolDefinition :: Text -> Text -> Schema -> Maybe Schema -> Maybe Text -> Maybe ToolAnnotations -> [Icon] -> ToolDefinition
Files
- CHANGELOG.md +92/−0
- README.md +180/−12
- examples/Complete/Main.hs +22/−3
- mcp-server.cabal +9/−1
- src/MCP/Server.hs +3/−1
- src/MCP/Server/Derive.hs +293/−64
- src/MCP/Server/Derive/Internal.hs +53/−2
- src/MCP/Server/Handlers.hs +52/−5
- src/MCP/Server/Transport/Http.hs +141/−15
- src/MCP/Server/Transport/Stdio.hs +69/−14
- src/MCP/Server/Types.hs +259/−17
- test/HspecMain.hs +8/−0
- test/Spec/Cancellation.hs +80/−0
- test/Spec/DefinitionMetadata.hs +105/−0
- test/Spec/DerivedOutput.hs +112/−0
- test/Spec/GoldenWire.hs +148/−83
- test/Spec/ModernEra.hs +22/−2
- test/Spec/Progress.hs +106/−0
- test/Spec/Subscriptions.hs +1/−0
- test/Spec/TemplatesCompletions.hs +1/−0
- test/Spec/UnicodeHandling.hs +8/−19
- test/TestTypes.hs +43/−1
- test/golden/README.md +69/−0
- test/golden/legacy/completion-complete.json +0/−1
- test/golden/legacy/completion-complete.request.json +1/−0
- test/golden/legacy/completion-complete.response.json +1/−0
- test/golden/legacy/initialize-notifying.json +0/−1
- test/golden/legacy/initialize-notifying.request.json +1/−0
- test/golden/legacy/initialize-notifying.response.json +1/−0
- test/golden/legacy/initialize.json +0/−1
- test/golden/legacy/initialize.request.json +1/−0
- test/golden/legacy/initialize.response.json +1/−0
- test/golden/legacy/ping.json +0/−1
- test/golden/legacy/ping.request.json +1/−0
- test/golden/legacy/ping.response.json +1/−0
- test/golden/legacy/prompts-get.json +0/−1
- test/golden/legacy/prompts-get.request.json +1/−0
- test/golden/legacy/prompts-get.response.json +1/−0
- test/golden/legacy/prompts-list.json +0/−1
- test/golden/legacy/prompts-list.request.json +1/−0
- test/golden/legacy/prompts-list.response.json +1/−0
- test/golden/legacy/resources-list.json +0/−1
- test/golden/legacy/resources-list.request.json +1/−0
- test/golden/legacy/resources-list.response.json +1/−0
- test/golden/legacy/resources-read.json +0/−1
- test/golden/legacy/resources-read.request.json +1/−0
- test/golden/legacy/resources-read.response.json +1/−0
- test/golden/legacy/resources-templates-list.json +0/−1
- test/golden/legacy/resources-templates-list.request.json +1/−0
- test/golden/legacy/resources-templates-list.response.json +1/−0
- test/golden/legacy/tools-call-annotated.request.json +1/−0
- test/golden/legacy/tools-call-annotated.response.json +1/−0
- test/golden/legacy/tools-call-boom.json +0/−1
- test/golden/legacy/tools-call-boom.request.json +1/−0
- test/golden/legacy/tools-call-boom.response.json +1/−0
- test/golden/legacy/tools-call-echo.json +0/−1
- test/golden/legacy/tools-call-echo.request.json +1/−0
- test/golden/legacy/tools-call-echo.response.json +1/−0
- test/golden/legacy/tools-call-structured.request.json +1/−0
- test/golden/legacy/tools-call-structured.response.json +1/−0
- test/golden/legacy/tools-call-unknown.json +0/−1
- test/golden/legacy/tools-call-unknown.request.json +1/−0
- test/golden/legacy/tools-call-unknown.response.json +1/−0
- test/golden/legacy/tools-list-extended.request.json +1/−0
- test/golden/legacy/tools-list-extended.response.json +1/−0
- test/golden/legacy/tools-list.json +0/−1
- test/golden/legacy/tools-list.request.json +1/−0
- test/golden/legacy/tools-list.response.json +1/−0
- test/golden/manifest.json +151/−0
- test/golden/modern/completion-complete.json +0/−1
- test/golden/modern/completion-complete.request.json +1/−0
- test/golden/modern/completion-complete.response.json +1/−0
- test/golden/modern/initialize.json +0/−1
- test/golden/modern/initialize.request.json +1/−0
- test/golden/modern/initialize.response.json +1/−0
- test/golden/modern/ping.json +0/−1
- test/golden/modern/ping.request.json +1/−0
- test/golden/modern/ping.response.json +1/−0
- test/golden/modern/prompts-get.json +0/−1
- test/golden/modern/prompts-get.request.json +1/−0
- test/golden/modern/prompts-get.response.json +1/−0
- test/golden/modern/resources-read.json +0/−1
- test/golden/modern/resources-read.request.json +1/−0
- test/golden/modern/resources-read.response.json +1/−0
- test/golden/modern/resources-templates-list.json +0/−1
- test/golden/modern/resources-templates-list.request.json +1/−0
- test/golden/modern/resources-templates-list.response.json +1/−0
- test/golden/modern/server-discover-notifying.json +0/−1
- test/golden/modern/server-discover-notifying.request.json +1/−0
- test/golden/modern/server-discover-notifying.response.json +1/−0
- test/golden/modern/server-discover.json +0/−1
- test/golden/modern/server-discover.request.json +1/−0
- test/golden/modern/server-discover.response.json +1/−0
- test/golden/modern/tools-call-annotated.request.json +1/−0
- test/golden/modern/tools-call-annotated.response.json +1/−0
- test/golden/modern/tools-call-echo.json +0/−1
- test/golden/modern/tools-call-echo.request.json +1/−0
- test/golden/modern/tools-call-echo.response.json +1/−0
- test/golden/modern/tools-call-structured.request.json +1/−0
- test/golden/modern/tools-call-structured.response.json +1/−0
- test/golden/modern/tools-list.json +0/−1
- test/golden/modern/tools-list.request.json +1/−0
- test/golden/modern/tools-list.response.json +1/−0
- test/golden/modern/unsupported-version.json +0/−1
- test/golden/modern/unsupported-version.request.json +1/−0
- test/golden/modern/unsupported-version.response.json +1/−0
CHANGELOG.md view
@@ -1,5 +1,97 @@ # Revision history for mcp-server +## 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
README.md view
@@ -17,6 +17,9 @@ - ✅ **Resource Templates**: Parameterized resources via URI templates - ✅ **Tools**: Model-controlled callable functions - ✅ **Completions**: Argument autocompletion for prompts and templates+- ✅ **Change Notifications**: `listChanged`/resource-update pushes, via `subscriptions/listen` (2026-07-28) or legacy stdio delivery+- ✅ **Progress & Logging**: `notifications/progress` and `notifications/message` scoped to the requesting client+- ✅ **Cancellation**: `notifications/cancelled` (stdio) and stream closure (HTTP) interrupt in-flight handlers - ✅ **Initialization Flow**: Complete protocol lifecycle with version negotiation - ✅ **Error Handling**: Comprehensive error types and JSON-RPC error responses @@ -35,6 +38,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} +import Data.Text (Text) import MCP.Server import MCP.Server.Derive @@ -57,6 +61,11 @@ handleTool _ (Search query) = pure $ ContentText $ "Search results for " <> query handleTool _ (Order item) = pure $ ContentText $ "Ordered " <> item +-- Template Haskell staging: this empty splice ends the declaration group,+-- so the derive splices below can see the types above. (Alternatively,+-- declare the types in a separate module, as the examples/ do.)+$(pure [])+ -- Derive handlers automatically main :: IO () main = runMcpServerStdio serverInfo handlers@@ -66,7 +75,10 @@ , serverVersion = "1.0.0" , serverInstructions = "A sample MCP server" }- handlers = McpServerHandlers+ -- Start from 'noHandlers' and record-update the features you provide:+ -- constructing McpServerHandlers directly breaks (at runtime!) when a+ -- field is missed, and the library grows new handler slots over time.+ handlers = noHandlers { prompts = Just $(derivePromptHandler ''MyPrompt 'handlePrompt) , resources = Just $(deriveResourceHandler ''MyResource 'handleResource) , tools = Just $(deriveToolHandler ''MyTool 'handleTool)@@ -129,6 +141,33 @@ Prompt handlers can likewise return a `PromptResult` with a description and a multi-message conversation (user and assistant roles). +#### Derived Output Schemas++Tools can be typed on the way out too: give the derivation a result record+and it derives the tool's `outputSchema` (same field rules as inputs —+primitives, `Maybe`, lists, enums, nested records) and serializes your typed+values into `structuredContent`, guaranteed to match the schema. Per the+spec's recommendation, the JSON is also returned as a text content block for+clients that predate structured output:++```haskell+data WeatherReport = WeatherReport+ { temperature :: Int+ , sky :: Sky -- enum+ , alerts :: [Text]+ , humidity :: Maybe Int -- optional, 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`.+ #### Nested Parameter Types You can nest parameter types with automatic unwrapping:@@ -207,6 +246,33 @@ All parameter types must ultimately resolve to records with named fields to generate proper MCP schemas. +#### Tool Annotations, Icons and Titles++The `WithOptions` derivation variants take per-constructor+`DefinitionOptions` — description, title, icons, behavioral annotations+(which drive client permission UX, e.g. auto-approving read-only tools),+and argument descriptions scoped to the constructor:++```haskell+tools = Just $(deriveToolHandlerWithOptions ''MyTool 'handleTool+ [ ("Search", defaultDefinitionOptions+ { optDescription = Just "Search the catalog"+ , optToolAnnotations = Just defaultToolAnnotations+ { toolReadOnlyHint = Just True, toolIdempotentHint = Just True }+ , optIcons = [icon "https://example.com/search.png"]+ , optFieldDescriptions = [("q", "Search terms")]+ })+ ])+```++Content blocks can carry annotations too (`audience`, `priority`,+`lastModified`), attached with the `ContentAnnotated` wrapper:++```haskell+ContentAnnotated defaultAnnotations { annotationsPriority = Just 0.9 }+ (ContentText "important result")+```+ ## Custom Descriptions You can provide custom descriptions for constructors and fields using the `*WithDescription` variants:@@ -222,7 +288,7 @@ ] -- Use in derivation-handlers = McpServerHandlers+handlers = noHandlers { prompts = Just $(derivePromptHandlerWithDescription ''MyPrompt 'handlePrompt descriptions) , tools = Just $(deriveToolHandlerWithDescription ''MyTool 'handleTool descriptions) , resources = Just $(deriveResourceHandlerWithDescription ''MyResource 'handleResource descriptions)@@ -246,13 +312,73 @@ main :: IO () main = runMcpServerStdio serverInfo handlers where- handlers = McpServerHandlers+ handlers = noHandlers { prompts = Just (promptListHandler, promptGetHandler)- , resources = Nothing -- Not supported- , tools = Nothing -- Not supported } ``` +## Progress and Per-Request Logging++Handlers can report progress on long-running work and send log messages to+the calling client through actions on the `ClientContext`:++```haskell+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+```++Both are safe to call unconditionally:++- `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` — the spec forbids it otherwise — and+ drops messages below the declared level.++Delivery is transport-appropriate: on stdio the notifications interleave+before the response; on HTTP, a request that opted in is answered with an+SSE response stream carrying the notifications followed by the final+response (requests that didn't opt in keep the single-JSON response).++## Cancellation++In-flight requests can be cancelled, and per the spec the server then stops+work as soon as practical and sends nothing further for that request:++- **stdio**: each request runs in its own task; a `notifications/cancelled`+ naming its id cancels the task (cancellations for unknown or completed ids+ are ignored, as required).+- **HTTP**: closing the response stream is the cancellation signal. For SSE+ responses the handler is cancelled as soon as the disconnect is detected+ (within one keep-alive interval). For single-JSON responses a disconnect+ is only detected at the final write — the handler runs to completion+ first — so mid-handler cancellation applies to streaming requests:+ clients wanting cancellable calls should opt into streaming via a+ `progressToken`.++A consequence of cancellable requests: **requests are now served+concurrently on both transports** (stdio previously processed them strictly+sequentially). Handlers touching shared mutable state must synchronize+(`MVar`, `STM`, ...) — as was already required for HTTP servers.++Cancellation is delivered to handler code as an asynchronous exception (the+standard GHC mechanism, as used by `timeout` and `cancel`). Handlers are+interruptible wherever they block in `IO`; a handler that acquires resources+must release them with `bracket`/`finally` so cancellation cannot leak them:++```haskell+handleTool ctx (ImportData file) =+ bracket (openFile file ReadMode) hClose $ \h -> do+ ...+```++Handlers that must not be interrupted mid-operation can shield critical+sections with `mask`, but should keep them short — cancellation waits for+them.+ ## Change Notifications Servers whose tool/prompt/resource lists change at runtime can push change@@ -277,12 +403,12 @@ 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 after- `initialize`.+- **Legacy stdio clients** receive spontaneous untagged notifications once+ their `notifications/initialized` arrives (the lifecycle's ready signal). - **Legacy HTTP clients** have no delivery channel (this library does not offer the deprecated GET SSE stream), so nothing is advertised to them. -## HTTP Transport (NEW!)+## HTTP Transport The library supports the MCP Streamable HTTP transport. Compile your executable with `ghc-options: -threaded` — Warp requires the threaded runtime:@@ -326,23 +452,64 @@ **Features:** - CORS enabled for web clients-- POST `/mcp` for JSON-RPC messages (GET returns 405 — this library does not- offer server-initiated SSE streams)+- POST `/mcp` for JSON-RPC messages (GET returns 405 — server-to-client+ notifications flow over the `subscriptions/listen` POST response stream,+ not a standalone GET stream) - Dual-era protocol support: legacy revisions (`2024-11-05`–`2025-11-25`) negotiate via `initialize`; the stateless `2026-07-28` revision declares its version per request in `_meta`, with full request-metadata header validation (`MCP-Protocol-Version`, `Mcp-Method`, `Mcp-Name` including the base64 sentinel encoding)+- Change notifications as long-lived SSE streams via `httpNotifications`+ (see [Change Notifications](#change-notifications)) - Optional pluggable bearer-token authentication via `httpAuthorize` - Origin validation via `httpAllowedOrigins` - Cacheability hints for modern list/read results via `httpCacheHints` +### Embedding in an existing WAI stack++`runMcpServerHttp` starts its own Warp server, but the MCP endpoint is a+plain [WAI](https://hackage.haskell.org/package/wai) application underneath,+and it is exported — so you can mount it inside whatever you already run+(your own Warp settings, TLS, middleware, or a larger router):++```haskell+import MCP.Server (mcpApplication, defaultHttpConfig)+import qualified Network.Wai.Handler.Warp as Warp++main :: IO ()+main = Warp.runSettings mySettings $ \req respond ->+ -- route /mcp to the MCP endpoint, everything else to your app+ mcpApplication defaultHttpConfig serverInfo handlers req respond+```++`httpPort`/`httpHost` are ignored when embedding (they only configure the+server `runMcpServerHttp` starts); the endpoint path, Origin validation,+bearer auth and `subscriptions/listen` streaming all apply as usual.++## Conformance corpus++The wire-format fixtures under+[`test/golden/`](test/golden/README.md) double as an **API-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+(legacy `initialize`-negotiated revisions and the stateless `2026-07-28`+revision), enumerated by a `manifest.json`. Nothing in the corpus is+Haskell-specific — any MCP server implementation that reproduces the small+reference server described in the corpus README can replay the requests and+diff the responses. Contributions of new cases are welcome.++## Roadmap++Design decisions and planned work live as ADRs under+[`specs/`](specs/README.md), ordered by [`specs/ROADMAP.md`](specs/ROADMAP.md).+ ## Examples The library includes several examples: - **`examples/Simple/`**: Basic key-value store using Template Haskell derivation (STDIO)-- **`examples/Complete/`**: Full-featured example with prompts, resources, and tools (STDIO)+- **`examples/Complete/`**: Full-featured example with prompts, resources, a resource template, tools (enum/nested/list arguments, `isError`), and completions (STDIO) - **`examples/HttpSimple/`**: HTTP version of the simple key-value store ## Docker Usage@@ -377,7 +544,8 @@ ## Documentation -- [MCP Specification](https://modelcontextprotocol.io/specification/2025-11-25/)+- [MCP Specification (2026-07-28)](https://modelcontextprotocol.io/specification/2026-07-28/)+- [MCP Specification (2025-11-25, newest legacy revision)](https://modelcontextprotocol.io/specification/2025-11-25/) - [API Documentation](https://hackage.haskell.org/package/mcp-server) - [Examples](examples/)
examples/Complete/Main.hs view
@@ -33,10 +33,15 @@ -- with isError so the model can see them (returning plain Content works too -- for simple tools — see the other examples). handleTool :: ClientContext -> MyTool -> IO ToolResult-handleTool _ (SearchForProduct q category) =- case category of+handleTool ctx (SearchForProduct q category) = do+ -- Progress reporting is safe to call unconditionally: it is a no-op+ -- unless the request carried a progressToken+ reportProgress ctx 0.5 (Just 1.0) (Just "searching the catalog")+ result <- case category of Nothing -> pure $ toolResult [ContentText $ "Search results for '" <> q <> "': Found 15 products across all categories"] Just cat -> pure $ toolResult [ContentText $ "Search results for '" <> q <> "' in " <> cat <> ": Found 8 products"]+ reportProgress ctx 1.0 (Just 1.0) Nothing+ pure result handleTool _ (AddToCart sku quantities) | any (<= 0) quantities = pure $ toolError "Quantities must be positive" | otherwise = pure $ toolResult@@ -69,7 +74,21 @@ let prompts = $(derivePromptHandler ''MyPrompt 'handlePrompt) resources = $(deriveResourceHandler ''MyResource 'handleResource) templates = $(deriveResourceTemplates ''MyResource)- tools = $(deriveToolHandler ''MyTool 'handleTool)+ -- Per-constructor options: descriptions, behavioral hints for+ -- client permission UX, icons, and constructor-scoped argument+ -- descriptions+ tools = $(deriveToolHandlerWithOptions ''MyTool 'handleTool+ [ ("SearchForProduct", defaultDefinitionOptions+ { optDescription = Just "Search the product catalog"+ , optToolAnnotations = Just defaultToolAnnotations+ { toolReadOnlyHint = Just True, toolIdempotentHint = Just True }+ , optFieldDescriptions = [("q", "Search terms"), ("category", "Restrict to a category")]+ })+ , ("Checkout", defaultDefinitionOptions+ { optToolAnnotations = Just defaultToolAnnotations+ { toolDestructiveHint = Just True }+ })+ ]) in runMcpServerStdio McpServerInfo { serverName = "Complete Example MCP Server"
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.2.0.0+version: 0.2.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.@@ -53,6 +53,8 @@ -- Extra source files to be distributed with the package, such as examples, or a tutorial module. extra-source-files:+ test/golden/README.md+ test/golden/manifest.json test/golden/legacy/*.json test/golden/modern/*.json -- Source repository information@@ -88,6 +90,7 @@ -- Other library packages from which modules are imported. build-depends: aeson >=2 && <3,+ async >=2.2 && <2.3, base >=4.18 && <4.23, base64-bytestring >=1.0 && <1.3, bytestring >=0.10 && <0.13,@@ -178,9 +181,13 @@ other-modules: Spec.AdvancedDerivation Spec.BasicDerivation+ Spec.Cancellation+ Spec.DefinitionMetadata+ Spec.DerivedOutput Spec.GoldenWire Spec.JSONConversion Spec.ModernEra+ Spec.Progress Spec.ProtocolVersionNegotiation Spec.SchemaValidation Spec.Subscriptions@@ -204,6 +211,7 @@ build-depends: QuickCheck, aeson,+ async, base, bytestring, containers,
src/MCP/Server.hs view
@@ -13,6 +13,7 @@ , defaultStdioConfig , HttpConfig(..) , defaultHttpConfig+ , mcpApplication -- * Change Notifications , McpNotifier(..)@@ -28,7 +29,8 @@ import MCP.Server.Transport.Stdio (StdioConfig (..), defaultStdioConfig, transportRunStdio, transportRunStdioWithConfig)-import MCP.Server.Transport.Http (HttpConfig(..), transportRunHttp, defaultHttpConfig)+import MCP.Server.Transport.Http (HttpConfig(..), transportRunHttp, defaultHttpConfig,+ mcpApplication) import MCP.Server.Types -- | Run an MCP server using STDIO transport
src/MCP/Server/Derive.hs view
@@ -1,3 +1,4 @@+{-# LANGUAGE DeriveLift #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} @@ -14,24 +15,84 @@ ( -- * Template Haskell Derivation derivePromptHandler , derivePromptHandlerWithDescription+ , derivePromptHandlerWithOptions , deriveResourceHandler , deriveResourceHandlerWithDescription+ , deriveResourceHandlerWithOptions , deriveResourceTemplates , deriveResourceTemplatesWithDescription+ , deriveResourceTemplatesWithOptions , deriveToolHandler , deriveToolHandlerWithDescription+ , deriveToolHandlerWithOptions+ , deriveToolHandlerWithOutput+ , deriveToolHandlerWithOutputDescription+ , deriveToolHandlerWithOutputOptions++ -- * Per-constructor customization+ , DefinitionOptions(..)+ , defaultDefinitionOptions ) where +import Control.Monad (zipWithM) import Data.Aeson (Value (..)) import Data.List (intercalate) import Data.Maybe (fromMaybe)+import Data.Text (Text) import qualified Data.Text as T import Language.Haskell.TH+import Language.Haskell.TH.Syntax (Lift, lift) import qualified Data.Char as Char import MCP.Server.Derive.Internal import MCP.Server.Types +-- | Per-constructor customization for the @WithOptions@ derivations: a+-- single mechanism for everything a definition can carry beyond its shape.+-- Entries are looked up by constructor name; for output-typed tool+-- derivations, an entry keyed by the /output type's/ name supplies its+-- field descriptions.+data DefinitionOptions = DefinitionOptions+ { optDescription :: Maybe Text+ -- ^ The definition's description (falls back to the constructor name)+ , optTitle :: Maybe Text+ , optIcons :: [Icon]+ , optToolAnnotations :: Maybe ToolAnnotations+ -- ^ Behavioral hints; meaningful for tools, ignored elsewhere+ , optFieldDescriptions :: [(Text, Text)]+ -- ^ Field\/argument descriptions, scoped to this constructor — two+ -- constructors may describe a same-named field differently+ } deriving (Show, Eq, Lift)++-- | Nothing customized; record-update what you need.+defaultDefinitionOptions :: DefinitionOptions+defaultDefinitionOptions = DefinitionOptions+ { optDescription = Nothing+ , optTitle = Nothing+ , optIcons = []+ , optToolAnnotations = Nothing+ , optFieldDescriptions = []+ }++-- | Look up a constructor's options.+optionsFor :: [(String, DefinitionOptions)] -> String -> DefinitionOptions+optionsFor opts name = fromMaybe defaultDefinitionOptions (lookup name opts)++-- | Adapt the legacy flat description list to per-constructor options:+-- constructor entries become 'optDescription'; the whole list also serves+-- as the (unscoped) field-description namespace, preserving the old+-- behavior exactly.+optionsFromDescriptions :: [(String, String)] -> String -> DefinitionOptions+optionsFromDescriptions descriptions name = defaultDefinitionOptions+ { optDescription = T.pack <$> lookup name descriptions+ }++-- The merged field-description namespace for one constructor: its scoped+-- entries shadow the legacy global list.+fieldDescsFor :: DefinitionOptions -> [(String, String)] -> [(String, String)]+fieldDescsFor opts globalDescs =+ [(T.unpack k, T.unpack v) | (k, v) <- optFieldDescriptions opts] ++ globalDescs+ ------------------------------------------------------------------------------- -- Small helpers -------------------------------------------------------------------------------@@ -140,8 +201,8 @@ cons <- reifyCons n case cons of Just cs | not (null cs), all isNullary cs -> mkEnumValueParser n cs- Just [RecC icon ifields] -> mkObjectParser n icon ifields- Just [NormalC icon [(_bang, inner)]] -> [| fmap $(conE icon) . $(mkValueParser inner) |]+ Just [RecC icn ifields] -> mkObjectParser n icn ifields+ Just [NormalC icn [(_bang, inner)]] -> [| fmap $(conE icn) . $(mkValueParser inner) |] _ -> fail $ "Unsupported tool argument type: " ++ show n _ -> fail $ "Unsupported tool argument type: " ++ show ty @@ -172,11 +233,11 @@ -- Parser for a nested record: a JSON object decoded field by field mkObjectParser :: Name -> Name -> [(Name, Bang, Type)] -> Q Exp-mkObjectParser tyName icon ifields = do+mkObjectParser tyName icn ifields = do vVar <- newName "v" oVar <- newName "o" chain <- foldl (\acc f -> [| $acc <*> $(fieldExtractor oVar f) |])- [| pure $(conE icon) |]+ [| pure $(conE icn) |] ifields fallback <- [| Left ("Failed to parse " <> $(litE $ stringL $ nameBase tyName) <> " object from: " <> renderValue $(varE vVar)) |]@@ -215,10 +276,75 @@ _ -> fail $ "Unsupported prompt argument type (prompt arguments are strings): " ++ show ty -------------------------------------------------------------------------------+-- Value serializers (structured output)+-------------------------------------------------------------------------------++-- Build a serializer expression of type @a -> Value@ that mirrors+-- 'mkSchemaShape' exactly: what the schema promises is what the value+-- contains (snake_cased enums, 'Maybe' fields omitted when 'Nothing').+mkValueBuilder :: Type -> Q Exp+mkValueBuilder ty = case ty of+ ConT n | nameBase n `elem` ["Int", "Integer", "Double", "Float", "Bool", "Text"] ->+ [| toJSON |]+ AppT ListT inner ->+ [| \xs -> toJSON (map $(mkValueBuilder inner) xs) |]+ ConT n -> do+ cons <- reifyCons n+ case cons of+ Just cs | not (null cs), all isNullary cs -> do+ vVar <- newName "v"+ matches <- mapM enumMatch cs+ return $ LamE [VarP vVar] $ CaseE (VarE vVar) matches+ Just [RecC icn ifields] -> mkRecordBuilder icn ifields+ Just [NormalC icn [(_bang, inner)]] -> do+ xVar <- newName "x"+ wrapped <- conP icn [varP xVar]+ body <- [| $(mkValueBuilder inner) $(varE xVar) |]+ return $ LamE [wrapped] body+ _ -> fail $ "Unsupported output field type: " ++ show n+ _ -> fail $ "Unsupported output field type: " ++ show ty+ where+ enumMatch c = do+ let cn = conName c+ body <- [| String $(litE $ stringL $ snakeName cn) |]+ pat <- conP cn []+ return $ Match pat (NormalB body) []++-- Serializer for a record: an object with one entry per field, omitting+-- 'Maybe' fields that are 'Nothing'. Fields are bound by pattern-matching+-- the constructor — never by accessor application, which would be ambiguous+-- for users with DuplicateRecordFields enabled.+mkRecordBuilder :: Name -> [(Name, Bang, Type)] -> Q Exp+mkRecordBuilder con fields = do+ vars <- mapM (const $ newName "f") fields+ pat <- conP con (map varP vars)+ fieldExps <- zipWithM fieldPairs vars fields+ body <- [| object (concat $(return $ ListE fieldExps)) |]+ return $ LamE [pat] body+ where+ fieldPairs var (fieldName, _, fieldType) = do+ let (isOptional, innerType) = unwrapMaybe fieldType+ let fname = litE $ stringL $ nameBase fieldName+ if isOptional+ then [| omitNothing $fname $(mkValueBuilder innerType) $(varE var) |]+ else [| [ $fname .= $(mkValueBuilder innerType) $(varE var) ] |]++-- The output type must (possibly through single-constructor wrappers)+-- resolve to a record: outputSchema is an object schema per the spec.+requireOutputRecord :: Name -> Q ()+requireOutputRecord n = do+ cons <- reifyCons n+ case cons of+ Just [RecC _ (_:_)] -> pure ()+ Just [NormalC _ [(_bang, ConT inner)]] -> requireOutputRecord inner+ _ -> fail $ "Output type " ++ show n+ ++ " must be a record (outputSchema is an object schema)"++------------------------------------------------------------------------------- -- Argument decoding for a constructor ------------------------------------------------------------------------------- -data ArgStyle = ToolArgs | PromptArgs+data ArgStyle = ToolArgs | ToolArgsOutput Exp | PromptArgs -- Build a decoder expression of type @Either Error <ConType>@ reading from -- the in-scope arguments map bound to 'argsName'.@@ -232,11 +358,11 @@ ConT typeName -> do cons <- reifyCons typeName case cons of- Just [RecC icon ifields] -> do- inner <- mkFieldsDecoder style (conE icon) ifields+ Just [RecC icn ifields] -> do+ inner <- mkFieldsDecoder style (conE icn) ifields [| $(conE cn) <$> $(return inner) |]- Just [NormalC icon [(_b, innerTy)]] -> do- innerCon <- mkConDecoder style (NormalC icon [(_b, innerTy)])+ Just [NormalC icn [(_b, innerTy)]] -> do+ innerCon <- mkConDecoder style (NormalC icn [(_b, innerTy)]) [| $(conE cn) <$> $(return innerCon) |] _ -> fail $ "Parameter type " ++ show typeName ++ " must be a record type or single-parameter constructor" _ -> fail $ "Parameter type must be a concrete type, got: " ++ show ty@@ -251,12 +377,12 @@ let (isOptional, innerType) = unwrapMaybe fieldType let fname = litE $ stringL $ nameBase fieldName case style of- ToolArgs- | isOptional -> [| optionalArg $fname $(mkValueParser innerType) $(varE argsName) |]- | otherwise -> [| requiredArg $fname $(mkValueParser innerType) $(varE argsName) |] PromptArgs | isOptional -> [| optionalTextArg $fname $(mkTextParser innerType) $(varE argsName) |] | otherwise -> [| requiredTextArg $fname $(mkTextParser innerType) $(varE argsName) |]+ _ -- tool styles share the Value-based argument decoding+ | isOptional -> [| optionalArg $fname $(mkValueParser innerType) $(varE argsName) |]+ | otherwise -> [| requiredArg $fname $(mkValueParser innerType) $(varE argsName) |] ------------------------------------------------------------------------------- -- Schema generation@@ -308,12 +434,25 @@ -- -- > $(derivePromptHandlerWithDescription ''MyPrompt 'handlePrompt [("Constructor", "Description")]) derivePromptHandlerWithDescription :: Name -> Name -> [(String, String)] -> Q Exp-derivePromptHandlerWithDescription typeName handlerName descriptions = do+derivePromptHandlerWithDescription typeName handlerName descriptions =+ derivePromptHandlerGeneric typeName handlerName (optionsFromDescriptions descriptions) descriptions++-- | Derive prompt handlers with per-constructor 'DefinitionOptions'+-- (title, icons, scoped argument descriptions). Usage:+--+-- > $(derivePromptHandlerWithOptions ''MyPrompt 'handlePrompt+-- > [("Recipe", defaultDefinitionOptions { optDescription = Just "…" })])+derivePromptHandlerWithOptions :: Name -> Name -> [(String, DefinitionOptions)] -> Q Exp+derivePromptHandlerWithOptions typeName handlerName opts =+ derivePromptHandlerGeneric typeName handlerName (optionsFor opts) []++derivePromptHandlerGeneric :: Name -> Name -> (String -> DefinitionOptions) -> [(String, String)] -> Q Exp+derivePromptHandlerGeneric typeName handlerName conOpts globalDescs = do cons <- reifyCons typeName case cons of Just constructors -> do -- Generate prompt definitions- promptDefs <- traverse (mkPromptDefWithDescription descriptions) constructors+ promptDefs <- traverse (mkPromptDef conOpts globalDescs) constructors -- Generate list handler listHandlerExp <- [| \_ctx -> pure $(return $ ListE promptDefs) |]@@ -327,7 +466,7 @@ (map clauseToMatch cases ++ [defaultMatch]) return $ TupE [Just listHandlerExp, Just getHandlerExp]- Nothing -> fail $ "derivePromptHandlerWithDescription: " ++ show typeName ++ " is not a data type"+ Nothing -> fail $ "derivePromptHandler: " ++ show typeName ++ " is not a data type" -- | Derive prompt handlers from a data type. -- Usage:@@ -337,18 +476,20 @@ derivePromptHandler typeName handlerName = derivePromptHandlerWithDescription typeName handlerName [] -mkPromptDefWithDescription :: [(String, String)] -> Con -> Q Exp-mkPromptDefWithDescription descriptions con = do+mkPromptDef :: (String -> DefinitionOptions) -> [(String, String)] -> Con -> Q Exp+mkPromptDef conOpts globalDescs con = do let name = conName con let sname = snakeName name- let description = descriptionFor descriptions (nameBase name) ("Handle " ++ nameBase name)+ let opts = conOpts (nameBase name)+ let description = fromMaybe (T.pack ("Handle " ++ nameBase name)) (optDescription opts) fields <- getConFields con- args <- traverse (mkArgDef descriptions) fields+ args <- traverse (mkArgDef (fieldDescsFor opts globalDescs)) fields [| PromptDefinition { promptDefinitionName = $(litE $ stringL sname)- , promptDefinitionDescription = $(litE $ stringL description)+ , promptDefinitionDescription = $(lift description) , promptDefinitionArguments = $(return $ ListE args)- , promptDefinitionTitle = Nothing+ , promptDefinitionTitle = $(lift (optTitle opts))+ , promptDefinitionIcons = $(lift (optIcons opts)) } |] mkArgDef :: [(String, String)] -> (Name, Bang, Type) -> Q Exp@@ -377,6 +518,12 @@ Right v -> do r <- $(varE handlerName) $(varE ctxName) v pure (Right (toToolResult r)) |]+ 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)) |] PromptArgs -> [| case $(return decoder) of Left err -> pure (Left err)@@ -403,12 +550,22 @@ -- -- > $(deriveResourceHandlerWithDescription ''MyResource 'handleResource [("Constructor", "Description")]) deriveResourceHandlerWithDescription :: Name -> Name -> [(String, String)] -> Q Exp-deriveResourceHandlerWithDescription typeName handlerName descriptions = do+deriveResourceHandlerWithDescription typeName handlerName descriptions =+ deriveResourceHandlerGeneric typeName handlerName (optionsFromDescriptions descriptions)++-- | Derive resource handlers with per-constructor 'DefinitionOptions'+-- (description, title, icons).+deriveResourceHandlerWithOptions :: Name -> Name -> [(String, DefinitionOptions)] -> Q Exp+deriveResourceHandlerWithOptions typeName handlerName opts =+ deriveResourceHandlerGeneric typeName handlerName (optionsFor opts)++deriveResourceHandlerGeneric :: Name -> Name -> (String -> DefinitionOptions) -> Q Exp+deriveResourceHandlerGeneric typeName handlerName conOpts = do cons <- reifyCons typeName case cons of Just constructors -> do -- Static resource definitions: nullary constructors only- resourceDefs <- traverse (mkResourceDefWithDescription descriptions)+ resourceDefs <- traverse (mkResourceDef conOpts) (filter isNullary constructors) listHandlerExp <- [| \_ctx -> pure $(return $ ListE resourceDefs) |] @@ -420,7 +577,7 @@ let readHandlerExp = LamE [VarP ctxName, VarP uriName] readBody return $ TupE [Just listHandlerExp, Just readHandlerExp]- Nothing -> fail $ "deriveResourceHandlerWithDescription: " ++ show typeName ++ " is not a data type"+ Nothing -> fail $ "deriveResourceHandler: " ++ show typeName ++ " is not a data type" -- | Derive resource handlers from a data type. -- Usage:@@ -436,14 +593,24 @@ -- -- > $(deriveResourceTemplatesWithDescription ''MyResource [("Constructor", "Description")]) deriveResourceTemplatesWithDescription :: Name -> [(String, String)] -> Q Exp-deriveResourceTemplatesWithDescription typeName descriptions = do+deriveResourceTemplatesWithDescription typeName descriptions =+ deriveResourceTemplatesGeneric typeName (optionsFromDescriptions descriptions)++-- | Derive a 'ResourceTemplateListHandler' with per-constructor+-- 'DefinitionOptions' (description, title, icons).+deriveResourceTemplatesWithOptions :: Name -> [(String, DefinitionOptions)] -> Q Exp+deriveResourceTemplatesWithOptions typeName opts =+ deriveResourceTemplatesGeneric typeName (optionsFor opts)++deriveResourceTemplatesGeneric :: Name -> (String -> DefinitionOptions) -> Q Exp+deriveResourceTemplatesGeneric typeName conOpts = do cons <- reifyCons typeName case cons of Just constructors -> do- templateDefs <- traverse (mkTemplateDefWithDescription descriptions)+ templateDefs <- traverse (mkTemplateDef conOpts) [c | c@(RecC _ _) <- constructors] [| \_ctx -> pure $(return $ ListE templateDefs) |]- Nothing -> fail $ "deriveResourceTemplatesWithDescription: " ++ show typeName ++ " is not a data type"+ Nothing -> fail $ "deriveResourceTemplates: " ++ show typeName ++ " is not a data type" -- | Derive a 'ResourceTemplateListHandler' from a resource type's record -- constructors. Usage:@@ -460,38 +627,37 @@ "resource://" <> snakeName name <> concat ["/{" <> nameBase fn <> "}" | (fn, _, _) <- fields] -mkTemplateDefWithDescription :: [(String, String)] -> Con -> Q Exp-mkTemplateDefWithDescription _ (RecC name []) =+mkTemplateDef :: (String -> DefinitionOptions) -> Con -> Q Exp+mkTemplateDef _ (RecC name []) = fail $ "Resource template constructors need at least one field: " ++ nameBase name-mkTemplateDefWithDescription descriptions (RecC name fields) = do- let description = descriptionFor descriptions (nameBase name) (nameBase name)+mkTemplateDef conOpts (RecC name fields) = do+ let opts = conOpts (nameBase name)+ let description = fromMaybe (T.pack (nameBase name)) (optDescription opts) [| ResourceTemplateDefinition { resourceTemplateURITemplate = $(litE $ stringL $ templateURI name fields) , resourceTemplateName = $(litE $ stringL $ snakeName name)- , resourceTemplateDescription = Just $(litE $ stringL description)+ , resourceTemplateDescription = Just $(lift description) , resourceTemplateMimeType = Just "text/plain"- , resourceTemplateTitle = Nothing+ , resourceTemplateTitle = $(lift (optTitle opts))+ , resourceTemplateIcons = $(lift (optIcons opts)) } |]-mkTemplateDefWithDescription _ _ = fail "Resource templates require record constructors"+mkTemplateDef _ _ = fail "Resource templates require record constructors" -mkResourceDefWithDescription :: [(String, String)] -> Con -> Q Exp-mkResourceDefWithDescription descriptions (NormalC name []) = do+mkResourceDef :: (String -> DefinitionOptions) -> Con -> Q Exp+mkResourceDef conOpts (NormalC name []) = do let resourceName = T.pack . snakeName $ name let resourceURI = "resource://" <> T.unpack resourceName- let constructorName = nameBase name- let description = case lookup constructorName descriptions of- Just desc -> Just desc- Nothing -> Just constructorName+ let opts = conOpts (nameBase name)+ let description = fromMaybe (T.pack (nameBase name)) (optDescription opts) [| ResourceDefinition { resourceDefinitionURI = $(litE $ stringL resourceURI) , resourceDefinitionName = $(litE $ stringL $ T.unpack resourceName)- , resourceDefinitionDescription = $(case description of- Just desc -> [| Just $(litE $ stringL desc) |]- Nothing -> [| Nothing |])+ , resourceDefinitionDescription = Just $(lift description) , resourceDefinitionMimeType = Just "text/plain"- , resourceDefinitionTitle = Nothing+ , resourceDefinitionTitle = $(lift (optTitle opts))+ , resourceDefinitionIcons = $(lift (optIcons opts)) } |]-mkResourceDefWithDescription _ _ = fail "Unsupported constructor type for resources"+mkResourceDef _ _ = fail "Unsupported constructor type for resources" -- One alternative of the read handler: try this constructor, else fall -- through to the rest of the chain.@@ -546,17 +712,83 @@ -- -- > $(deriveToolHandlerWithDescription ''MyTool 'handleTool [("Constructor", "Description")]) deriveToolHandlerWithDescription :: Name -> Name -> [(String, String)] -> Q Exp-deriveToolHandlerWithDescription typeName handlerName descriptions = do+deriveToolHandlerWithDescription typeName handlerName descriptions =+ deriveToolHandlerGeneric typeName handlerName (optionsFromDescriptions descriptions) descriptions Nothing++-- | Derive tool handlers with per-constructor 'DefinitionOptions'+-- (description, title, icons, behavioral annotations, scoped argument+-- descriptions). Usage:+--+-- > $(deriveToolHandlerWithOptions ''MyTool 'handleTool+-- > [ ("Search", defaultDefinitionOptions+-- > { optDescription = Just "Search the catalog"+-- > , optToolAnnotations = Just defaultToolAnnotations { toolReadOnlyHint = Just True }+-- > })+-- > ])+deriveToolHandlerWithOptions :: Name -> Name -> [(String, DefinitionOptions)] -> Q Exp+deriveToolHandlerWithOptions typeName handlerName opts =+ deriveToolHandlerGeneric typeName handlerName (optionsFor opts) [] Nothing++-- | Derive tool handlers from a data type.+-- Usage:+--+-- > $(deriveToolHandler ''MyTool 'handleTool)+deriveToolHandler :: Name -> Name -> Q Exp+deriveToolHandler typeName handlerName =+ deriveToolHandlerWithDescription typeName handlerName []++-- | Derive tool handlers with typed, structured output: the third name is a+-- 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+-- serializes into @structuredContent@ — guaranteed to match the schema.+--+-- > handleTool :: ClientContext -> MyTool -> IO (ToolOutput WeatherReport)+-- > $(deriveToolHandlerWithOutput ''MyTool 'handleTool ''WeatherReport)+deriveToolHandlerWithOutput :: Name -> Name -> Name -> Q Exp+deriveToolHandlerWithOutput typeName handlerName outputName =+ deriveToolHandlerWithOutputDescription typeName handlerName outputName []++-- | 'deriveToolHandlerWithOutput' with custom descriptions. The+-- description list is shared with the output type: entries matching output+-- field names describe the @outputSchema@ properties.+deriveToolHandlerWithOutputDescription :: Name -> Name -> Name -> [(String, String)] -> Q Exp+deriveToolHandlerWithOutputDescription typeName handlerName outputName descriptions =+ deriveToolHandlerGeneric typeName handlerName (optionsFromDescriptions descriptions) descriptions (Just outputName)++-- | 'deriveToolHandlerWithOutput' with per-constructor+-- '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 =+ deriveToolHandlerGeneric typeName handlerName (optionsFor opts) [] (Just outputName)++deriveToolHandlerGeneric :: Name -> Name -> (String -> DefinitionOptions) -> [(String, String)] -> Maybe Name -> Q Exp+deriveToolHandlerGeneric typeName handlerName conOpts globalDescs outputName = do cons <- reifyCons typeName case cons of Just constructors -> do+ -- The output type's schema and its matching serializer, when typed+ -- output is requested. Its field descriptions come from an options+ -- entry keyed by the output type's own name, merged with the legacy+ -- global list.+ output <- traverse+ (\o -> do+ requireOutputRecord o+ let outDescs = fieldDescsFor (conOpts (nameBase o)) globalDescs+ (,) <$> mkSchemaShape outDescs (ConT o)+ <*> mkValueBuilder (ConT o))+ outputName+ -- Generate tool definitions- toolDefs <- traverse (mkToolDefWithDescription descriptions) constructors+ toolDefs <- traverse (mkToolDef conOpts globalDescs (fst <$> output)) constructors listHandlerExp <- [| \_ctx -> pure $(return $ ListE toolDefs) |] -- Generate call handler with cases- cases <- traverse (mkDispatchCase ToolArgs handlerName) constructors+ let style = maybe ToolArgs (ToolArgsOutput . snd) output+ cases <- traverse (mkDispatchCase style handlerName) constructors defaultCase <- [| pure $ Left $ UnknownTool $ "Unknown tool: " <> $(varE argNameN) |] let defaultMatch = Match WildP (NormalB defaultCase) [] let callHandlerExp = LamE [VarP ctxName, VarP argNameN, VarP argsName] $@@ -564,27 +796,24 @@ (map clauseToMatch cases ++ [defaultMatch]) return $ TupE [Just listHandlerExp, Just callHandlerExp]- Nothing -> fail $ "deriveToolHandlerWithDescription: " ++ show typeName ++ " is not a data type"---- | Derive tool handlers from a data type.--- Usage:------ > $(deriveToolHandler ''MyTool 'handleTool)-deriveToolHandler :: Name -> Name -> Q Exp-deriveToolHandler typeName handlerName =- deriveToolHandlerWithDescription typeName handlerName []+ Nothing -> fail $ "deriveToolHandler: " ++ show typeName ++ " is not a data type" -mkToolDefWithDescription :: [(String, String)] -> Con -> Q Exp-mkToolDefWithDescription descriptions con = do+mkToolDef :: (String -> DefinitionOptions) -> [(String, String)] -> Maybe Exp -> Con -> Q Exp+mkToolDef conOpts globalDescs outputShape con = do let name = conName con let sname = snakeName name- let description = descriptionFor descriptions (nameBase name) (nameBase name)+ let opts = conOpts (nameBase name)+ let description = fromMaybe (T.pack (nameBase name)) (optDescription opts) fields <- getConFields con- shapeExp <- mkObjectShape descriptions fields+ shapeExp <- mkObjectShape (fieldDescsFor opts globalDescs) fields [| ToolDefinition { toolDefinitionName = $(litE $ stringL sname)- , toolDefinitionDescription = $(litE $ stringL description)+ , toolDefinitionDescription = $(lift description) , toolDefinitionInputSchema = Schema Nothing $(return shapeExp)- , toolDefinitionOutputSchema = Nothing- , toolDefinitionTitle = Nothing+ , toolDefinitionOutputSchema = $(case outputShape of+ Just shape -> [| Just (Schema Nothing $(return shape)) |]+ Nothing -> [| Nothing |])+ , toolDefinitionTitle = $(lift (optTitle opts))+ , toolDefinitionAnnotations = $(lift (optToolAnnotations opts))+ , toolDefinitionIcons = $(lift (optIcons opts)) } |]
src/MCP/Server/Derive/Internal.hs view
@@ -34,11 +34,16 @@ -- * Resource template matching , matchTemplateSegments , templateField+ -- * Structured output+ , resolveToolOutput+ , omitNothing+ , canonicalJsonText ) where -import Data.Aeson (Value (..), encode)+import Data.Aeson (ToJSON (..), Value (..), encode) import qualified Data.Aeson.Key as Key import qualified Data.Aeson.KeyMap as KM+import Data.Aeson.Types (Pair) import qualified Data.ByteString.Lazy as BSL import Data.Foldable (toList) import Data.Map (Map)@@ -51,7 +56,9 @@ import Network.URI (unEscapeString) import Text.Read (readMaybe) -import MCP.Server.Types (Error (..))+import MCP.Server.Types (Content (..), Error (..),+ ToolOutput (..), ToolResult (..),+ toolError, toolResult) -- --------------------------------------------------------------------------- -- Text-based parsers (prompt arguments are strings per the MCP spec)@@ -213,3 +220,47 @@ templateField name p segs i = case drop i segs of (s:_) -> either (Left . InvalidParams . (("template field '" <> name <> "': ") <>)) Right (p s) [] -> Left $ InvalidParams $ "missing segment for template field '" <> name <> "'"++-- ---------------------------------------------------------------------------+-- 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+-- 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+resolveToolOutput serialize out = case out of+ ToolOutput o ->+ let v = serialize o+ in (toolResult [ContentText (canonicalJsonText v)]) { toolResultStructured = Just v }+ ToolOutputWith content o ->+ (toolResult content) { toolResultStructured = Just (serialize o) }+ 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)]
src/MCP/Server/Handlers.hs view
@@ -30,6 +30,7 @@ , errorMessageFromMcpError ) where +import Control.Monad (when) import Data.Aeson import qualified Data.Aeson.Key as Key import qualified Data.Aeson.KeyMap as KM@@ -82,10 +83,15 @@ -- | Look up an @io.modelcontextprotocol/\<key\>@ entry in a request's -- params @_meta@ object. metaLookup :: Text -> Maybe Value -> Maybe Value-metaLookup key params = do+metaLookup key params = plainMetaLookup ("io.modelcontextprotocol/" <> key) params++-- | Look up an unprefixed key in a request's params @_meta@ object+-- (e.g. @progressToken@, which the spec defines without a prefix).+plainMetaLookup :: Text -> Maybe Value -> Maybe Value+plainMetaLookup key params = do Object o <- params Object m <- KM.lookup "_meta" o- KM.lookup (Key.fromText ("io.modelcontextprotocol/" <> key)) m+ KM.lookup (Key.fromText key) m -- | The protocol revision a request declares in its params @_meta@ -- (modern, 2026-07-28+ clients). 'Nothing' for legacy requests.@@ -149,14 +155,20 @@ -- its params @_meta@ (2026-07-28+) is served statelessly with the modern -- result envelope; a request that does not is served exactly as before -- under the revision negotiated by @initialize@.+--+-- @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 -> NotificationSupport+ -> (JsonRpcNotification -> IO ()) -> McpServerHandlers -> ClientContext -> JsonRpcMessage -> IO (Maybe JsonRpcMessage)-handleMcpMessage serverInfo hints notifSupport handlers ctx0 (JsonRpcMessageRequest req) = do+handleMcpMessage serverInfo hints notifSupport emit handlers ctx0 (JsonRpcMessageRequest req) = do let params = requestParams req declaredVersion = metaProtocolVersion params case declaredVersion of@@ -172,6 +184,8 @@ { clientProtocolVersion = declaredVersion , clientInfo = metaLookup "clientInfo" params , clientCapabilities = metaLookup "clientCapabilities" params+ , reportProgress = progressReporter emit params+ , logToClient = clientLogger emit params } response <- case requestMethod req of -- Era purity: the modern revision has neither initialize (nothing to@@ -206,7 +220,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"@@ -214,8 +228,41 @@ hPutStrLn stderr $ "Received unknown notification: " ++ T.unpack (notificationMethod notif) return Nothing -handleMcpMessage _ _ _ _ _ (JsonRpcMessageResponse _) =+handleMcpMessage _ _ _ _ _ _ (JsonRpcMessageResponse _) = return Nothing++-- | The 'reportProgress' action for a request: emits+-- @notifications\/progress@ tagged with the request's @progressToken@, or+-- does nothing when no token was provided.+progressReporter :: (JsonRpcNotification -> IO ()) -> Maybe Value -> Double -> Maybe Double -> Maybe Text -> IO ()+progressReporter emit params = case plainMetaLookup "progressToken" params of+ Nothing -> \_ _ _ -> pure ()+ Just token -> \progress total message ->+ emit $ makeNotification "notifications/progress" $ Just $ object $+ [ "progressToken" .= token+ , "progress" .= progress+ ] ++ maybe [] (\t -> ["total" .= t]) total+ ++ maybe [] (\m -> ["message" .= m]) message++-- | The 'logToClient' action for a request: emits @notifications\/message@+-- at or above the request's declared @io.modelcontextprotocol/logLevel@,+-- and nothing at all when the request did not declare one (the spec+-- forbids it).+clientLogger :: (JsonRpcNotification -> IO ()) -> Maybe Value -> LogLevel -> Value -> IO ()+clientLogger emit params = case declared of+ Nothing -> \_ _ -> pure ()+ Just threshold -> \level payload ->+ when (level >= threshold) $+ emit $ makeNotification "notifications/message" $ Just $ object+ [ "level" .= level+ , "data" .= payload+ ]+ where+ declared = do+ v <- metaLookup "logLevel" params+ case fromJSON v of+ Success l -> Just l+ Error _ -> Nothing -- | Handle initialize request handleInitialize :: McpServerInfo -> NotificationSupport -> McpServerHandlers -> JsonRpcRequest -> IO JsonRpcResponse
src/MCP/Server/Transport/Http.hs view
@@ -6,17 +6,23 @@ HttpConfig(..) , transportRunHttp , defaultHttpConfig+ , mcpApplication -- * Request validation (exposed for testing) , BodyPeek(..) , peekBody , peekIsModern+ , wantsStreamingResponse , validateRequestHeaders , decodeSentinel ) where +import Control.Concurrent (threadDelay)+import Control.Concurrent.Async (race)+import Control.Concurrent.MVar (newMVar, withMVar) import Control.Concurrent.STM (atomically, check, orElse, readTChan, readTVar, registerDelay)+import Control.Exception (uninterruptibleMask_) import Control.Monad (forever, when) import Data.Aeson import qualified Data.Aeson.KeyMap as KM@@ -102,8 +108,35 @@ httpHasNotifications :: HttpConfig -> Bool httpHasNotifications = isJust . httpNotifications +-- | What notification delivery this HTTP server offers.+httpNotifSupport :: HttpConfig -> NotificationSupport+httpNotifSupport config = NotificationSupport+ { supportsLegacyPush = False -- no legacy HTTP delivery channel+ , supportsListen = httpHasNotifications config+ } +-- | Response headers for an SSE stream: no caching, and no proxy+-- buffering, so events reach the client immediately.+sseHeaders :: ResponseHeaders+sseHeaders =+ [ ("Content-Type", "text/event-stream")+ , ("Cache-Control", "no-cache")+ , ("X-Accel-Buffering", "no")+ ]++ -- | Transport-specific implementation for HTTP+--+-- A client closing the connection is its cancellation signal: for SSE+-- responses the handler task is cancelled once the disconnect surfaces (at+-- most one keep-alive interval later — the periodic keep-alive write is+-- what detects it). For single-JSON responses nothing is written until the+-- handler finishes, so a disconnect is only detected — and the request+-- thread only torn down — at the final write: mid-handler cancellation+-- applies to streaming requests, and clients wanting cancellable calls+-- should opt into streaming via a @progressToken@. Cancellation reaches+-- handler code as an asynchronous exception: handlers that acquire+-- resources should release them with 'Control.Exception.bracket'. transportRunHttp :: HttpConfig -> McpServerInfo -> McpServerHandlers -> IO () transportRunHttp config serverInfo handlers = do let settings = Warp.setHost (fromString $ httpHost config) $@@ -113,7 +146,17 @@ putStrLn $ "Starting MCP HTTP server on " ++ httpHost config ++ ":" ++ show (httpPort config) ++ httpEndpoint config Warp.runSettings settings (mcpApplication config serverInfo handlers) --- | WAI Application for MCP over HTTP+-- | The MCP endpoint as a plain WAI 'Wai.Application', for embedding into+-- an existing WAI stack (your own Warp settings, TLS, middleware, or a+-- larger router) instead of letting 'transportRunHttp' run its own server.+--+-- When embedding, 'httpPort' and 'httpHost' are ignored — they only+-- configure the server 'transportRunHttp' starts. Everything else applies+-- as usual: requests are served only on the 'httpEndpoint' path (any other+-- path gets 404, so mount accordingly or match the path in your router),+-- 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 -- Log the request@@ -213,6 +256,12 @@ | peekMethod peek == Just "subscriptions/listen" , Just src <- httpNotifications config -> handleSubscriptionsListen config src body respond+ -- Requests that opted into request-scoped notifications get an+ -- SSE response stream carrying them before the final response —+ -- 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 | otherwise -> do logVerbose config $ "Received POST body (" ++ show (BSL.length body) ++ " bytes): " ++ take 200 (show body) handleJsonRpcRequest config serverInfo handlers ctx modern body respond@@ -233,14 +282,43 @@ [("Content-Type", "text/plain"), ("Allow", "POST, OPTIONS")] "Method Not Allowed" --- | The parts of a JSON-RPC body that header validation needs.+-- | The parts of a JSON-RPC body that header validation and response-mode+-- selection need. data BodyPeek = BodyPeek { peekMethod :: Maybe Text , peekMetaVersion :: Maybe Text , peekName :: Maybe Text -- ^ params.name or params.uri , peekId :: RequestId+ , peekHasId :: Bool+ -- ^ Whether an @id@ member is present at all ('peekId' alone+ -- conflates an absent id — a notification — with an explicit null)+ , peekWantsStream :: Bool+ -- ^ The request opted into request-scoped notifications+ -- (@_meta.progressToken@ or @_meta.\"io.modelcontextprotocol/logLevel\"@),+ -- so the response must be an SSE stream that can carry them } +-- | Methods that run user handlers and may therefore emit request-scoped+-- notifications. Only these upgrade to an SSE response stream: everything+-- else keeps the JSON path so pre-dispatch outcomes retain their+-- spec-mandated HTTP statuses (404 for unknown methods, 202 for+-- notifications) — 'Wai.responseStream' would commit @200@ before dispatch+-- could veto it.+streamableMethods :: [Text]+streamableMethods =+ [ "tools/call", "tools/list"+ , "prompts/get", "prompts/list"+ , "resources/read", "resources/list", "resources/templates/list"+ , "completion/complete"+ ]++-- | Whether this request is answered with an SSE response stream.+wantsStreamingResponse :: BodyPeek -> Bool+wantsStreamingResponse peek =+ peekWantsStream peek+ && peekHasId peek+ && maybe False (`elem` streamableMethods) (peekMethod peek)+ -- | Whether the body declares a modern (2026-07-28+) request. peekIsModern :: BodyPeek -> Bool peekIsModern peek =@@ -262,8 +340,16 @@ , peekId = case KM.lookup "id" o of Just v -> either (const RequestIdNull) id (parseEither parseJSON v) Nothing -> RequestIdNull+ , peekHasId = KM.member "id" o+ , peekWantsStream = case KM.lookup "params" o of+ Just (Object p) -> case KM.lookup "_meta" p of+ Just (Object m) ->+ KM.member "progressToken" m+ || KM.member "io.modelcontextprotocol/logLevel" m+ _ -> False+ _ -> False }- _ -> BodyPeek Nothing Nothing Nothing RequestIdNull+ _ -> BodyPeek Nothing Nothing Nothing RequestIdNull False False -- | Validate the request-metadata headers against the body. 'Nothing' means -- the request may proceed; @'Just' err@ is answered with 400 and the given@@ -351,13 +437,7 @@ let subId = requestId req notifFilter = parseNotificationFilter (requestParams req) logVerbose config $ "Opening subscription stream " ++ show subId- respond $ Wai.responseStream- status200- [ ("Content-Type", "text/event-stream")- , ("Cache-Control", "no-cache")- , ("X-Accel-Buffering", "no")- ]- $ \write flush -> do+ respond $ Wai.responseStream status200 sseHeaders $ \write flush -> do chan <- atomically $ subscribeEvents src let sendJson v = do write $ B.lazyByteString $ "data: " <> encode v <> "\n\n"@@ -377,6 +457,53 @@ write $ B.byteString ": keep-alive\n\n" flush +-- | Serve a request that opted into request-scoped notifications: the+-- response is an SSE stream on which progress and client-log notifications+-- flow, followed by the final JSON-RPC response.+--+-- The handler runs in its own task, raced against a keep-alive writer that+-- 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 =+ 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+ streamLock <- newMVar ()+ let sendChunk b = withMVar streamLock $ \_ -> uninterruptibleMask_ $ do+ write b+ flush+ sendEvent v = sendChunk $ B.lazyByteString $ "data: " <> encode v <> "\n\n"+ emit n = sendEvent (toJSON (n :: JsonRpcNotification))+ case eitherDecode body of+ Left err -> do+ hPutStrLn stderr $ "JSON parse error: " ++ err+ sendEvent $ toJSON $ makeErrorResponse RequestIdNull $ JsonRpcError+ { errorCode = -32700, errorMessage = "Parse error", errorData = Nothing }+ Right jsonValue -> case parseJsonRpcMessage jsonValue of+ Left err -> do+ hPutStrLn stderr $ "JSON-RPC parse error: " ++ err+ sendEvent $ toJSON $ makeErrorResponse RequestIdNull $ JsonRpcError+ { errorCode = -32600, errorMessage = "Invalid Request", errorData = Nothing }+ Right message -> do+ logVerbose config $ "Processing streaming HTTP message: " ++ show (getMessageSummary message)+ -- 5s: short enough that an abandoned handler is cancelled+ -- promptly (a write to a closed connection is what surfaces the+ -- disconnect), long enough to stay negligible on the wire+ let keepAlive :: IO ()+ keepAlive = forever $ do+ threadDelay 5000000+ sendChunk $ B.byteString ": keep-alive\n\n"+ outcome <- race+ (handleMcpMessage serverInfo (httpCacheHints config)+ (httpNotifSupport config) emit handlers ctx message)+ keepAlive+ case outcome of+ Left (Just responseMsg) -> sendEvent (encodeJsonRpcMessage responseMsg)+ Left Nothing -> pure ()+ 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@@ -411,11 +538,10 @@ Right message -> do logVerbose config $ "Processing HTTP message: " ++ show (getMessageSummary message)- let notifSupport = NotificationSupport- { supportsLegacyPush = False -- no legacy HTTP delivery channel- , supportsListen = httpHasNotifications config- }- maybeResponse <- handleMcpMessage serverInfo (httpCacheHints config) notifSupport handlers ctx message+ -- Single-JSON responses have no channel for request-scoped+ -- notifications; requests that want them take the streaming path.+ maybeResponse <- handleMcpMessage serverInfo (httpCacheHints config)+ (httpNotifSupport config) (\_ -> pure ()) handlers ctx message case maybeResponse of Just responseMsg -> do
src/MCP/Server/Transport/Stdio.hs view
@@ -10,9 +10,12 @@ ) where import Control.Concurrent (ThreadId, forkIO, killThread)-import Control.Concurrent.MVar (modifyMVar_, newMVar, readMVar,+import Control.Concurrent.Async (Async, async, cancel)+import Control.Concurrent.MVar (modifyMVar_, newEmptyMVar, newMVar,+ putMVar, readMVar, takeMVar, withMVar) import Control.Concurrent.STM (atomically, readTChan)+import Control.Exception (finally, uninterruptibleMask_) import Control.Monad (forever, unless, when) import Data.Aeson import qualified Data.Aeson.KeyMap as KM@@ -61,23 +64,39 @@ transportRunStdio = transportRunStdioWithConfig defaultStdioConfig -- | Run the STDIO transport with the given configuration.+--+-- Each request is served in its own task, so a @notifications/cancelled@+-- naming its id can interrupt it mid-flight (after which nothing further is+-- written for that id). Cancellation reaches handler code as an+-- asynchronous exception: handlers that acquire resources should release+-- them with 'Control.Exception.bracket'.+--+-- This also means requests run /concurrently/ (before 0.2.0.1 the stdio+-- transport processed them strictly sequentially): handlers touching+-- shared mutable state must synchronize, as was already required of+-- handlers used with the HTTP transport. transportRunStdioWithConfig :: StdioConfig -> McpServerInfo -> McpServerHandlers -> IO () transportRunStdioWithConfig config serverInfo handlers = do -- Ensure UTF-8 encoding for all handles hSetEncoding stderr utf8 hSetEncoding stdout utf8 - -- Subscription threads and the main loop share stdout: one line at a time.+ -- Subscription threads, request tasks and the main loop share stdout:+ -- one line at a time. writeLock <- newMVar () -- Active subscriptions_listen streams, by their request id subsVar <- newMVar ([] :: [(RequestId, ThreadId)])+ -- In-flight request tasks, by request id (cancellable)+ inflightVar <- newMVar ([] :: [(RequestId, Async ())]) -- Whether a legacy client has completed initialize (gates legacy pushes) legacyReady <- newIORef False let logLine = TIO.hPutStrLn stderr logVerbose msg = when (stdioVerbose config) $ logLine msg - sendRaw bytes = withMVar writeLock $ \_ -> do+ -- Writes are uninterruptible so a cancellation arriving mid-write+ -- cannot corrupt the message channel with a half line+ sendRaw bytes = withMVar writeLock $ \_ -> uninterruptibleMask_ $ do TIO.putStrLn $ TE.decodeUtf8 $ BSL.toStrict bytes hFlush stdout sendMessage msg = sendRaw $ encode $ encodeJsonRpcMessage msg@@ -136,6 +155,28 @@ sendResponse $ closureResponse serverInfo subId) subs + -- Cancel an in-flight request task. 'cancel' waits for the task to+ -- finish, so once this returns nothing further is written for that id.+ cancelInflight cancelledId = do+ inflight <- readMVar inflightVar+ case lookup cancelledId inflight of+ Nothing -> pure False+ Just task -> do+ cancel task+ logLine $ "Cancelled request " <> T.pack (show cancelledId)+ pure True++ dispatchMessage message = do+ -- Request-scoped notifications (progress, client logs) share the+ -- locked stdout channel, interleaved before the response+ response <- handleMcpMessage serverInfo (stdioCacheHints config) notifSupport sendNotification handlers anonymousContext message+ case response of+ Just responseMsg -> do+ logLine $ "Sending response for: " <> T.pack (show (getMessageSummary message))+ sendMessage responseMsg+ Nothing ->+ logLine $ "No response needed for: " <> T.pack (show (getMessageSummary message))+ handleParsed message = case message of -- subscriptions/listen is transport-level: the stream outlives the -- request. Only intercept when a source is configured and the@@ -147,15 +188,33 @@ , maybe True (`elem` modernVersions) (metaProtocolVersion (requestParams req)) -> openSubscription src req - -- notifications/cancelled referencing an open subscription tears it- -- down (no response, per the cancellation rules)+ -- Every other request runs in its own task so a later+ -- notifications/cancelled can interrupt it while the read loop keeps+ -- serving. The task is registered before its body starts (the gate)+ -- so cancellation can never race registration; it deregisters itself+ -- on any exit, including cancellation.+ | otherwise -> do+ let rid = requestId req+ gate <- newEmptyMVar+ task <- async $ do+ takeMVar gate+ dispatchMessage message+ `finally` modifyMVar_ inflightVar (pure . filter ((/= rid) . fst))+ modifyMVar_ inflightVar (pure . ((rid, task) :))+ putMVar gate ()++ -- notifications/cancelled tears down the referenced subscription or+ -- in-flight request (no response either way, per the cancellation+ -- rules; unknown ids are ignored as the spec requires) JsonRpcMessageNotification notif | notificationMethod notif == "notifications/cancelled" , Just cancelledId <- cancelledRequestId (notificationParams notif) -> do wasSub <- cancelSubscription cancelledId- unless wasSub $- logLine $ "Ignoring cancellation for unknown request " <> T.pack (show cancelledId)+ unless wasSub $ do+ wasInflight <- cancelInflight cancelledId+ unless wasInflight $+ logLine $ "Ignoring cancellation for unknown request " <> T.pack (show cancelledId) _ -> do -- The client's initialized notification is the legacy ready signal:@@ -167,18 +226,14 @@ | notificationMethod n == "notifications/initialized" -> writeIORef legacyReady True _ -> pure ()- response <- handleMcpMessage serverInfo (stdioCacheHints config) notifSupport handlers anonymousContext message- case response of- Just responseMsg -> do- logLine $ "Sending response for: " <> T.pack (show (getMessageSummary message))- sendMessage responseMsg- Nothing ->- logLine $ "No response needed for: " <> T.pack (show (getMessageSummary message))+ dispatchMessage message loop = do eof <- hIsEOF stdin if eof then do+ inflight <- readMVar inflightVar+ mapM_ (cancel . snd) inflight closeAllSubscriptions logLine "stdin closed - shutting down" else do
src/MCP/Server/Types.hs view
@@ -1,4 +1,5 @@ {-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE DeriveLift #-} {-# LANGUAGE OverloadedStrings #-} module MCP.Server.Types@@ -8,11 +9,21 @@ , ContentAudioData(..) , ResourceContent(..) + -- * Metadata Types+ , Annotations(..)+ , defaultAnnotations+ , ToolAnnotations(..)+ , defaultToolAnnotations+ , Icon(..)+ , icon+ , LogLevel(..)+ -- * Handler Result Types , ToolResult(..) , toolResult , toolError , ToToolResult(..)+ , ToolOutput(..) , PromptResult(..) , ToPromptResult(..) , PromptMessage(..)@@ -33,9 +44,13 @@ -- * Definition Types , PromptDefinition(..)+ , mkPromptDefinition , ResourceDefinition(..)+ , mkResourceDefinition , ResourceTemplateDefinition(..)+ , mkResourceTemplateDefinition , ToolDefinition(..)+ , mkToolDefinition , ArgumentDefinition(..) -- * Completion Types@@ -86,6 +101,7 @@ import Data.Text (Text) import qualified Data.Text as T import GHC.Generics (Generic)+import Language.Haskell.TH.Syntax (Lift) import Network.URI (URI, parseURI) type PromptName = Text@@ -102,6 +118,9 @@ -- ^ A resource embedded into the result, carrying its full contents | ContentResourceLink ResourceDefinition -- ^ A reference to a resource the client can read separately+ | ContentAnnotated Annotations Content+ -- ^ A content block carrying 'Annotations'; the annotations are merged+ -- into the inner block's JSON object. Do not nest. deriving (Show, Eq, Generic) instance ToJSON Content where@@ -127,24 +146,62 @@ case toJSON def of Object o -> Object (KM.insert "type" (String "resource_link") o) other -> other+ toJSON (ContentAnnotated anns inner) =+ case toJSON inner of+ Object o -> Object (KM.insert "annotations" (toJSON anns) o)+ other -> other instance FromJSON Content where parseJSON = withObject "Content" $ \o -> do- contentType <- o .: "type" :: Parser Text- case contentType of- "text" -> ContentText <$> o .: "text"- "image" -> do- imgData <- o .: "data"- mimeType <- o .: "mimeType"- return $ ContentImage $ ContentImageData imgData mimeType- "audio" -> do- audioData <- o .: "data"- mimeType <- o .: "mimeType"- return $ ContentAudio $ ContentAudioData audioData mimeType- "resource" -> ContentEmbeddedResource <$> o .: "resource"- "resource_link" -> ContentResourceLink <$> parseJSON (Object o)- _ -> fail $ "Unknown content type: " ++ T.unpack contentType+ inner <- parseInner o+ case KM.lookup "annotations" o of+ Just anns -> ContentAnnotated <$> parseJSON anns <*> pure inner+ Nothing -> pure inner+ where+ parseInner o = do+ contentType <- o .: "type" :: Parser Text+ case contentType of+ "text" -> ContentText <$> o .: "text"+ "image" -> do+ imgData <- o .: "data"+ mimeType <- o .: "mimeType"+ return $ ContentImage $ ContentImageData imgData mimeType+ "audio" -> do+ audioData <- o .: "data"+ mimeType <- o .: "mimeType"+ return $ ContentAudio $ ContentAudioData audioData mimeType+ "resource" -> ContentEmbeddedResource <$> o .: "resource"+ "resource_link" -> ContentResourceLink <$> parseJSON (Object o)+ _ -> fail $ "Unknown content type: " ++ T.unpack contentType +-- | Optional hints on content blocks: who the content is for, how important+-- it is, and when it last changed (2025-03-26+).+data Annotations = Annotations+ { annotationsAudience :: [MessageRole] -- ^ Intended audience(s); omitted when empty+ , annotationsPriority :: Maybe Double -- ^ 0.0 (optional) … 1.0 (most important)+ , annotationsLastModified :: Maybe Text -- ^ ISO 8601 timestamp+ } deriving (Show, Eq, Generic)++-- | No annotations set; record-update the ones you need.+defaultAnnotations :: Annotations+defaultAnnotations = Annotations+ { annotationsAudience = []+ , annotationsPriority = Nothing+ , annotationsLastModified = Nothing+ }++instance ToJSON Annotations where+ toJSON anns = object $+ (if null (annotationsAudience anns) then [] else ["audience" .= annotationsAudience anns])+ ++ maybe [] (\p -> ["priority" .= p]) (annotationsPriority anns)+ ++ maybe [] (\lm -> ["lastModified" .= lm]) (annotationsLastModified anns)++instance FromJSON Annotations where+ parseJSON = withObject "Annotations" $ \o -> Annotations+ <$> o .:? "audience" .!= []+ <*> o .:? "priority"+ <*> o .:? "lastModified"+ data ContentImageData = ContentImageData { contentImageData :: Text -- ^ base64-encoded image data , contentImageMimeType :: Text@@ -204,6 +261,99 @@ toJSON RoleUser = "user" toJSON RoleAssistant = "assistant" +instance FromJSON MessageRole where+ parseJSON = withText "MessageRole" $ \t -> case t of+ "user" -> pure RoleUser+ "assistant" -> pure RoleAssistant+ _ -> fail $ "Unknown role: " ++ T.unpack t++-- | Behavioral hints on a tool (2025-03-26+): clients use these for+-- permission UX (e.g. auto-approving read-only tools). All hints are+-- advisory and default to unset.+data ToolAnnotations = ToolAnnotations+ { toolAnnotationsTitle :: Maybe Text+ , toolReadOnlyHint :: Maybe Bool -- ^ The tool does not modify its environment+ , toolDestructiveHint :: Maybe Bool -- ^ The tool may perform destructive updates+ , toolIdempotentHint :: Maybe Bool -- ^ Repeated calls with the same arguments have no additional effect+ , toolOpenWorldHint :: Maybe Bool -- ^ The tool interacts with an open world of external entities+ } deriving (Show, Eq, Generic, Lift)++-- | No hints set; record-update the ones you need.+defaultToolAnnotations :: ToolAnnotations+defaultToolAnnotations = ToolAnnotations+ { toolAnnotationsTitle = Nothing+ , toolReadOnlyHint = Nothing+ , toolDestructiveHint = Nothing+ , toolIdempotentHint = Nothing+ , toolOpenWorldHint = Nothing+ }++instance ToJSON ToolAnnotations where+ toJSON anns = object $ concat+ [ maybe [] (\t -> ["title" .= t]) (toolAnnotationsTitle anns)+ , maybe [] (\b -> ["readOnlyHint" .= b]) (toolReadOnlyHint anns)+ , maybe [] (\b -> ["destructiveHint" .= b]) (toolDestructiveHint anns)+ , maybe [] (\b -> ["idempotentHint" .= b]) (toolIdempotentHint anns)+ , maybe [] (\b -> ["openWorldHint" .= b]) (toolOpenWorldHint anns)+ ]++-- | An icon a client may display for a tool, prompt or resource+-- (2025-11-25+).+data Icon = Icon+ { iconSrc :: Text -- ^ URI of the icon+ , iconMimeType :: Maybe Text+ , iconSizes :: [Text] -- ^ e.g. @[\"48x48\"]@; omitted when empty+ } deriving (Show, Eq, Generic, Lift)++-- | An icon with just a source URI.+icon :: Text -> Icon+icon src = Icon { iconSrc = src, iconMimeType = Nothing, iconSizes = [] }++-- | RFC 5424 log severities, least to most severe; the 'Ord' instance+-- follows severity, so @level >= threshold@ is the filtering test.+data LogLevel+ = LogDebug+ | LogInfo+ | LogNotice+ | LogWarning+ | LogError+ | LogCritical+ | LogAlert+ | LogEmergency+ deriving (Show, Eq, Ord, Enum, Bounded, Generic)++logLevelText :: LogLevel -> Text+logLevelText l = case l of+ LogDebug -> "debug"+ LogInfo -> "info"+ LogNotice -> "notice"+ LogWarning -> "warning"+ LogError -> "error"+ LogCritical -> "critical"+ LogAlert -> "alert"+ LogEmergency -> "emergency"++instance ToJSON LogLevel where+ toJSON = String . logLevelText++instance FromJSON LogLevel where+ parseJSON = withText "LogLevel" $ \t ->+ case lookup t [(logLevelText l, l) | l <- [minBound .. maxBound]] of+ Just l -> pure l+ Nothing -> fail $ "Unknown log level: " ++ T.unpack t++instance ToJSON Icon where+ toJSON i = object $+ [ "src" .= iconSrc i ]+ ++ maybe [] (\m -> ["mimeType" .= m]) (iconMimeType i)+ ++ (if null (iconSizes i) then [] else ["sizes" .= iconSizes i])++instance FromJSON Icon where+ parseJSON = withObject "Icon" $ \o -> Icon+ <$> o .: "src"+ <*> o .:? "mimeType"+ <*> o .:? "sizes" .!= []+ -- | Prompt message data PromptMessage = PromptMessage { promptMessageRole :: MessageRole@@ -273,6 +423,25 @@ firstJust :: [Maybe a] -> Maybe a firstJust = listToMaybe . catMaybes +-- | What a tool handler derived with+-- 'MCP.Server.Derive.deriveToolHandlerWithOutput' returns: a typed value+-- that the derivation serializes into @structuredContent@ (matching the+-- derived @outputSchema@), or one of the escape hatches.+data ToolOutput o+ = ToolOutput o+ -- ^ A structured value. Per the spec's recommendation, the serialized+ -- JSON is also returned as a text content block for clients that+ -- predate structured output.+ | ToolOutputWith [Content] o+ -- ^ A structured value with caller-supplied content blocks (no+ -- automatic text block).+ | ToolOutputError Text+ -- ^ An execution failure, reported with @isError@ (see 'toolError').+ | ToolOutputRaw ToolResult+ -- ^ Full control: return this 'ToolResult' as-is, with no structured+ -- content implied.+ deriving (Show, Eq)+ -- | The full result of a prompts/get request: an optional description and -- a conversation of one or more messages. data PromptResult = PromptResult@@ -396,14 +565,28 @@ , promptDefinitionDescription :: Text , promptDefinitionArguments :: [ArgumentDefinition] , promptDefinitionTitle :: Maybe Text -- New title field for human-friendly display+ , promptDefinitionIcons :: [Icon] -- ^ omitted when empty (2025-11-25+) } deriving (Show, Eq, Generic) +-- | A prompt definition with only the required fields set; record-update+-- the optional ones (constructing 'PromptDefinition' directly breaks when+-- fields are added).+mkPromptDefinition :: Text -> Text -> [ArgumentDefinition] -> PromptDefinition+mkPromptDefinition name description args = PromptDefinition+ { promptDefinitionName = name+ , promptDefinitionDescription = description+ , promptDefinitionArguments = args+ , promptDefinitionTitle = Nothing+ , promptDefinitionIcons = []+ }+ instance ToJSON PromptDefinition where toJSON def = object $ [ "name" .= promptDefinitionName def , "description" .= promptDefinitionDescription def , "arguments" .= promptDefinitionArguments def ] ++ maybe [] (\t -> ["title" .= t]) (promptDefinitionTitle def)+ ++ (if null (promptDefinitionIcons def) then [] else ["icons" .= promptDefinitionIcons def]) -- | Resource definition (2025-06-18 enhanced) data ResourceDefinition = ResourceDefinition@@ -412,8 +595,21 @@ , resourceDefinitionDescription :: Maybe Text , resourceDefinitionMimeType :: Maybe Text , resourceDefinitionTitle :: Maybe Text -- New title field for human-friendly display+ , resourceDefinitionIcons :: [Icon] -- ^ omitted when empty (2025-11-25+) } deriving (Show, Eq, Generic) +-- | A resource definition with only the required fields set; record-update+-- the optional ones.+mkResourceDefinition :: Text -> Text -> ResourceDefinition+mkResourceDefinition uri name = ResourceDefinition+ { resourceDefinitionURI = uri+ , resourceDefinitionName = name+ , resourceDefinitionDescription = Nothing+ , resourceDefinitionMimeType = Nothing+ , resourceDefinitionTitle = Nothing+ , resourceDefinitionIcons = []+ }+ instance ToJSON ResourceDefinition where toJSON def = object $ [ "uri" .= resourceDefinitionURI def@@ -421,7 +617,8 @@ ] ++ maybe [] (\d -> ["description" .= d]) (resourceDefinitionDescription def) ++ maybe [] (\m -> ["mimeType" .= m]) (resourceDefinitionMimeType def) ++- maybe [] (\t -> ["title" .= t]) (resourceDefinitionTitle def)+ maybe [] (\t -> ["title" .= t]) (resourceDefinitionTitle def) +++ (if null (resourceDefinitionIcons def) then [] else ["icons" .= resourceDefinitionIcons def]) instance FromJSON ResourceDefinition where parseJSON = withObject "ResourceDefinition" $ \o -> ResourceDefinition@@ -430,6 +627,7 @@ <*> o .:? "description" <*> o .:? "mimeType" <*> o .:? "title"+ <*> o .:? "icons" .!= [] -- | Resource template definition: a parameterized resource identified by an -- RFC 6570 URI template.@@ -439,8 +637,21 @@ , resourceTemplateDescription :: Maybe Text , resourceTemplateMimeType :: Maybe Text , resourceTemplateTitle :: Maybe Text+ , resourceTemplateIcons :: [Icon] -- ^ omitted when empty (2025-11-25+) } deriving (Show, Eq, Generic) +-- | A template definition with only the required fields set; record-update+-- the optional ones.+mkResourceTemplateDefinition :: Text -> Text -> ResourceTemplateDefinition+mkResourceTemplateDefinition uriTemplate name = ResourceTemplateDefinition+ { resourceTemplateURITemplate = uriTemplate+ , resourceTemplateName = name+ , resourceTemplateDescription = Nothing+ , resourceTemplateMimeType = Nothing+ , resourceTemplateTitle = Nothing+ , resourceTemplateIcons = []+ }+ instance ToJSON ResourceTemplateDefinition where toJSON def = object $ [ "uriTemplate" .= resourceTemplateURITemplate def@@ -448,7 +659,8 @@ ] ++ maybe [] (\d -> ["description" .= d]) (resourceTemplateDescription def) ++ maybe [] (\m -> ["mimeType" .= m]) (resourceTemplateMimeType def) ++- maybe [] (\t -> ["title" .= t]) (resourceTemplateTitle def)+ maybe [] (\t -> ["title" .= t]) (resourceTemplateTitle def) +++ (if null (resourceTemplateIcons def) then [] else ["icons" .= resourceTemplateIcons def]) -- | What a completion request is completing an argument for. data CompletionRef@@ -478,8 +690,23 @@ , toolDefinitionInputSchema :: Schema , toolDefinitionOutputSchema :: Maybe Schema , toolDefinitionTitle :: Maybe Text -- New title field for human-friendly display+ , toolDefinitionAnnotations :: Maybe ToolAnnotations -- ^ behavioral hints (2025-03-26+)+ , toolDefinitionIcons :: [Icon] -- ^ omitted when empty (2025-11-25+) } deriving (Show, Eq, Generic) +-- | A tool definition with only the required fields set; record-update the+-- optional ones.+mkToolDefinition :: Text -> Text -> Schema -> ToolDefinition+mkToolDefinition name description inputSchema = ToolDefinition+ { toolDefinitionName = name+ , toolDefinitionDescription = description+ , toolDefinitionInputSchema = inputSchema+ , toolDefinitionOutputSchema = Nothing+ , toolDefinitionTitle = Nothing+ , toolDefinitionAnnotations = Nothing+ , toolDefinitionIcons = []+ }+ instance ToJSON ToolDefinition where toJSON def = object $ [ "name" .= toolDefinitionName def@@ -487,6 +714,8 @@ , "inputSchema" .= toolDefinitionInputSchema def ] ++ maybe [] (\s -> ["outputSchema" .= s]) (toolDefinitionOutputSchema def) ++ maybe [] (\t -> ["title" .= t]) (toolDefinitionTitle def)+ ++ maybe [] (\a -> ["annotations" .= a]) (toolDefinitionAnnotations def)+ ++ (if null (toolDefinitionIcons def) then [] else ["icons" .= toolDefinitionIcons def]) -- | Argument definition for prompts data ArgumentDefinition = ArgumentDefinition@@ -590,7 +819,18 @@ , clientCapabilities :: Maybe Value -- ^ The client's declared capabilities from request @_meta@ -- (modern clients).- } deriving (Show, Eq)+ , reportProgress :: Double -> Maybe Double -> Maybe Text -> IO ()+ -- ^ Report progress for this request: current progress (which must+ -- increase with each call), an optional total, and an optional+ -- human-readable message. A no-op when the request carried no+ -- @progressToken@, so handlers can call it unconditionally. Stop+ -- reporting once the handler returns; avoid flooding.+ , logToClient :: LogLevel -> Value -> IO ()+ -- ^ Send a log message to this request's client+ -- (@notifications\/message@). A no-op unless the request declared+ -- @io.modelcontextprotocol\/logLevel@ (the spec forbids emitting+ -- otherwise); messages below the declared level are dropped.+ } -- | A context carrying no transport- or request-level information: what -- handlers see for legacy stdio requests.@@ -601,6 +841,8 @@ , clientProtocolVersion = Nothing , clientInfo = Nothing , clientCapabilities = Nothing+ , reportProgress = \_ _ _ -> pure ()+ , logToClient = \_ _ -> pure () } -- | Cacheability hints stamped onto modern (2026-07-28+) list/read results,
test/HspecMain.hs view
@@ -7,9 +7,13 @@ import qualified Spec.BasicDerivation import qualified Spec.SchemaValidation import qualified Spec.AdvancedDerivation+import qualified Spec.Cancellation import qualified Spec.UnicodeHandling+import qualified Spec.DefinitionMetadata+import qualified Spec.DerivedOutput import qualified Spec.GoldenWire import qualified Spec.ModernEra+import qualified Spec.Progress import qualified Spec.ProtocolVersionNegotiation import qualified Spec.Subscriptions import qualified Spec.TemplatesCompletions@@ -23,9 +27,13 @@ Spec.BasicDerivation.spec Spec.SchemaValidation.spec Spec.AdvancedDerivation.spec+ Spec.Cancellation.spec Spec.UnicodeHandling.spec+ Spec.DefinitionMetadata.spec+ Spec.DerivedOutput.spec Spec.GoldenWire.spec Spec.ModernEra.spec+ Spec.Progress.spec Spec.ProtocolVersionNegotiation.spec Spec.Subscriptions.spec Spec.TemplatesCompletions.spec
+ test/Spec/Cancellation.hs view
@@ -0,0 +1,80 @@+{-# LANGUAGE OverloadedStrings #-}++-- | Coverage for the cancellation contract (ADR 0008): a request task+-- cancelled mid-handler stops emitting, never produces a response, and+-- releases bracket-acquired resources. The transports build on exactly+-- this shape (an 'async' around 'handleMcpMessage' that 'cancel'+-- interrupts), so the properties verified here are the ones the wire+-- behavior depends on.+module Spec.Cancellation (spec) where++import Control.Concurrent (threadDelay)+import Control.Concurrent.Async (async, cancel, waitCatch)+import Control.Concurrent.MVar (newEmptyMVar, putMVar, takeMVar)+import Control.Exception (bracket_)+import Data.Aeson+import Data.IORef+import Data.Text (Text)+import MCP.Server+import MCP.Server.Handlers (handleMcpMessage)+import MCP.Server.JsonRpc+import Test.Hspec++-- 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]+runCancelled handlerBody = do+ sink <- newIORef []+ started <- newEmptyMVar+ responded <- newIORef False+ let handlers = noHandlers+ { tools = Just+ ( \_ -> pure []+ , \ctx _ _ -> do+ reportProgress ctx 0 (Just 1) Nothing+ putMVar started ()+ handlerBody ctx+ pure $ Right $ toToolResult ("done" :: Text)+ )+ }+ params = object+ [ "name" .= ("slow" :: Text)+ , "arguments" .= object []+ , "_meta" .= object ["progressToken" .= ("t" :: Text)]+ ]+ task <- async $ do+ resp <- handleMcpMessage (McpServerInfo "T" "1" "") defaultCacheHints+ noNotificationSupport (\n -> modifyIORef' sink (++ [n]))+ handlers anonymousContext+ (JsonRpcMessageRequest (JsonRpcRequest "2.0" (RequestIdNumber 1) "tools/call" (Just params)))+ case resp of+ Just _ -> writeIORef responded True+ Nothing -> pure ()+ takeMVar started+ cancel task -- waits for the task to finish+ _ <- waitCatch task+ readIORef responded `shouldReturn` False+ readIORef sink++spec :: Spec+spec = describe "Cancellation contract" $ do++ it "a cancelled handler stops emitting and never yields a response" $ do+ lateEmit <- newIORef False+ ns <- runCancelled $ \ctx -> do+ threadDelay 5000000+ writeIORef lateEmit True+ reportProgress ctx 1 (Just 1) Nothing+ -- only the pre-cancellation progress made it out+ map notificationMethod ns `shouldBe` ["notifications/progress"]+ readIORef lateEmit `shouldReturn` False++ it "bracket releases handler resources on cancellation" $ do+ acquired <- newIORef False+ released <- newIORef False+ _ <- runCancelled $ \_ ->+ bracket_ (writeIORef acquired True) (writeIORef released True) $+ threadDelay 5000000+ readIORef acquired `shouldReturn` True+ readIORef released `shouldReturn` True
+ test/Spec/DefinitionMetadata.hs view
@@ -0,0 +1,105 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TemplateHaskell #-}++-- | Coverage for definition metadata (ADR 0006): tool annotations, icons,+-- content annotations, and the options-based derive customization.+module Spec.DefinitionMetadata (spec) where++import Data.Aeson+import Data.Text (Text)+import MCP.Server+import MCP.Server.Derive+import Test.Hspec+import TestTypes++annotatedToolHandlers :: (ToolListHandler, ToolCallHandler)+annotatedToolHandlers = $(deriveToolHandlerWithOptions ''TestTool 'handleTestTool+ [ ("Echo", defaultDefinitionOptions+ { optDescription = Just "Echoes the input"+ , optTitle = Just "Echo"+ , optIcons = [icon "https://example.com/echo.png"]+ , optToolAnnotations = Just defaultToolAnnotations+ { toolReadOnlyHint = Just True+ , toolIdempotentHint = Just True+ }+ , optFieldDescriptions = [("text", "What to echo")]+ })+ , ("Calculate", defaultDefinitionOptions+ { optFieldDescriptions = [("x", "Calculate's first operand")]+ })+ ])++toolNamed :: Text -> [ToolDefinition] -> ToolDefinition+toolNamed n defs = case [d | d <- defs, toolDefinitionName d == n] of+ (d:_) -> d+ [] -> error $ "tool not found: " ++ show n++propDescription :: Text -> ToolDefinition -> Maybe Text+propDescription name def = case schemaShape (toolDefinitionInputSchema def) of+ SchemaObject props _ -> lookup name props >>= schemaDescription+ _ -> Nothing++spec :: Spec+spec = describe "Definition metadata" $ do++ describe "JSON shapes" $ do+ it "serializes tool annotations with only the set hints" $ do+ toJSON defaultToolAnnotations { toolReadOnlyHint = Just True } `shouldBe`+ object ["readOnlyHint" .= True]++ it "serializes icons, omitting empty optional fields" $ do+ toJSON (icon "https://example.com/i.png") `shouldBe`+ object ["src" .= ("https://example.com/i.png" :: Text)]+ toJSON (Icon "u" (Just "image/png") ["48x48"]) `shouldBe`+ object ["src" .= ("u" :: Text), "mimeType" .= ("image/png" :: Text), "sizes" .= (["48x48"] :: [Text])]++ it "merges content annotations into the inner block" $ do+ let anns = defaultAnnotations { annotationsAudience = [RoleUser], annotationsPriority = Just 0.8 }+ toJSON (ContentAnnotated anns (ContentText "hi")) `shouldBe`+ object [ "type" .= ("text" :: Text), "text" .= ("hi" :: Text)+ , "annotations" .= object ["audience" .= (["user"] :: [Text]), "priority" .= (0.8 :: Double)]+ ]++ it "round-trips annotated content through FromJSON" $ do+ let anns = defaultAnnotations { annotationsAudience = [RoleAssistant] }+ c = ContentAnnotated anns (ContentText "hello")+ decode (encode c) `shouldBe` Just c++ it "round-trips resource links carrying icons" $ do+ let def = (mkResourceDefinition "resource://x" "x")+ { resourceDefinitionIcons = [Icon "https://example.com/x.png" (Just "image/png") ["48x48"]] }+ c = ContentResourceLink def+ decode (encode c) `shouldBe` Just c++ describe "Options-based derivation" $ do+ it "carries description, title, icons and annotations on the definition" $ do+ defs <- fst annotatedToolHandlers anonCtx+ let echoDef = toolNamed "echo" defs+ toolDefinitionDescription echoDef `shouldBe` "Echoes the input"+ toolDefinitionTitle echoDef `shouldBe` Just "Echo"+ toolDefinitionIcons echoDef `shouldBe` [icon "https://example.com/echo.png"]+ toolDefinitionAnnotations echoDef `shouldBe` Just defaultToolAnnotations+ { toolReadOnlyHint = Just True+ , toolIdempotentHint = Just True+ }++ it "leaves uncustomized constructors bare (constructor-name description)" $ do+ defs <- fst annotatedToolHandlers anonCtx+ let toggleDef = toolNamed "toggle" defs+ toolDefinitionDescription toggleDef `shouldBe` "Toggle"+ toolDefinitionAnnotations toggleDef `shouldBe` Nothing+ toolDefinitionIcons toggleDef `shouldBe` []++ it "scopes field descriptions to their constructor" $ do+ defs <- fst annotatedToolHandlers anonCtx+ -- 'text' described only on Echo; 'x' described only on Calculate+ propDescription "text" (toolNamed "echo" defs) `shouldBe` Just "What to echo"+ propDescription "x" (toolNamed "calculate" defs) `shouldBe` Just "Calculate's first operand"+ -- Echo's options must not leak onto Calculate's same-named args+ propDescription "operation" (toolNamed "calculate" defs) `shouldBe` Just "operation"++ it "still dispatches calls unchanged" $ do+ result <- snd annotatedToolHandlers anonCtx "echo" mempty+ case result of+ Left (MissingRequiredParams msg) -> show msg `shouldContain` "text"+ other -> expectationFailure $ "expected missing-params error, got: " ++ show other
+ test/Spec/DerivedOutput.hs view
@@ -0,0 +1,112 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TemplateHaskell #-}++-- | Coverage for derived structured output (ADR 0005): the outputSchema+-- generated from a result record, and the ToolOutput result forms.+module Spec.DerivedOutput (spec) where++import Data.Aeson+import qualified Data.ByteString.Lazy as BSL+import Data.Text (Text)+import qualified Data.Text.Encoding as TE+import MCP.Server+import MCP.Server.Derive+import Test.Hspec+import TestHelpers+import TestTypes++weatherHandlers :: (ToolListHandler, ToolCallHandler)+weatherHandlers = $(deriveToolHandlerWithOutputDescription ''WeatherTool 'handleWeatherTool ''WeatherReport+ [ ("GetWeather", "Current weather"), ("wrTemperature", "Degrees Celsius") ])++callWeather :: Text -> [(Text, Value)] -> IO (Either Error ToolResult)+callWeather name args = snd weatherHandlers anonCtx name (valueArgs args)++-- The serialized form the derivation guarantees for 'sampleReport'+expectedReport :: Text -> Maybe Int -> Value+expectedReport place humidity = object $+ [ "wrTemperature" .= (21 :: Int)+ , "wrSky" .= ("sky_cloudy" :: Text)+ , "wrWind" .= object ["windSpeedKph" .= (12.5 :: Double), "windGusting" .= False]+ , "wrAlerts" .= (["wind advisory for " <> place] :: [Text])+ ] ++ maybe [] (\h -> ["wrHumidity" .= h]) humidity++spec :: Spec+spec = describe "Derived structured output" $ do++ describe "outputSchema derivation" $ do+ it "every tool of the splice carries the output record's object schema" $ do+ defs <- fst weatherHandlers anonCtx+ map toolDefinitionName defs `shouldBe`+ ["get_weather", "broken_station", "custom_report", "plain_forecast"]+ case map toolDefinitionOutputSchema defs of+ (s:rest) -> do+ s `shouldSatisfy` (/= Nothing)+ all (== s) rest `shouldBe` True+ [] -> expectationFailure "no tools derived"++ it "mirrors field types: enum, nested record, list, optional Maybe" $ do+ defs <- fst weatherHandlers anonCtx+ case defs of+ [] -> expectationFailure "no tools derived"+ (d:_) -> case toolDefinitionOutputSchema d of+ Nothing -> expectationFailure "outputSchema missing"+ Just s -> do+ schemaRequired s `shouldBe` ["wrTemperature", "wrSky", "wrWind", "wrAlerts"]+ fmap schemaTypeName (lookup "wrSky" (schemaProps s)) `shouldBe` Just "string"+ fmap schemaShape (lookup "wrSky" (schemaProps s)) `shouldBe`+ Just (SchemaString (Just ["sky_clear", "sky_cloudy", "sky_raining"]))+ fmap schemaTypeName (lookup "wrWind" (schemaProps s)) `shouldBe` Just "object"+ fmap schemaTypeName (lookup "wrAlerts" (schemaProps s)) `shouldBe` Just "array"+ fmap schemaDescription (lookup "wrTemperature" (schemaProps s)) `shouldBe`+ Just (Just "Degrees Celsius")++ 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+ Right tr -> do+ toolResultIsError tr `shouldBe` False+ toolResultStructured tr `shouldBe` Just (expectedReport "Cape Town" (Just 60))+ case toolResultContent tr of+ [ContentText t] ->+ decode (BSL.fromStrict (TE.encodeUtf8 t)) `shouldBe`+ Just (expectedReport "Cape Town" (Just 60))+ other -> expectationFailure $ "expected one text block, got: " ++ show other+ Left err -> expectationFailure $ "expected result, got error: " ++ show err++ it "renders the text block canonically (sorted keys, build-plan independent)" $ do+ result <- callWeather "get_weather" [("wtCity", String "Cape Town")]+ case 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+ 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+ 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+ Right tr -> do+ toolResultIsError tr `shouldBe` True+ toolResultStructured tr `shouldBe` Nothing+ toolResultContent tr `shouldBe` [ContentText "station offline: X1"]+ Left err -> expectationFailure $ "expected isError result, got: " ++ show err++ it "passes ToolOutputRaw through untouched" $ do+ result <- callWeather "plain_forecast" [("wtNote", String "sunny")]+ case result of+ Right tr -> do+ toolResultStructured tr `shouldBe` Nothing+ toolResultContent tr `shouldBe` [ContentText "sunny"]+ Left err -> expectationFailure $ "expected result, got error: " ++ show err
test/Spec/GoldenWire.hs view
@@ -1,32 +1,41 @@ {-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TemplateHaskell #-} --- | Golden wire-format fixtures: a canned request set is run through--- 'handleMcpMessage' and each response is compared against a checked-in--- fixture under @test/golden/@.+-- | Golden wire-format conformance corpus: @test/golden/@ holds, per case,+-- a JSON-RPC request (@\<name\>.request.json@) and the reference server's+-- exact response (@\<name\>.response.json@), enumerated by+-- @test/golden/manifest.json@. This spec replays every request through+-- 'handleMcpMessage' and compares the response against the fixture. ----- The legacy fixtures were generated from @main@ at v0.2.0 (commit--- @7bd1bcc@), so they anchor the promise that dual-era support leaves--- legacy responses unchanged. The modern fixtures pin the 2026-07-28--- envelope introduced by this revision of the library.+-- The corpus is deliberately API-agnostic — request and response are plain+-- wire bytes, so any MCP implementation that reproduces the reference+-- server described in @test/golden/README.md@ can consume it. Within this+-- library it pins two promises: the legacy fixtures were generated from+-- @main@ at v0.2.0 (commit @7bd1bcc@), anchoring "dual-era support leaves+-- legacy responses unchanged", and the modern fixtures pin the 2026-07-28+-- envelope. -- -- Responses are compared as parsed 'Value's, not raw bytes: aeson's object -- key order depends on the aeson\/hashable versions in the build plan, which -- vary across the CI matrix, while 'Value' equality is stable and still -- catches every added, removed, renamed or changed field. ----- To create a fixture for a new case, run the suite with @GOLDEN_ACCEPT=1@:--- missing fixture files are then written from the current output. Existing--- fixtures are never overwritten — delete one first to regenerate it, and--- never regenerate the legacy fixtures from a branch that intends to keep--- legacy output unchanged.+-- To add a case: write the @.request.json@ by hand, add its manifest entry,+-- and run the suite with @GOLDEN_ACCEPT=1@ — a missing response fixture is+-- then written from the current output. Existing fixtures are never+-- overwritten — delete one first to regenerate it, and never regenerate the+-- legacy fixtures from a branch that intends to keep legacy output+-- unchanged. module Spec.GoldenWire (spec) where import Control.Monad (forM_) import Data.Aeson import qualified Data.ByteString.Lazy as BSL import qualified Data.Map as Map+import Data.Text (Text) import qualified Data.Text as T import MCP.Server+import MCP.Server.Derive import MCP.Server.Handlers (handleMcpMessage) import MCP.Server.JsonRpc import System.Directory (doesFileExist)@@ -41,7 +50,8 @@ } -- Deterministic manual handlers (no Template Haskell): one prompt, one--- resource, one happy tool, one failing tool.+-- resource, one happy tool, one failing tool. Documented for external+-- consumers in test/golden/README.md — keep the two in sync. goldenHandlers :: McpServerHandlers goldenHandlers = noHandlers { prompts = Just (promptList, promptGet)@@ -50,8 +60,8 @@ } where promptList _ = pure- [ PromptDefinition "greet" "Greet someone"- [ArgumentDefinition "name" "Who to greet" True] Nothing+ [ mkPromptDefinition "greet" "Greet someone"+ [ArgumentDefinition "name" "Who to greet" True] ] promptGet _ name args = case name of "greet" -> pure $ Right $ PromptResult (Just "A greeting")@@ -59,16 +69,19 @@ _ -> pure $ Left $ InvalidPromptName name resourceList _ = pure- [ ResourceDefinition "resource://info" "info" (Just "Some info") (Just "text/plain") Nothing ]+ [ (mkResourceDefinition "resource://info" "info")+ { resourceDefinitionDescription = Just "Some info"+ , resourceDefinitionMimeType = Just "text/plain"+ }+ ] resourceRead _ uri | show uri == "resource://info" = pure $ Right $ ResourceText uri "text/plain" "The golden info" | otherwise = pure $ Left $ ResourceNotFound $ T.pack $ show uri toolList _ = pure- [ ToolDefinition "echo" "Echo the text"+ [ mkToolDefinition "echo" "Echo the text" (Schema Nothing (SchemaObject [("text", Schema (Just "The text") (SchemaString Nothing))] ["text"]))- Nothing Nothing ] toolCall _ name args = case name of "echo" -> case Map.lookup "text" args of@@ -77,96 +90,148 @@ "boom" -> pure $ Right $ toolError "kaboom" _ -> pure $ Left $ UnknownTool name --- | 'goldenHandlers' extended with the handler slots introduced after--- v0.2.0. Used only for the fixtures of methods that postdate the legacy--- anchor: extending the main handler set would change the advertised--- capabilities and perturb the v0.2.0-anchored initialize fixture.+-- The extended set's structured-output tool, derived so the corpus pins+-- exactly what the TH derivation puts on the wire (ADR 0005). The field+-- selectors are intentionally unused: the generated serializer binds+-- fields by pattern-matching.+data GoldenEchoOutput = GoldenEchoOutput+ { echoedText :: Text+ , echoedLength :: Int+ }+++data GoldenStructTool = EchoStructured { input :: Text }++-- Selector uses, so -Wunused-top-binds stays quiet+_selectors :: (GoldenEchoOutput -> Text, GoldenEchoOutput -> Int, GoldenStructTool -> Text)+_selectors = (echoedText, echoedLength, input)++goldenStructHandler :: ClientContext -> GoldenStructTool -> IO (ToolOutput GoldenEchoOutput)+goldenStructHandler _ (EchoStructured t) =+ pure $ ToolOutput (GoldenEchoOutput t (T.length t))++-- The extended set's annotated tool (ADR 0006): read-only hints, icon and+-- title on the definition; annotated content on the result+data GoldenAnnotatedTool = AnnotatedProbe { probe :: Text }++goldenAnnotatedHandler :: ClientContext -> GoldenAnnotatedTool -> IO ToolResult+goldenAnnotatedHandler _ (AnnotatedProbe p) = pure $ toolResult+ [ ContentAnnotated+ defaultAnnotations { annotationsAudience = [RoleUser], annotationsPriority = Just 0.5 }+ (ContentText ("probed: " <> p))+ ]++$(pure [])++structuredTools :: (ToolListHandler, ToolCallHandler)+structuredTools = $(deriveToolHandlerWithOutputDescription+ ''GoldenStructTool 'goldenStructHandler ''GoldenEchoOutput+ [ ("EchoStructured", "Echo with structured output")+ , ("input", "The text")+ , ("echoedText", "The echoed text")+ , ("echoedLength", "Its length")+ ])++annotatedTools :: (ToolListHandler, ToolCallHandler)+annotatedTools = $(deriveToolHandlerWithOptions+ ''GoldenAnnotatedTool 'goldenAnnotatedHandler+ [ ("AnnotatedProbe", defaultDefinitionOptions+ { optDescription = Just "A read-only probe"+ , optTitle = Just "Probe"+ , optIcons = [icon "https://example.com/probe.png"]+ , optToolAnnotations = Just defaultToolAnnotations+ { toolReadOnlyHint = Just True+ , toolIdempotentHint = Just True+ }+ , optFieldDescriptions = [("probe", "What to probe")]+ })+ ])++-- | 'goldenHandlers' extended with the handler slots and tools introduced+-- after v0.2.0. Used only for the fixtures of methods/behaviors that+-- postdate the legacy anchor: extending the main handler set would change+-- the advertised capabilities and perturb the v0.2.0-anchored initialize+-- fixture. extendedHandlers :: McpServerHandlers extendedHandlers = goldenHandlers { resourceTemplates = Just $ \_ -> pure- [ ResourceTemplateDefinition "resource://item/{itemId}" "item"- (Just "An item") (Just "text/plain") Nothing+ [ (mkResourceTemplateDefinition "resource://item/{itemId}" "item")+ { resourceTemplateDescription = Just "An item"+ , resourceTemplateMimeType = Just "text/plain"+ } ] , completions = Just $ \_ _ref _arg partial _ctx -> pure $ Right $ completionResult (filter (T.isPrefixOf partial) ["alpha", "beta"])+ , tools = do+ (baseList, baseCall) <- tools goldenHandlers+ let (sList, sCall) = structuredTools+ (aList, aCall) = annotatedTools+ pure ( \c -> concat <$> sequence [baseList c, sList c, aList c]+ , \c n a -> case n of+ "echo_structured" -> sCall c n a+ "annotated_probe" -> aCall c n a+ _ -> baseCall c n a+ ) } --- | (fixture path, raw request). The raw requests are written out verbatim--- so the fixtures capture the full request->response wire behavior.-cases :: [(FilePath, BSL.ByteString)]-cases =- -- Legacy era: no _meta; served under the initialize-negotiated revision.- [ ("legacy/initialize", "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{},\"clientInfo\":{\"name\":\"golden-client\",\"version\":\"1.0\"}}}")- , ("legacy/ping", "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"ping\"}")- , ("legacy/tools-list", "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/list\"}")- , ("legacy/tools-call-echo", "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",\"params\":{\"name\":\"echo\",\"arguments\":{\"text\":\"hi\"}}}")- , ("legacy/tools-call-unknown", "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"tools/call\",\"params\":{\"name\":\"nope\",\"arguments\":{}}}")- , ("legacy/tools-call-boom", "{\"jsonrpc\":\"2.0\",\"id\":6,\"method\":\"tools/call\",\"params\":{\"name\":\"boom\",\"arguments\":{}}}")- , ("legacy/prompts-list", "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"prompts/list\"}")- , ("legacy/prompts-get", "{\"jsonrpc\":\"2.0\",\"id\":8,\"method\":\"prompts/get\",\"params\":{\"name\":\"greet\",\"arguments\":{\"name\":\"World\"}}}")- , ("legacy/resources-list", "{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"resources/list\"}")- , ("legacy/resources-read", "{\"jsonrpc\":\"2.0\",\"id\":10,\"method\":\"resources/read\",\"params\":{\"uri\":\"resource://info\"}}")+-- | One manifest entry: which case, and which reference-server variant+-- answers it.+data GoldenCase = GoldenCase+ { caseName :: FilePath+ , caseHandlers :: Text -- ^ "base" or "extended"+ , caseNotifications :: Bool+ } - -- Modern era: _meta declares 2026-07-28; responses carry the modern- -- envelope (resultType, serverInfo _meta, cacheability on list/read).- , ("modern/server-discover", "{\"jsonrpc\":\"2.0\",\"id\":21,\"method\":\"server/discover\",\"params\":{" <> meta <> "}}")- , ("modern/tools-list", "{\"jsonrpc\":\"2.0\",\"id\":22,\"method\":\"tools/list\",\"params\":{" <> meta <> "}}")- , ("modern/tools-call-echo", "{\"jsonrpc\":\"2.0\",\"id\":23,\"method\":\"tools/call\",\"params\":{\"name\":\"echo\",\"arguments\":{\"text\":\"hi\"}," <> meta <> "}}")- , ("modern/prompts-get", "{\"jsonrpc\":\"2.0\",\"id\":24,\"method\":\"prompts/get\",\"params\":{\"name\":\"greet\",\"arguments\":{\"name\":\"World\"}," <> meta <> "}}")- , ("modern/resources-read", "{\"jsonrpc\":\"2.0\",\"id\":25,\"method\":\"resources/read\",\"params\":{\"uri\":\"resource://info\"," <> meta <> "}}")- , ("modern/unsupported-version", "{\"jsonrpc\":\"2.0\",\"id\":26,\"method\":\"tools/list\",\"params\":{\"_meta\":{\"io.modelcontextprotocol/protocolVersion\":\"2099-01-01\"}}}")- , ("modern/initialize", "{\"jsonrpc\":\"2.0\",\"id\":27,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2026-07-28\",\"capabilities\":{},\"clientInfo\":{\"name\":\"golden-client\",\"version\":\"1.0\"}," <> meta <> "}}")- , ("modern/ping", "{\"jsonrpc\":\"2.0\",\"id\":28,\"method\":\"ping\",\"params\":{" <> meta <> "}}")- ]+instance FromJSON GoldenCase where+ parseJSON = withObject "GoldenCase" $ \o -> GoldenCase+ <$> o .: "name"+ <*> o .: "handlers"+ <*> o .: "notifications" --- Methods introduced after v0.2.0: their fixtures originate on the branch--- that added the method (there is no earlier behavior to anchor to).-extendedCases :: [(FilePath, BSL.ByteString)]-extendedCases =- [ ("legacy/resources-templates-list", "{\"jsonrpc\":\"2.0\",\"id\":11,\"method\":\"resources/templates/list\"}")- , ("legacy/completion-complete", "{\"jsonrpc\":\"2.0\",\"id\":12,\"method\":\"completion/complete\",\"params\":{\"ref\":{\"type\":\"ref/prompt\",\"name\":\"greet\"},\"argument\":{\"name\":\"name\",\"value\":\"al\"}}}")- , ("modern/resources-templates-list", "{\"jsonrpc\":\"2.0\",\"id\":29,\"method\":\"resources/templates/list\",\"params\":{" <> meta <> "}}")- , ("modern/completion-complete", "{\"jsonrpc\":\"2.0\",\"id\":30,\"method\":\"completion/complete\",\"params\":{\"ref\":{\"type\":\"ref/prompt\",\"name\":\"greet\"},\"argument\":{\"name\":\"name\",\"value\":\"al\"}," <> meta <> "}}")- ]+newtype Manifest = Manifest [GoldenCase] -meta :: BSL.ByteString-meta = "\"_meta\":{\"io.modelcontextprotocol/protocolVersion\":\"2026-07-28\",\"io.modelcontextprotocol/clientInfo\":{\"name\":\"golden-client\",\"version\":\"1.0\"},\"io.modelcontextprotocol/clientCapabilities\":{}}"+instance FromJSON Manifest where+ parseJSON = withObject "Manifest" $ \o -> Manifest <$> o .: "cases" --- Capability fixtures for a transport that delivers notifications (stdio--- with a configured source: legacy push + modern listen)-notifyingCases :: [(FilePath, BSL.ByteString)]-notifyingCases =- [ ("legacy/initialize-notifying", "{\"jsonrpc\":\"2.0\",\"id\":13,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{},\"clientInfo\":{\"name\":\"golden-client\",\"version\":\"1.0\"}}}")- , ("modern/server-discover-notifying", "{\"jsonrpc\":\"2.0\",\"id\":31,\"method\":\"server/discover\",\"params\":{" <> meta <> "}}")- ]+-- | The corpus enumeration, read while hspec constructs the spec tree.+loadManifest :: IO [GoldenCase]+loadManifest = do+ bytes <- BSL.readFile "test/golden/manifest.json"+ case eitherDecode bytes of+ Left err -> fail ("test/golden/manifest.json does not parse: " ++ err)+ Right (Manifest cs) -> pure cs -runCase :: NotificationSupport -> McpServerHandlers -> BSL.ByteString -> IO Value-runCase support handlers raw = do+runCase :: GoldenCase -> BSL.ByteString -> IO Value+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)- maybeResponse <- handleMcpMessage goldenServerInfo defaultCacheHints support handlers anonymousContext message+ let handlers = if caseHandlers gc == "extended" then extendedHandlers else goldenHandlers+ support = if caseNotifications gc+ then NotificationSupport { supportsLegacyPush = True, supportsListen = True }+ else noNotificationSupport+ maybeResponse <- handleMcpMessage goldenServerInfo defaultCacheHints support (\_ -> pure ()) handlers anonymousContext message case maybeResponse of Just responseMsg -> pure $ encodeJsonRpcMessage responseMsg Nothing -> fail "expected a response" -goldenCase :: NotificationSupport -> McpServerHandlers -> (FilePath, BSL.ByteString) -> Spec-goldenCase support handlers (name, raw) =- it name $ do- actual <- runCase support handlers raw- let path = "test/golden/" ++ name ++ ".json"- exists <- doesFileExist path+goldenCase :: GoldenCase -> Spec+goldenCase gc =+ it (caseName gc) $ do+ let requestPath = "test/golden/" ++ caseName gc ++ ".request.json"+ responsePath = "test/golden/" ++ caseName gc ++ ".response.json"+ raw <- BSL.readFile requestPath+ actual <- runCase gc raw+ exists <- doesFileExist responsePath accept <- lookupEnv "GOLDEN_ACCEPT" if not exists && accept /= Nothing- then BSL.writeFile path (encode actual)+ then BSL.writeFile responsePath (encode actual) else do- fixtureBytes <- BSL.readFile path+ fixtureBytes <- BSL.readFile responsePath case eitherDecode fixtureBytes :: Either String Value of Left err -> expectationFailure $ "fixture does not parse: " ++ err Right fixture -> actual `shouldBe` fixture spec :: Spec spec = describe "Golden wire-format fixtures" $ do- forM_ cases (goldenCase noNotificationSupport goldenHandlers)- forM_ extendedCases (goldenCase noNotificationSupport extendedHandlers)- forM_ notifyingCases- (goldenCase (NotificationSupport { supportsLegacyPush = True, supportsListen = True }) goldenHandlers)+ cases <- runIO loadManifest+ forM_ cases goldenCase
test/Spec/ModernEra.hs view
@@ -14,7 +14,8 @@ import MCP.Server.Handlers (handleMcpMessage) import MCP.Server.JsonRpc import MCP.Server.Transport.Http (BodyPeek (..), decodeSentinel, peekBody,- peekIsModern, validateRequestHeaders)+ peekIsModern, validateRequestHeaders,+ wantsStreamingResponse) import qualified Network.Wai as Wai import Test.Hspec @@ -37,7 +38,7 @@ } run :: JsonRpcMessage -> IO (Maybe JsonRpcMessage)-run = handleMcpMessage testServerInfo defaultCacheHints noNotificationSupport testHandlers anonymousContext+run = handleMcpMessage testServerInfo defaultCacheHints noNotificationSupport (\_ -> pure ()) testHandlers anonymousContext request :: Text -> Maybe Value -> JsonRpcMessage request method params = JsonRpcMessageRequest $ JsonRpcRequest@@ -217,6 +218,25 @@ fmap errorCode (validateRequestHeaders (reqWith [("MCP-Protocol-Version", "1999-01-01")]) (peekBody legacyBody)) `shouldBe` Just (-32600)++ describe "Streaming-response selection" $ do+ let tokenMeta = "\"_meta\":{\"progressToken\":\"t1\"}"++ it "streams dispatchable requests that carry a progressToken" $ do+ let body = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"x\",\"arguments\":{}," <> tokenMeta <> "}}"+ wantsStreamingResponse (peekBody body) `shouldBe` True++ it "keeps unknown methods on the JSON path so they retain their 404" $ do+ let body = "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"subscriptions/listen\",\"params\":{" <> tokenMeta <> "}}"+ wantsStreamingResponse (peekBody body) `shouldBe` False++ it "keeps notification bodies on the JSON path so they retain their 202" $ do+ let body = "{\"jsonrpc\":\"2.0\",\"method\":\"tools/call\",\"params\":{\"name\":\"x\"," <> tokenMeta <> "}}"+ wantsStreamingResponse (peekBody body) `shouldBe` False++ it "does not stream token-less requests" $ do+ let body = "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"x\"}}"+ wantsStreamingResponse (peekBody body) `shouldBe` False describe "Sentinel decoding" $ do it "passes plain values through" $
+ test/Spec/Progress.hs view
@@ -0,0 +1,106 @@+{-# LANGUAGE OverloadedStrings #-}++-- | Coverage for progress notifications and per-request client logging+-- (ADR 0007): the reportProgress/logToClient context actions and their+-- _meta-driven gating.+module Spec.Progress (spec) where++import Data.Aeson+import qualified Data.Aeson.KeyMap as KM+import Data.Aeson.Types (Pair)+import Data.IORef+import Data.Text (Text)+import MCP.Server+import MCP.Server.Handlers (handleMcpMessage)+import MCP.Server.JsonRpc+import Test.Hspec++progressServer :: McpServerHandlers+progressServer = noHandlers+ { tools = Just+ ( \_ -> pure []+ , \ctx name _ -> case name of+ "work" -> do+ reportProgress ctx 0.2 (Just 1.0) (Just "starting")+ reportProgress ctx 0.9 Nothing Nothing+ pure $ Right $ toToolResult ("done" :: Text)+ "chatty" -> do+ logToClient ctx LogInfo (String "fyi")+ logToClient ctx LogError (String "bad")+ pure $ Right $ toToolResult ("ok" :: Text)+ _ -> pure $ Left $ UnknownTool name+ )+ }++-- Run a tools/call with the given _meta fields, collecting emitted+-- request-scoped notifications in order.+callTool :: [Pair] -> Text -> IO ([JsonRpcNotification], Maybe JsonRpcMessage)+callTool metaFields tool = do+ sink <- newIORef []+ let emit n = modifyIORef' sink (++ [n])+ params = object $+ ["name" .= tool, "arguments" .= object []]+ ++ (if null metaFields then [] else ["_meta" .= object metaFields])+ resp <- handleMcpMessage (McpServerInfo "T" "1" "") defaultCacheHints+ noNotificationSupport emit progressServer anonymousContext+ (JsonRpcMessageRequest (JsonRpcRequest "2.0" (RequestIdNumber 1) "tools/call" (Just params)))+ notifications <- readIORef sink+ pure (notifications, resp)++paramsOf :: JsonRpcNotification -> Object+paramsOf n = case notificationParams n of+ Just (Object o) -> o+ other -> error $ "notification params not an object: " ++ show other++spec :: Spec+spec = describe "Progress and client logging" $ do++ describe "reportProgress" $ do+ it "emits tagged, ordered notifications/progress before the response" $ do+ (ns, resp) <- callTool ["progressToken" .= ("abc123" :: Text)] "work"+ map notificationMethod ns `shouldBe`+ ["notifications/progress", "notifications/progress"]+ case map paramsOf ns of+ [first', second'] -> do+ KM.lookup "progressToken" first' `shouldBe` Just (String "abc123")+ KM.lookup "progress" first' `shouldBe` Just (Number 0.2)+ KM.lookup "total" first' `shouldBe` Just (Number 1.0)+ KM.lookup "message" first' `shouldBe` Just (String "starting")+ KM.lookup "progress" second' `shouldBe` Just (Number 0.9)+ KM.member "total" second' `shouldBe` False+ KM.member "message" second' `shouldBe` False+ other -> expectationFailure $ "expected two notifications, got: " ++ show (length other)+ resp `shouldSatisfy` (/= Nothing)++ it "preserves integer tokens as integers" $ do+ (ns, _) <- callTool ["progressToken" .= (42 :: Int)] "work"+ map (KM.lookup "progressToken" . paramsOf) ns `shouldBe`+ [Just (Number 42), Just (Number 42)]++ it "is a no-op without a progressToken" $ do+ (ns, resp) <- callTool [] "work"+ ns `shouldBe` []+ resp `shouldSatisfy` (/= Nothing)++ describe "logToClient" $ do+ it "filters below the declared level and emits at or above it" $ do+ (ns, _) <- callTool ["io.modelcontextprotocol/logLevel" .= ("warning" :: Text)] "chatty"+ map notificationMethod ns `shouldBe` ["notifications/message"]+ case ns of+ [n] -> do+ KM.lookup "level" (paramsOf n) `shouldBe` Just (String "error")+ KM.lookup "data" (paramsOf n) `shouldBe` Just (String "bad")+ other -> expectationFailure $ "expected one notification, got: " ++ show (length other)++ it "emits everything at a permissive level" $ do+ (ns, _) <- callTool ["io.modelcontextprotocol/logLevel" .= ("debug" :: Text)] "chatty"+ map (KM.lookup "level" . paramsOf) ns `shouldBe`+ [Just (String "info"), Just (String "error")]++ it "emits nothing when the request declared no logLevel (spec MUST NOT)" $ do+ (ns, _) <- callTool [] "chatty"+ ns `shouldBe` []++ it "treats an unparseable logLevel as absent" $ do+ (ns, _) <- callTool ["io.modelcontextprotocol/logLevel" .= ("loudly" :: Text)] "chatty"+ ns `shouldBe` []
test/Spec/Subscriptions.hs view
@@ -121,6 +121,7 @@ (McpServerInfo "T" "1" "") defaultCacheHints support+ (\_ -> pure ()) toolsServer anonymousContext (JsonRpcMessageRequest (JsonRpcRequest "2.0" (RequestIdNumber 1) method params))
test/Spec/TemplatesCompletions.hs view
@@ -53,6 +53,7 @@ (McpServerInfo "T" "1" "") defaultCacheHints noNotificationSupport+ (\_ -> pure ()) handlers anonymousContext (JsonRpcMessageRequest (JsonRpcRequest "2.0" (RequestIdNumber 1) method params))
test/Spec/UnicodeHandling.hs view
@@ -171,12 +171,9 @@ it "handles complete Unicode workflow without Template Haskell" $ do -- Create manual handlers with Unicode content let promptListHandler = return [- PromptDefinition- { promptDefinitionName = "math_formula"- , promptDefinitionDescription = "Generate mathematical formulas with Unicode: ∀∃∈√"- , promptDefinitionArguments = [ArgumentDefinition "formula" "Mathematical expression" True]- , promptDefinitionTitle = Nothing -- 2025-06-18: New title field- }+ mkPromptDefinition "math_formula"+ "Generate mathematical formulas with Unicode: ∀∃∈√"+ [ArgumentDefinition "formula" "Mathematical expression" True] ] let promptGetHandler name args = case name of@@ -186,12 +183,9 @@ _ -> return $ Left $ InvalidPromptName name let resourceListHandler = return [- ResourceDefinition- { resourceDefinitionURI = "resource://unicode_symbols"- , resourceDefinitionName = "unicode_symbols"- , resourceDefinitionDescription = Just "Unicode mathematical symbols: ∀∃∈∉√∑"+ (mkResourceDefinition "resource://unicode_symbols" "unicode_symbols")+ { resourceDefinitionDescription = Just "Unicode mathematical symbols: ∀∃∈∉√∑" , resourceDefinitionMimeType = Just "text/plain"- , resourceDefinitionTitle = Nothing -- 2025-06-18: New title field } ] @@ -201,15 +195,10 @@ else return $ Left $ ResourceNotFound $ T.pack $ show uri let toolListHandler = return [- ToolDefinition- { toolDefinitionName = "calculate"- , toolDefinitionDescription = "Calculate with Unicode symbols: √∑∏"- , toolDefinitionInputSchema = schema $ SchemaObject+ mkToolDefinition "calculate" "Calculate with Unicode symbols: √∑∏"+ (schema $ SchemaObject [("expression", describedSchema "Mathematical expression" (SchemaString Nothing))]- ["expression"]- , toolDefinitionOutputSchema = Nothing- , toolDefinitionTitle = Nothing -- 2025-06-18: New title field- }+ ["expression"]) ] let toolCallHandler name args = case name of
test/TestTypes.hs view
@@ -6,7 +6,8 @@ import qualified Data.Text as T import MCP.Server (ClientContext, anonymousContext, Content (..), MessageRole (..), PromptMessage (..), PromptResult (..),- ResourceContent (..), ToolResult, toolError,+ ResourceContent (..), ToolOutput (..),+ ToolResult, toolError, toolResult) import Network.URI (URI) @@ -178,6 +179,47 @@ pure $ ResourceText uri "text/plain" ("Profile of " <> uid) handleTemplResource _ uri (OrderItem oid item) = pure $ ResourceText uri "text/plain" ("Order " <> T.pack (show oid) <> " item " <> item)++-- Types exercising derived structured output (ADR 0005)+data Wind = Wind { windSpeedKph :: Double, windGusting :: Bool }+ deriving (Show, Eq)++data Sky = SkyClear | SkyCloudy | SkyRaining+ deriving (Show, Eq)++data WeatherReport = WeatherReport+ { wrTemperature :: Int+ , wrSky :: Sky+ , wrWind :: Wind+ , wrAlerts :: [Text]+ , wrHumidity :: Maybe Int+ } deriving (Show, Eq)++data WeatherTool+ = GetWeather { wtCity :: Text }+ | BrokenStation { wtStation :: Text }+ | CustomReport { wtRegion :: Text }+ | PlainForecast { wtNote :: Text }+ deriving (Show, Eq)++sampleReport :: Text -> Maybe Int -> WeatherReport+sampleReport place humidity = WeatherReport+ { wrTemperature = 21+ , wrSky = SkyCloudy+ , wrWind = Wind 12.5 False+ , wrAlerts = ["wind advisory for " <> place]+ , wrHumidity = humidity+ }++handleWeatherTool :: ClientContext -> WeatherTool -> IO (ToolOutput WeatherReport)+handleWeatherTool _ (GetWeather c) =+ pure $ ToolOutput (sampleReport c (Just 60))+handleWeatherTool _ (BrokenStation s) =+ pure $ ToolOutputError ("station offline: " <> s)+handleWeatherTool _ (CustomReport r) =+ pure $ ToolOutputWith [ContentText ("Report for " <> r)] (sampleReport r Nothing)+handleWeatherTool _ (PlainForecast n) =+ pure $ ToolOutputRaw (toolResult [ContentText n]) -- A prompt whose handler produces a multi-message conversation data ConvPrompt = Conversation { topic :: Text }
+ test/golden/README.md view
@@ -0,0 +1,69 @@+# MCP wire-format conformance corpus++Each case in this directory is a pair of files:++- `<name>.request.json` — a single JSON-RPC request, exactly as it would+ arrive on the wire (one line).+- `<name>.response.json` — the reference server's exact response.++`manifest.json` enumerates the cases and states, per case, which+reference-server variant answers it (`handlers`) and whether the serving+transport can deliver change notifications (`notifications`). Responses are+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 corpus is deliberately **API-agnostic**: nothing in it refers to this+library's types or Haskell at all. Any MCP server implementation that+reproduces the reference server below can replay the requests and diff the+responses — the cases then pin protocol behavior (error codes, era+envelopes, capability advertisement) rather than any particular API.++## Eras++- `legacy/` — requests carry no modern `_meta`; they are answered under the+ revision negotiated by `initialize` (2024-11-05 … 2025-11-25 share this+ wire format for the operations covered here). These fixtures were+ generated from mcp-server v0.2.0 and are the anchor for "newer protocol+ work leaves legacy responses unchanged" — **never regenerate them** from a+ branch that intends to preserve legacy output.+- `modern/` — requests declare a revision (2026-07-28) in params `_meta`;+ responses carry the modern envelope: `resultType`, server identity in+ result `_meta`, and `ttlMs`/`cacheScope` on the cacheable methods.++## The reference server++Identity: name `Golden Server`, version `1.0.0`, instructions+`Golden fixture server`. Cache hints: `ttlMs` 0, scope `private`.++The `base` handler set:++| Feature | Definition | Behavior |+|---|---|---|+| Tool `echo` | input schema: object, required `text` (string, described "The text") | returns one text content block `echo: <text>` |+| Tool `boom` | not listed | any call returns `isError: true` with text `kaboom` |+| Prompt `greet` | one required argument `name` ("Who to greet") | description `A greeting`, one user message `Hello, <name>!` |+| Resource `resource://info` | name `info`, description `Some info`, `text/plain` | text contents `The golden info` |++The `extended` handler set adds (used only for methods that postdate the+v0.2.0 anchor, so the anchored capability fixtures stay untouched):++| Feature | Definition | Behavior |+|---|---|---|+| Resource template `resource://item/{itemId}` | name `item`, description `An item`, `text/plain` | — |+| 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` |++Cases with `"notifications": true` are answered as if the transport can+deliver change notifications (stdio with a configured notifier: legacy push+and modern `subscriptions/listen`), which flips the advertised+`listChanged`/`subscribe` capability flags.++## Adding a case++Write the `.request.json` by hand, add a manifest entry, and run the test+suite with `GOLDEN_ACCEPT=1`: the missing `.response.json` is written from+the current implementation's output. Existing response fixtures are never+overwritten — delete one first to regenerate it deliberately.
− test/golden/legacy/completion-complete.json
@@ -1,1 +0,0 @@-{"id":12,"jsonrpc":"2.0","result":{"completion":{"values":["alpha"]}}}
+ test/golden/legacy/completion-complete.request.json view
@@ -0,0 +1,1 @@+{"jsonrpc":"2.0","id":12,"method":"completion/complete","params":{"ref":{"type":"ref/prompt","name":"greet"},"argument":{"name":"name","value":"al"}}}
+ test/golden/legacy/completion-complete.response.json view
@@ -0,0 +1,1 @@+{"id":12,"jsonrpc":"2.0","result":{"completion":{"values":["alpha"]}}}
− test/golden/legacy/initialize-notifying.json
@@ -1,1 +0,0 @@-{"id":13,"jsonrpc":"2.0","result":{"capabilities":{"prompts":{"listChanged":true},"resources":{"listChanged":true},"tools":{"listChanged":true}},"protocolVersion":"2025-06-18","serverInfo":{"instructions":"Golden fixture server","name":"Golden Server","version":"1.0.0"}}}
+ test/golden/legacy/initialize-notifying.request.json view
@@ -0,0 +1,1 @@+{"jsonrpc":"2.0","id":13,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"golden-client","version":"1.0"}}}
+ test/golden/legacy/initialize-notifying.response.json view
@@ -0,0 +1,1 @@+{"id":13,"jsonrpc":"2.0","result":{"capabilities":{"prompts":{"listChanged":true},"resources":{"listChanged":true},"tools":{"listChanged":true}},"protocolVersion":"2025-06-18","serverInfo":{"instructions":"Golden fixture server","name":"Golden Server","version":"1.0.0"}}}
− test/golden/legacy/initialize.json
@@ -1,1 +0,0 @@-{"id":1,"jsonrpc":"2.0","result":{"capabilities":{"prompts":{},"resources":{},"tools":{}},"protocolVersion":"2025-06-18","serverInfo":{"instructions":"Golden fixture server","name":"Golden Server","version":"1.0.0"}}}
+ test/golden/legacy/initialize.request.json view
@@ -0,0 +1,1 @@+{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"golden-client","version":"1.0"}}}
+ test/golden/legacy/initialize.response.json view
@@ -0,0 +1,1 @@+{"id":1,"jsonrpc":"2.0","result":{"capabilities":{"prompts":{},"resources":{},"tools":{}},"protocolVersion":"2025-06-18","serverInfo":{"instructions":"Golden fixture server","name":"Golden Server","version":"1.0.0"}}}
− test/golden/legacy/ping.json
@@ -1,1 +0,0 @@-{"id":2,"jsonrpc":"2.0","result":{}}
+ test/golden/legacy/ping.request.json view
@@ -0,0 +1,1 @@+{"jsonrpc":"2.0","id":2,"method":"ping"}
+ test/golden/legacy/ping.response.json view
@@ -0,0 +1,1 @@+{"id":2,"jsonrpc":"2.0","result":{}}
− test/golden/legacy/prompts-get.json
@@ -1,1 +0,0 @@-{"id":8,"jsonrpc":"2.0","result":{"description":"A greeting","messages":[{"content":{"text":"Hello, World!","type":"text"},"role":"user"}]}}
+ test/golden/legacy/prompts-get.request.json view
@@ -0,0 +1,1 @@+{"jsonrpc":"2.0","id":8,"method":"prompts/get","params":{"name":"greet","arguments":{"name":"World"}}}
+ test/golden/legacy/prompts-get.response.json view
@@ -0,0 +1,1 @@+{"id":8,"jsonrpc":"2.0","result":{"description":"A greeting","messages":[{"content":{"text":"Hello, World!","type":"text"},"role":"user"}]}}
− test/golden/legacy/prompts-list.json
@@ -1,1 +0,0 @@-{"id":7,"jsonrpc":"2.0","result":{"prompts":[{"arguments":[{"description":"Who to greet","name":"name","required":true}],"description":"Greet someone","name":"greet"}]}}
+ test/golden/legacy/prompts-list.request.json view
@@ -0,0 +1,1 @@+{"jsonrpc":"2.0","id":7,"method":"prompts/list"}
+ test/golden/legacy/prompts-list.response.json view
@@ -0,0 +1,1 @@+{"id":7,"jsonrpc":"2.0","result":{"prompts":[{"arguments":[{"description":"Who to greet","name":"name","required":true}],"description":"Greet someone","name":"greet"}]}}
− test/golden/legacy/resources-list.json
@@ -1,1 +0,0 @@-{"id":9,"jsonrpc":"2.0","result":{"resources":[{"description":"Some info","mimeType":"text/plain","name":"info","uri":"resource://info"}]}}
+ test/golden/legacy/resources-list.request.json view
@@ -0,0 +1,1 @@+{"jsonrpc":"2.0","id":9,"method":"resources/list"}
+ test/golden/legacy/resources-list.response.json view
@@ -0,0 +1,1 @@+{"id":9,"jsonrpc":"2.0","result":{"resources":[{"description":"Some info","mimeType":"text/plain","name":"info","uri":"resource://info"}]}}
− test/golden/legacy/resources-read.json
@@ -1,1 +0,0 @@-{"id":10,"jsonrpc":"2.0","result":{"contents":[{"mimeType":"text/plain","text":"The golden info","uri":"resource://info"}]}}
+ test/golden/legacy/resources-read.request.json view
@@ -0,0 +1,1 @@+{"jsonrpc":"2.0","id":10,"method":"resources/read","params":{"uri":"resource://info"}}
+ test/golden/legacy/resources-read.response.json view
@@ -0,0 +1,1 @@+{"id":10,"jsonrpc":"2.0","result":{"contents":[{"mimeType":"text/plain","text":"The golden info","uri":"resource://info"}]}}
− test/golden/legacy/resources-templates-list.json
@@ -1,1 +0,0 @@-{"id":11,"jsonrpc":"2.0","result":{"resourceTemplates":[{"description":"An item","mimeType":"text/plain","name":"item","uriTemplate":"resource://item/{itemId}"}]}}
+ test/golden/legacy/resources-templates-list.request.json view
@@ -0,0 +1,1 @@+{"jsonrpc":"2.0","id":11,"method":"resources/templates/list"}
+ test/golden/legacy/resources-templates-list.response.json view
@@ -0,0 +1,1 @@+{"id":11,"jsonrpc":"2.0","result":{"resourceTemplates":[{"description":"An item","mimeType":"text/plain","name":"item","uriTemplate":"resource://item/{itemId}"}]}}
+ test/golden/legacy/tools-call-annotated.request.json view
@@ -0,0 +1,1 @@+{"jsonrpc":"2.0","id":16,"method":"tools/call","params":{"name":"annotated_probe","arguments":{"probe":"sensor"}}}
+ test/golden/legacy/tools-call-annotated.response.json view
@@ -0,0 +1,1 @@+{"id":16,"jsonrpc":"2.0","result":{"content":[{"annotations":{"audience":["user"],"priority":0.5},"text":"probed: sensor","type":"text"}]}}
− test/golden/legacy/tools-call-boom.json
@@ -1,1 +0,0 @@-{"id":6,"jsonrpc":"2.0","result":{"content":[{"text":"kaboom","type":"text"}],"isError":true}}
+ test/golden/legacy/tools-call-boom.request.json view
@@ -0,0 +1,1 @@+{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"boom","arguments":{}}}
+ test/golden/legacy/tools-call-boom.response.json view
@@ -0,0 +1,1 @@+{"id":6,"jsonrpc":"2.0","result":{"content":[{"text":"kaboom","type":"text"}],"isError":true}}
− test/golden/legacy/tools-call-echo.json
@@ -1,1 +0,0 @@-{"id":4,"jsonrpc":"2.0","result":{"content":[{"text":"echo: hi","type":"text"}]}}
+ test/golden/legacy/tools-call-echo.request.json view
@@ -0,0 +1,1 @@+{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"echo","arguments":{"text":"hi"}}}
+ test/golden/legacy/tools-call-echo.response.json view
@@ -0,0 +1,1 @@+{"id":4,"jsonrpc":"2.0","result":{"content":[{"text":"echo: hi","type":"text"}]}}
+ test/golden/legacy/tools-call-structured.request.json view
@@ -0,0 +1,1 @@+{"jsonrpc":"2.0","id":15,"method":"tools/call","params":{"name":"echo_structured","arguments":{"input":"golden"}}}
+ test/golden/legacy/tools-call-structured.response.json view
@@ -0,0 +1,1 @@+{"id":15,"jsonrpc":"2.0","result":{"content":[{"text":"{\"echoedLength\":6,\"echoedText\":\"golden\"}","type":"text"}],"structuredContent":{"echoedLength":6,"echoedText":"golden"}}}
− test/golden/legacy/tools-call-unknown.json
@@ -1,1 +0,0 @@-{"error":{"code":-32602,"message":"Unknown tool: nope"},"id":5,"jsonrpc":"2.0"}
+ test/golden/legacy/tools-call-unknown.request.json view
@@ -0,0 +1,1 @@+{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"nope","arguments":{}}}
+ test/golden/legacy/tools-call-unknown.response.json view
@@ -0,0 +1,1 @@+{"error":{"code":-32602,"message":"Unknown tool: nope"},"id":5,"jsonrpc":"2.0"}
+ test/golden/legacy/tools-list-extended.request.json view
@@ -0,0 +1,1 @@+{"jsonrpc":"2.0","id":14,"method":"tools/list"}
+ test/golden/legacy/tools-list-extended.response.json view
@@ -0,0 +1,1 @@+{"id":14,"jsonrpc":"2.0","result":{"tools":[{"description":"Echo the text","inputSchema":{"properties":{"text":{"description":"The text","type":"string"}},"required":["text"],"type":"object"},"name":"echo"},{"description":"Echo with structured output","inputSchema":{"properties":{"input":{"description":"The text","type":"string"}},"required":["input"],"type":"object"},"name":"echo_structured","outputSchema":{"properties":{"echoedLength":{"description":"Its length","type":"integer"},"echoedText":{"description":"The echoed text","type":"string"}},"required":["echoedText","echoedLength"],"type":"object"}},{"annotations":{"idempotentHint":true,"readOnlyHint":true},"description":"A read-only probe","icons":[{"src":"https://example.com/probe.png"}],"inputSchema":{"properties":{"probe":{"description":"What to probe","type":"string"}},"required":["probe"],"type":"object"},"name":"annotated_probe","title":"Probe"}]}}
− test/golden/legacy/tools-list.json
@@ -1,1 +0,0 @@-{"id":3,"jsonrpc":"2.0","result":{"tools":[{"description":"Echo the text","inputSchema":{"properties":{"text":{"description":"The text","type":"string"}},"required":["text"],"type":"object"},"name":"echo"}]}}
+ test/golden/legacy/tools-list.request.json view
@@ -0,0 +1,1 @@+{"jsonrpc":"2.0","id":3,"method":"tools/list"}
+ test/golden/legacy/tools-list.response.json view
@@ -0,0 +1,1 @@+{"id":3,"jsonrpc":"2.0","result":{"tools":[{"description":"Echo the text","inputSchema":{"properties":{"text":{"description":"The text","type":"string"}},"required":["text"],"type":"object"},"name":"echo"}]}}
+ test/golden/manifest.json view
@@ -0,0 +1,151 @@+{+ "description": "Wire-level MCP conformance corpus: each case is a JSON-RPC request and the reference server's exact response, per protocol era. See README.md for the reference server definition.",+ "comparison": "parsed-json-equality",+ "cases": [+ {+ "name": "legacy/completion-complete",+ "handlers": "extended",+ "notifications": false+ },+ {+ "name": "legacy/initialize",+ "handlers": "base",+ "notifications": false+ },+ {+ "name": "legacy/initialize-notifying",+ "handlers": "base",+ "notifications": true+ },+ {+ "name": "legacy/ping",+ "handlers": "base",+ "notifications": false+ },+ {+ "name": "legacy/prompts-get",+ "handlers": "base",+ "notifications": false+ },+ {+ "name": "legacy/prompts-list",+ "handlers": "base",+ "notifications": false+ },+ {+ "name": "legacy/resources-list",+ "handlers": "base",+ "notifications": false+ },+ {+ "name": "legacy/resources-read",+ "handlers": "base",+ "notifications": false+ },+ {+ "name": "legacy/resources-templates-list",+ "handlers": "extended",+ "notifications": false+ },+ {+ "name": "legacy/tools-call-annotated",+ "handlers": "extended",+ "notifications": false+ },+ {+ "name": "legacy/tools-call-boom",+ "handlers": "base",+ "notifications": false+ },+ {+ "name": "legacy/tools-call-echo",+ "handlers": "base",+ "notifications": false+ },+ {+ "name": "legacy/tools-call-structured",+ "handlers": "extended",+ "notifications": false+ },+ {+ "name": "legacy/tools-call-unknown",+ "handlers": "base",+ "notifications": false+ },+ {+ "name": "legacy/tools-list",+ "handlers": "base",+ "notifications": false+ },+ {+ "name": "legacy/tools-list-extended",+ "handlers": "extended",+ "notifications": false+ },+ {+ "name": "modern/completion-complete",+ "handlers": "extended",+ "notifications": false+ },+ {+ "name": "modern/initialize",+ "handlers": "base",+ "notifications": false+ },+ {+ "name": "modern/ping",+ "handlers": "base",+ "notifications": false+ },+ {+ "name": "modern/prompts-get",+ "handlers": "base",+ "notifications": false+ },+ {+ "name": "modern/resources-read",+ "handlers": "base",+ "notifications": false+ },+ {+ "name": "modern/resources-templates-list",+ "handlers": "extended",+ "notifications": false+ },+ {+ "name": "modern/server-discover",+ "handlers": "base",+ "notifications": false+ },+ {+ "name": "modern/server-discover-notifying",+ "handlers": "base",+ "notifications": true+ },+ {+ "name": "modern/tools-call-annotated",+ "handlers": "extended",+ "notifications": false+ },+ {+ "name": "modern/tools-call-echo",+ "handlers": "base",+ "notifications": false+ },+ {+ "name": "modern/tools-call-structured",+ "handlers": "extended",+ "notifications": false+ },+ {+ "name": "modern/tools-list",+ "handlers": "base",+ "notifications": false+ },+ {+ "name": "modern/unsupported-version",+ "handlers": "base",+ "notifications": false+ }+ ]+}
− test/golden/modern/completion-complete.json
@@ -1,1 +0,0 @@-{"id":30,"jsonrpc":"2.0","result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"Golden Server","version":"1.0.0"}},"completion":{"values":["alpha"]},"resultType":"complete"}}
+ test/golden/modern/completion-complete.request.json view
@@ -0,0 +1,1 @@+{"jsonrpc":"2.0","id":30,"method":"completion/complete","params":{"ref":{"type":"ref/prompt","name":"greet"},"argument":{"name":"name","value":"al"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"golden-client","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}
+ test/golden/modern/completion-complete.response.json view
@@ -0,0 +1,1 @@+{"id":30,"jsonrpc":"2.0","result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"Golden Server","version":"1.0.0"}},"completion":{"values":["alpha"]},"resultType":"complete"}}
− test/golden/modern/initialize.json
@@ -1,1 +0,0 @@-{"error":{"code":-32601,"message":"Method not found: initialize (not part of protocol revision 2026-07-28)"},"id":27,"jsonrpc":"2.0"}
+ test/golden/modern/initialize.request.json view
@@ -0,0 +1,1 @@+{"jsonrpc":"2.0","id":27,"method":"initialize","params":{"protocolVersion":"2026-07-28","capabilities":{},"clientInfo":{"name":"golden-client","version":"1.0"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"golden-client","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}
+ test/golden/modern/initialize.response.json view
@@ -0,0 +1,1 @@+{"error":{"code":-32601,"message":"Method not found: initialize (not part of protocol revision 2026-07-28)"},"id":27,"jsonrpc":"2.0"}
− test/golden/modern/ping.json
@@ -1,1 +0,0 @@-{"error":{"code":-32601,"message":"Method not found: ping (not part of protocol revision 2026-07-28)"},"id":28,"jsonrpc":"2.0"}
+ test/golden/modern/ping.request.json view
@@ -0,0 +1,1 @@+{"jsonrpc":"2.0","id":28,"method":"ping","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"golden-client","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}
+ test/golden/modern/ping.response.json view
@@ -0,0 +1,1 @@+{"error":{"code":-32601,"message":"Method not found: ping (not part of protocol revision 2026-07-28)"},"id":28,"jsonrpc":"2.0"}
− test/golden/modern/prompts-get.json
@@ -1,1 +0,0 @@-{"id":24,"jsonrpc":"2.0","result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"Golden Server","version":"1.0.0"}},"description":"A greeting","messages":[{"content":{"text":"Hello, World!","type":"text"},"role":"user"}],"resultType":"complete"}}
+ test/golden/modern/prompts-get.request.json view
@@ -0,0 +1,1 @@+{"jsonrpc":"2.0","id":24,"method":"prompts/get","params":{"name":"greet","arguments":{"name":"World"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"golden-client","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}
+ test/golden/modern/prompts-get.response.json view
@@ -0,0 +1,1 @@+{"id":24,"jsonrpc":"2.0","result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"Golden Server","version":"1.0.0"}},"description":"A greeting","messages":[{"content":{"text":"Hello, World!","type":"text"},"role":"user"}],"resultType":"complete"}}
− test/golden/modern/resources-read.json
@@ -1,1 +0,0 @@-{"id":25,"jsonrpc":"2.0","result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"Golden Server","version":"1.0.0"}},"cacheScope":"private","contents":[{"mimeType":"text/plain","text":"The golden info","uri":"resource://info"}],"resultType":"complete","ttlMs":0}}
+ test/golden/modern/resources-read.request.json view
@@ -0,0 +1,1 @@+{"jsonrpc":"2.0","id":25,"method":"resources/read","params":{"uri":"resource://info","_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"golden-client","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}
+ test/golden/modern/resources-read.response.json view
@@ -0,0 +1,1 @@+{"id":25,"jsonrpc":"2.0","result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"Golden Server","version":"1.0.0"}},"cacheScope":"private","contents":[{"mimeType":"text/plain","text":"The golden info","uri":"resource://info"}],"resultType":"complete","ttlMs":0}}
− test/golden/modern/resources-templates-list.json
@@ -1,1 +0,0 @@-{"id":29,"jsonrpc":"2.0","result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"Golden Server","version":"1.0.0"}},"cacheScope":"private","resourceTemplates":[{"description":"An item","mimeType":"text/plain","name":"item","uriTemplate":"resource://item/{itemId}"}],"resultType":"complete","ttlMs":0}}
+ test/golden/modern/resources-templates-list.request.json view
@@ -0,0 +1,1 @@+{"jsonrpc":"2.0","id":29,"method":"resources/templates/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"golden-client","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}
+ test/golden/modern/resources-templates-list.response.json view
@@ -0,0 +1,1 @@+{"id":29,"jsonrpc":"2.0","result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"Golden Server","version":"1.0.0"}},"cacheScope":"private","resourceTemplates":[{"description":"An item","mimeType":"text/plain","name":"item","uriTemplate":"resource://item/{itemId}"}],"resultType":"complete","ttlMs":0}}
− test/golden/modern/server-discover-notifying.json
@@ -1,1 +0,0 @@-{"id":31,"jsonrpc":"2.0","result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"Golden Server","version":"1.0.0"}},"cacheScope":"private","capabilities":{"prompts":{"listChanged":true},"resources":{"listChanged":true,"subscribe":true},"tools":{"listChanged":true}},"instructions":"Golden fixture server","resultType":"complete","supportedVersions":["2026-07-28","2025-11-25","2025-06-18","2025-03-26","2024-11-05"],"ttlMs":0}}
+ test/golden/modern/server-discover-notifying.request.json view
@@ -0,0 +1,1 @@+{"jsonrpc":"2.0","id":31,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"golden-client","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}
+ test/golden/modern/server-discover-notifying.response.json view
@@ -0,0 +1,1 @@+{"id":31,"jsonrpc":"2.0","result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"Golden Server","version":"1.0.0"}},"cacheScope":"private","capabilities":{"prompts":{"listChanged":true},"resources":{"listChanged":true,"subscribe":true},"tools":{"listChanged":true}},"instructions":"Golden fixture server","resultType":"complete","supportedVersions":["2026-07-28","2025-11-25","2025-06-18","2025-03-26","2024-11-05"],"ttlMs":0}}
− test/golden/modern/server-discover.json
@@ -1,1 +0,0 @@-{"id":21,"jsonrpc":"2.0","result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"Golden Server","version":"1.0.0"}},"cacheScope":"private","capabilities":{"prompts":{},"resources":{},"tools":{}},"instructions":"Golden fixture server","resultType":"complete","supportedVersions":["2026-07-28","2025-11-25","2025-06-18","2025-03-26","2024-11-05"],"ttlMs":0}}
+ test/golden/modern/server-discover.request.json view
@@ -0,0 +1,1 @@+{"jsonrpc":"2.0","id":21,"method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"golden-client","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}
+ test/golden/modern/server-discover.response.json view
@@ -0,0 +1,1 @@+{"id":21,"jsonrpc":"2.0","result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"Golden Server","version":"1.0.0"}},"cacheScope":"private","capabilities":{"prompts":{},"resources":{},"tools":{}},"instructions":"Golden fixture server","resultType":"complete","supportedVersions":["2026-07-28","2025-11-25","2025-06-18","2025-03-26","2024-11-05"],"ttlMs":0}}
+ test/golden/modern/tools-call-annotated.request.json view
@@ -0,0 +1,1 @@+{"jsonrpc":"2.0","id":33,"method":"tools/call","params":{"name":"annotated_probe","arguments":{"probe":"sensor"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"golden-client","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}
+ test/golden/modern/tools-call-annotated.response.json view
@@ -0,0 +1,1 @@+{"id":33,"jsonrpc":"2.0","result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"Golden Server","version":"1.0.0"}},"content":[{"annotations":{"audience":["user"],"priority":0.5},"text":"probed: sensor","type":"text"}],"resultType":"complete"}}
− test/golden/modern/tools-call-echo.json
@@ -1,1 +0,0 @@-{"id":23,"jsonrpc":"2.0","result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"Golden Server","version":"1.0.0"}},"content":[{"text":"echo: hi","type":"text"}],"resultType":"complete"}}
+ test/golden/modern/tools-call-echo.request.json view
@@ -0,0 +1,1 @@+{"jsonrpc":"2.0","id":23,"method":"tools/call","params":{"name":"echo","arguments":{"text":"hi"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"golden-client","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}
+ test/golden/modern/tools-call-echo.response.json view
@@ -0,0 +1,1 @@+{"id":23,"jsonrpc":"2.0","result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"Golden Server","version":"1.0.0"}},"content":[{"text":"echo: hi","type":"text"}],"resultType":"complete"}}
+ test/golden/modern/tools-call-structured.request.json view
@@ -0,0 +1,1 @@+{"jsonrpc":"2.0","id":32,"method":"tools/call","params":{"name":"echo_structured","arguments":{"input":"golden"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"golden-client","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}
+ test/golden/modern/tools-call-structured.response.json view
@@ -0,0 +1,1 @@+{"id":32,"jsonrpc":"2.0","result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"Golden Server","version":"1.0.0"}},"content":[{"text":"{\"echoedLength\":6,\"echoedText\":\"golden\"}","type":"text"}],"resultType":"complete","structuredContent":{"echoedLength":6,"echoedText":"golden"}}}
− test/golden/modern/tools-list.json
@@ -1,1 +0,0 @@-{"id":22,"jsonrpc":"2.0","result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"Golden Server","version":"1.0.0"}},"cacheScope":"private","resultType":"complete","tools":[{"description":"Echo the text","inputSchema":{"properties":{"text":{"description":"The text","type":"string"}},"required":["text"],"type":"object"},"name":"echo"}],"ttlMs":0}}
+ test/golden/modern/tools-list.request.json view
@@ -0,0 +1,1 @@+{"jsonrpc":"2.0","id":22,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"golden-client","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}
+ test/golden/modern/tools-list.response.json view
@@ -0,0 +1,1 @@+{"id":22,"jsonrpc":"2.0","result":{"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"Golden Server","version":"1.0.0"}},"cacheScope":"private","resultType":"complete","tools":[{"description":"Echo the text","inputSchema":{"properties":{"text":{"description":"The text","type":"string"}},"required":["text"],"type":"object"},"name":"echo"}],"ttlMs":0}}
− test/golden/modern/unsupported-version.json
@@ -1,1 +0,0 @@-{"error":{"code":-32022,"data":{"requested":"2099-01-01","supported":["2026-07-28","2025-11-25","2025-06-18","2025-03-26","2024-11-05"]},"message":"Unsupported protocol version"},"id":26,"jsonrpc":"2.0"}
+ test/golden/modern/unsupported-version.request.json view
@@ -0,0 +1,1 @@+{"jsonrpc":"2.0","id":26,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2099-01-01"}}}
+ test/golden/modern/unsupported-version.response.json view
@@ -0,0 +1,1 @@+{"error":{"code":-32022,"data":{"requested":"2099-01-01","supported":["2026-07-28","2025-11-25","2025-06-18","2025-03-26","2024-11-05"]},"message":"Unsupported protocol version"},"id":26,"jsonrpc":"2.0"}