From bc9a3b237f120a90b730b4c033bdbf2d1dd6635d Mon Sep 17 00:00:00 2001 From: tom Date: Sat, 1 Aug 2026 09:41:45 +0200 Subject: [PATCH] Export the WAI application; make the golden fixtures a conformance corpus Two ideas from the dpella/mcp discussion (issue #9): - mcpApplication is exported (and re-exported from MCP.Server) so the MCP endpoint can be embedded into an existing WAI stack instead of transportRunHttp running its own Warp server. - The golden wire fixtures become a self-describing, API-agnostic conformance corpus: request/response pairs on disk enumerated by manifest.json, with the reference server documented in test/golden/README.md so other MCP implementations can consume it. Response fixtures are unchanged (renamed only); the runner is now manifest-driven. Version 0.2.1.0. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 17 +++ README.md | 33 +++++ mcp-server.cabal | 4 +- src/MCP/Server.hs | 4 +- src/MCP/Server/Transport/Http.hs | 13 +- test/Spec/GoldenWire.hs | 132 ++++++++---------- test/golden/README.md | 65 +++++++++ .../legacy/completion-complete.request.json | 1 + ...json => completion-complete.response.json} | 0 .../legacy/initialize-notifying.request.json | 1 + ...son => initialize-notifying.response.json} | 0 test/golden/legacy/initialize.request.json | 1 + ...itialize.json => initialize.response.json} | 0 test/golden/legacy/ping.request.json | 1 + .../legacy/{ping.json => ping.response.json} | 0 test/golden/legacy/prompts-get.request.json | 1 + ...pts-get.json => prompts-get.response.json} | 0 test/golden/legacy/prompts-list.request.json | 1 + ...s-list.json => prompts-list.response.json} | 0 .../golden/legacy/resources-list.request.json | 1 + ...list.json => resources-list.response.json} | 0 .../golden/legacy/resources-read.request.json | 1 + ...read.json => resources-read.response.json} | 0 .../resources-templates-list.request.json | 1 + ...=> resources-templates-list.response.json} | 0 .../legacy/tools-call-boom.request.json | 1 + ...oom.json => tools-call-boom.response.json} | 0 .../legacy/tools-call-echo.request.json | 1 + ...cho.json => tools-call-echo.response.json} | 0 .../legacy/tools-call-unknown.request.json | 1 + ....json => tools-call-unknown.response.json} | 0 test/golden/legacy/tools-list.request.json | 1 + ...ols-list.json => tools-list.response.json} | 0 test/golden/manifest.json | 126 +++++++++++++++++ .../modern/completion-complete.request.json | 1 + ...json => completion-complete.response.json} | 0 test/golden/modern/initialize.request.json | 1 + ...itialize.json => initialize.response.json} | 0 test/golden/modern/ping.request.json | 1 + .../modern/{ping.json => ping.response.json} | 0 test/golden/modern/prompts-get.request.json | 1 + ...pts-get.json => prompts-get.response.json} | 0 .../golden/modern/resources-read.request.json | 1 + ...read.json => resources-read.response.json} | 0 .../resources-templates-list.request.json | 1 + ...=> resources-templates-list.response.json} | 0 .../server-discover-notifying.request.json | 1 + ...> server-discover-notifying.response.json} | 0 .../modern/server-discover.request.json | 1 + ...ver.json => server-discover.response.json} | 0 .../modern/tools-call-echo.request.json | 1 + ...cho.json => tools-call-echo.response.json} | 0 test/golden/modern/tools-list.request.json | 1 + ...ols-list.json => tools-list.response.json} | 0 .../modern/unsupported-version.request.json | 1 + ...json => unsupported-version.response.json} | 0 56 files changed, 344 insertions(+), 74 deletions(-) create mode 100644 test/golden/README.md create mode 100644 test/golden/legacy/completion-complete.request.json rename test/golden/legacy/{completion-complete.json => completion-complete.response.json} (100%) create mode 100644 test/golden/legacy/initialize-notifying.request.json rename test/golden/legacy/{initialize-notifying.json => initialize-notifying.response.json} (100%) create mode 100644 test/golden/legacy/initialize.request.json rename test/golden/legacy/{initialize.json => initialize.response.json} (100%) create mode 100644 test/golden/legacy/ping.request.json rename test/golden/legacy/{ping.json => ping.response.json} (100%) create mode 100644 test/golden/legacy/prompts-get.request.json rename test/golden/legacy/{prompts-get.json => prompts-get.response.json} (100%) create mode 100644 test/golden/legacy/prompts-list.request.json rename test/golden/legacy/{prompts-list.json => prompts-list.response.json} (100%) create mode 100644 test/golden/legacy/resources-list.request.json rename test/golden/legacy/{resources-list.json => resources-list.response.json} (100%) create mode 100644 test/golden/legacy/resources-read.request.json rename test/golden/legacy/{resources-read.json => resources-read.response.json} (100%) create mode 100644 test/golden/legacy/resources-templates-list.request.json rename test/golden/legacy/{resources-templates-list.json => resources-templates-list.response.json} (100%) create mode 100644 test/golden/legacy/tools-call-boom.request.json rename test/golden/legacy/{tools-call-boom.json => tools-call-boom.response.json} (100%) create mode 100644 test/golden/legacy/tools-call-echo.request.json rename test/golden/legacy/{tools-call-echo.json => tools-call-echo.response.json} (100%) create mode 100644 test/golden/legacy/tools-call-unknown.request.json rename test/golden/legacy/{tools-call-unknown.json => tools-call-unknown.response.json} (100%) create mode 100644 test/golden/legacy/tools-list.request.json rename test/golden/legacy/{tools-list.json => tools-list.response.json} (100%) create mode 100644 test/golden/manifest.json create mode 100644 test/golden/modern/completion-complete.request.json rename test/golden/modern/{completion-complete.json => completion-complete.response.json} (100%) create mode 100644 test/golden/modern/initialize.request.json rename test/golden/modern/{initialize.json => initialize.response.json} (100%) create mode 100644 test/golden/modern/ping.request.json rename test/golden/modern/{ping.json => ping.response.json} (100%) create mode 100644 test/golden/modern/prompts-get.request.json rename test/golden/modern/{prompts-get.json => prompts-get.response.json} (100%) create mode 100644 test/golden/modern/resources-read.request.json rename test/golden/modern/{resources-read.json => resources-read.response.json} (100%) create mode 100644 test/golden/modern/resources-templates-list.request.json rename test/golden/modern/{resources-templates-list.json => resources-templates-list.response.json} (100%) create mode 100644 test/golden/modern/server-discover-notifying.request.json rename test/golden/modern/{server-discover-notifying.json => server-discover-notifying.response.json} (100%) create mode 100644 test/golden/modern/server-discover.request.json rename test/golden/modern/{server-discover.json => server-discover.response.json} (100%) create mode 100644 test/golden/modern/tools-call-echo.request.json rename test/golden/modern/{tools-call-echo.json => tools-call-echo.response.json} (100%) create mode 100644 test/golden/modern/tools-list.request.json rename test/golden/modern/{tools-list.json => tools-list.response.json} (100%) create mode 100644 test/golden/modern/unsupported-version.request.json rename test/golden/modern/{unsupported-version.json => unsupported-version.response.json} (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d10cc0..f2cd4bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Revision history for mcp-server +## 0.2.1.0 - ??? + +* 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 diff --git a/README.md b/README.md index 64864c3..8527721 100644 --- a/README.md +++ b/README.md @@ -348,6 +348,39 @@ application; the library only threads the identity through: - 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. + ## Examples The library includes several examples: diff --git a/mcp-server.cabal b/mcp-server.cabal index 9eb4fbd..783ef41 100644 --- a/mcp-server.cabal +++ b/mcp-server.cabal @@ -15,7 +15,7 @@ name: mcp-server -- PVP summary: +-+------- breaking API changes -- | | +----- non-breaking API additions -- | | | +--- code changes with no API change -version: 0.2.0.0 +version: 0.2.1.0 -- 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 @@ tested-with: GHC == 9.6.7 -- 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 diff --git a/src/MCP/Server.hs b/src/MCP/Server.hs index 264d24c..a3deb29 100644 --- a/src/MCP/Server.hs +++ b/src/MCP/Server.hs @@ -13,6 +13,7 @@ module MCP.Server , defaultStdioConfig , HttpConfig(..) , defaultHttpConfig + , mcpApplication -- * Change Notifications , McpNotifier(..) @@ -28,7 +29,8 @@ import MCP.Server.Notifications (McpNotifier (..), NotificationSource, 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 diff --git a/src/MCP/Server/Transport/Http.hs b/src/MCP/Server/Transport/Http.hs index e542633..b19b753 100644 --- a/src/MCP/Server/Transport/Http.hs +++ b/src/MCP/Server/Transport/Http.hs @@ -6,6 +6,7 @@ module MCP.Server.Transport.Http HttpConfig(..) , transportRunHttp , defaultHttpConfig + , mcpApplication -- * Request validation (exposed for testing) , BodyPeek(..) @@ -113,7 +114,17 @@ transportRunHttp config serverInfo handlers = do 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 diff --git a/test/Spec/GoldenWire.hs b/test/Spec/GoldenWire.hs index 1ccd62a..e33a329 100644 --- a/test/Spec/GoldenWire.hs +++ b/test/Spec/GoldenWire.hs @@ -1,30 +1,37 @@ {-# LANGUAGE OverloadedStrings #-} --- | 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 (@\.request.json@) and the reference server's +-- exact response (@\.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.Handlers (handleMcpMessage) @@ -41,7 +48,8 @@ goldenServerInfo = McpServerInfo } -- 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) @@ -91,82 +99,64 @@ extendedHandlers = goldenHandlers completionResult (filter (T.isPrefixOf partial) ["alpha", "beta"]) } --- | (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) + 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 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 diff --git a/test/golden/README.md b/test/golden/README.md new file mode 100644 index 0000000..6350b03 --- /dev/null +++ b/test/golden/README.md @@ -0,0 +1,65 @@ +# MCP wire-format conformance corpus + +Each case in this directory is a pair of files: + +- `.request.json` — a single JSON-RPC request, exactly as it would + arrive on the wire (one line). +- `.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. + +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: ` | +| 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, !` | +| 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 | + +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. diff --git a/test/golden/legacy/completion-complete.request.json b/test/golden/legacy/completion-complete.request.json new file mode 100644 index 0000000..0b948f9 --- /dev/null +++ b/test/golden/legacy/completion-complete.request.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":12,"method":"completion/complete","params":{"ref":{"type":"ref/prompt","name":"greet"},"argument":{"name":"name","value":"al"}}} diff --git a/test/golden/legacy/completion-complete.json b/test/golden/legacy/completion-complete.response.json similarity index 100% rename from test/golden/legacy/completion-complete.json rename to test/golden/legacy/completion-complete.response.json diff --git a/test/golden/legacy/initialize-notifying.request.json b/test/golden/legacy/initialize-notifying.request.json new file mode 100644 index 0000000..2f98421 --- /dev/null +++ b/test/golden/legacy/initialize-notifying.request.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":13,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"golden-client","version":"1.0"}}} diff --git a/test/golden/legacy/initialize-notifying.json b/test/golden/legacy/initialize-notifying.response.json similarity index 100% rename from test/golden/legacy/initialize-notifying.json rename to test/golden/legacy/initialize-notifying.response.json diff --git a/test/golden/legacy/initialize.request.json b/test/golden/legacy/initialize.request.json new file mode 100644 index 0000000..c302627 --- /dev/null +++ b/test/golden/legacy/initialize.request.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"golden-client","version":"1.0"}}} diff --git a/test/golden/legacy/initialize.json b/test/golden/legacy/initialize.response.json similarity index 100% rename from test/golden/legacy/initialize.json rename to test/golden/legacy/initialize.response.json diff --git a/test/golden/legacy/ping.request.json b/test/golden/legacy/ping.request.json new file mode 100644 index 0000000..b1c6af8 --- /dev/null +++ b/test/golden/legacy/ping.request.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":2,"method":"ping"} diff --git a/test/golden/legacy/ping.json b/test/golden/legacy/ping.response.json similarity index 100% rename from test/golden/legacy/ping.json rename to test/golden/legacy/ping.response.json diff --git a/test/golden/legacy/prompts-get.request.json b/test/golden/legacy/prompts-get.request.json new file mode 100644 index 0000000..6e67371 --- /dev/null +++ b/test/golden/legacy/prompts-get.request.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":8,"method":"prompts/get","params":{"name":"greet","arguments":{"name":"World"}}} diff --git a/test/golden/legacy/prompts-get.json b/test/golden/legacy/prompts-get.response.json similarity index 100% rename from test/golden/legacy/prompts-get.json rename to test/golden/legacy/prompts-get.response.json diff --git a/test/golden/legacy/prompts-list.request.json b/test/golden/legacy/prompts-list.request.json new file mode 100644 index 0000000..8b4706e --- /dev/null +++ b/test/golden/legacy/prompts-list.request.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":7,"method":"prompts/list"} diff --git a/test/golden/legacy/prompts-list.json b/test/golden/legacy/prompts-list.response.json similarity index 100% rename from test/golden/legacy/prompts-list.json rename to test/golden/legacy/prompts-list.response.json diff --git a/test/golden/legacy/resources-list.request.json b/test/golden/legacy/resources-list.request.json new file mode 100644 index 0000000..6776186 --- /dev/null +++ b/test/golden/legacy/resources-list.request.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":9,"method":"resources/list"} diff --git a/test/golden/legacy/resources-list.json b/test/golden/legacy/resources-list.response.json similarity index 100% rename from test/golden/legacy/resources-list.json rename to test/golden/legacy/resources-list.response.json diff --git a/test/golden/legacy/resources-read.request.json b/test/golden/legacy/resources-read.request.json new file mode 100644 index 0000000..0271493 --- /dev/null +++ b/test/golden/legacy/resources-read.request.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":10,"method":"resources/read","params":{"uri":"resource://info"}} diff --git a/test/golden/legacy/resources-read.json b/test/golden/legacy/resources-read.response.json similarity index 100% rename from test/golden/legacy/resources-read.json rename to test/golden/legacy/resources-read.response.json diff --git a/test/golden/legacy/resources-templates-list.request.json b/test/golden/legacy/resources-templates-list.request.json new file mode 100644 index 0000000..d22c779 --- /dev/null +++ b/test/golden/legacy/resources-templates-list.request.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":11,"method":"resources/templates/list"} diff --git a/test/golden/legacy/resources-templates-list.json b/test/golden/legacy/resources-templates-list.response.json similarity index 100% rename from test/golden/legacy/resources-templates-list.json rename to test/golden/legacy/resources-templates-list.response.json diff --git a/test/golden/legacy/tools-call-boom.request.json b/test/golden/legacy/tools-call-boom.request.json new file mode 100644 index 0000000..7898db3 --- /dev/null +++ b/test/golden/legacy/tools-call-boom.request.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"boom","arguments":{}}} diff --git a/test/golden/legacy/tools-call-boom.json b/test/golden/legacy/tools-call-boom.response.json similarity index 100% rename from test/golden/legacy/tools-call-boom.json rename to test/golden/legacy/tools-call-boom.response.json diff --git a/test/golden/legacy/tools-call-echo.request.json b/test/golden/legacy/tools-call-echo.request.json new file mode 100644 index 0000000..baf9416 --- /dev/null +++ b/test/golden/legacy/tools-call-echo.request.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"echo","arguments":{"text":"hi"}}} diff --git a/test/golden/legacy/tools-call-echo.json b/test/golden/legacy/tools-call-echo.response.json similarity index 100% rename from test/golden/legacy/tools-call-echo.json rename to test/golden/legacy/tools-call-echo.response.json diff --git a/test/golden/legacy/tools-call-unknown.request.json b/test/golden/legacy/tools-call-unknown.request.json new file mode 100644 index 0000000..0b6023e --- /dev/null +++ b/test/golden/legacy/tools-call-unknown.request.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"nope","arguments":{}}} diff --git a/test/golden/legacy/tools-call-unknown.json b/test/golden/legacy/tools-call-unknown.response.json similarity index 100% rename from test/golden/legacy/tools-call-unknown.json rename to test/golden/legacy/tools-call-unknown.response.json diff --git a/test/golden/legacy/tools-list.request.json b/test/golden/legacy/tools-list.request.json new file mode 100644 index 0000000..195e2e9 --- /dev/null +++ b/test/golden/legacy/tools-list.request.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":3,"method":"tools/list"} diff --git a/test/golden/legacy/tools-list.json b/test/golden/legacy/tools-list.response.json similarity index 100% rename from test/golden/legacy/tools-list.json rename to test/golden/legacy/tools-list.response.json diff --git a/test/golden/manifest.json b/test/golden/manifest.json new file mode 100644 index 0000000..70b045b --- /dev/null +++ b/test/golden/manifest.json @@ -0,0 +1,126 @@ +{ + "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-boom", + "handlers": "base", + "notifications": false + }, + { + "name": "legacy/tools-call-echo", + "handlers": "base", + "notifications": false + }, + { + "name": "legacy/tools-call-unknown", + "handlers": "base", + "notifications": false + }, + { + "name": "legacy/tools-list", + "handlers": "base", + "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-echo", + "handlers": "base", + "notifications": false + }, + { + "name": "modern/tools-list", + "handlers": "base", + "notifications": false + }, + { + "name": "modern/unsupported-version", + "handlers": "base", + "notifications": false + } + ] +} diff --git a/test/golden/modern/completion-complete.request.json b/test/golden/modern/completion-complete.request.json new file mode 100644 index 0000000..a9bedf4 --- /dev/null +++ b/test/golden/modern/completion-complete.request.json @@ -0,0 +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":{}}}} diff --git a/test/golden/modern/completion-complete.json b/test/golden/modern/completion-complete.response.json similarity index 100% rename from test/golden/modern/completion-complete.json rename to test/golden/modern/completion-complete.response.json diff --git a/test/golden/modern/initialize.request.json b/test/golden/modern/initialize.request.json new file mode 100644 index 0000000..ab7d973 --- /dev/null +++ b/test/golden/modern/initialize.request.json @@ -0,0 +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":{}}}} diff --git a/test/golden/modern/initialize.json b/test/golden/modern/initialize.response.json similarity index 100% rename from test/golden/modern/initialize.json rename to test/golden/modern/initialize.response.json diff --git a/test/golden/modern/ping.request.json b/test/golden/modern/ping.request.json new file mode 100644 index 0000000..4c23d8d --- /dev/null +++ b/test/golden/modern/ping.request.json @@ -0,0 +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":{}}}} diff --git a/test/golden/modern/ping.json b/test/golden/modern/ping.response.json similarity index 100% rename from test/golden/modern/ping.json rename to test/golden/modern/ping.response.json diff --git a/test/golden/modern/prompts-get.request.json b/test/golden/modern/prompts-get.request.json new file mode 100644 index 0000000..5d698a0 --- /dev/null +++ b/test/golden/modern/prompts-get.request.json @@ -0,0 +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":{}}}} diff --git a/test/golden/modern/prompts-get.json b/test/golden/modern/prompts-get.response.json similarity index 100% rename from test/golden/modern/prompts-get.json rename to test/golden/modern/prompts-get.response.json diff --git a/test/golden/modern/resources-read.request.json b/test/golden/modern/resources-read.request.json new file mode 100644 index 0000000..1d3122d --- /dev/null +++ b/test/golden/modern/resources-read.request.json @@ -0,0 +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":{}}}} diff --git a/test/golden/modern/resources-read.json b/test/golden/modern/resources-read.response.json similarity index 100% rename from test/golden/modern/resources-read.json rename to test/golden/modern/resources-read.response.json diff --git a/test/golden/modern/resources-templates-list.request.json b/test/golden/modern/resources-templates-list.request.json new file mode 100644 index 0000000..ff57037 --- /dev/null +++ b/test/golden/modern/resources-templates-list.request.json @@ -0,0 +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":{}}}} diff --git a/test/golden/modern/resources-templates-list.json b/test/golden/modern/resources-templates-list.response.json similarity index 100% rename from test/golden/modern/resources-templates-list.json rename to test/golden/modern/resources-templates-list.response.json diff --git a/test/golden/modern/server-discover-notifying.request.json b/test/golden/modern/server-discover-notifying.request.json new file mode 100644 index 0000000..3877591 --- /dev/null +++ b/test/golden/modern/server-discover-notifying.request.json @@ -0,0 +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":{}}}} diff --git a/test/golden/modern/server-discover-notifying.json b/test/golden/modern/server-discover-notifying.response.json similarity index 100% rename from test/golden/modern/server-discover-notifying.json rename to test/golden/modern/server-discover-notifying.response.json diff --git a/test/golden/modern/server-discover.request.json b/test/golden/modern/server-discover.request.json new file mode 100644 index 0000000..15d9fde --- /dev/null +++ b/test/golden/modern/server-discover.request.json @@ -0,0 +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":{}}}} diff --git a/test/golden/modern/server-discover.json b/test/golden/modern/server-discover.response.json similarity index 100% rename from test/golden/modern/server-discover.json rename to test/golden/modern/server-discover.response.json diff --git a/test/golden/modern/tools-call-echo.request.json b/test/golden/modern/tools-call-echo.request.json new file mode 100644 index 0000000..d998d2d --- /dev/null +++ b/test/golden/modern/tools-call-echo.request.json @@ -0,0 +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":{}}}} diff --git a/test/golden/modern/tools-call-echo.json b/test/golden/modern/tools-call-echo.response.json similarity index 100% rename from test/golden/modern/tools-call-echo.json rename to test/golden/modern/tools-call-echo.response.json diff --git a/test/golden/modern/tools-list.request.json b/test/golden/modern/tools-list.request.json new file mode 100644 index 0000000..57262c3 --- /dev/null +++ b/test/golden/modern/tools-list.request.json @@ -0,0 +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":{}}}} diff --git a/test/golden/modern/tools-list.json b/test/golden/modern/tools-list.response.json similarity index 100% rename from test/golden/modern/tools-list.json rename to test/golden/modern/tools-list.response.json diff --git a/test/golden/modern/unsupported-version.request.json b/test/golden/modern/unsupported-version.request.json new file mode 100644 index 0000000..11c8c8f --- /dev/null +++ b/test/golden/modern/unsupported-version.request.json @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":26,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2099-01-01"}}} diff --git a/test/golden/modern/unsupported-version.json b/test/golden/modern/unsupported-version.response.json similarity index 100% rename from test/golden/modern/unsupported-version.json rename to test/golden/modern/unsupported-version.response.json