packages feed

mcp-server-0.2.0.1: test/Spec/GoldenWire.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TemplateHaskell   #-}

-- | 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 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 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)
import System.Environment (lookupEnv)
import Test.Hspec

goldenServerInfo :: McpServerInfo
goldenServerInfo = McpServerInfo
  { serverName = "Golden Server"
  , serverVersion = "1.0.0"
  , serverInstructions = "Golden fixture server"
  }

-- Deterministic manual handlers (no Template Haskell): one prompt, one
-- 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)
  , resources = Just (resourceList, resourceRead)
  , tools = Just (toolList, toolCall)
  }
  where
    promptList _ = pure
      [ mkPromptDefinition "greet" "Greet someone"
          [ArgumentDefinition "name" "Who to greet" True]
      ]
    promptGet _ name args = case name of
      "greet" -> pure $ Right $ PromptResult (Just "A greeting")
        [PromptMessage RoleUser (ContentText ("Hello, " <> Map.findWithDefault "?" "name" args <> "!"))]
      _ -> pure $ Left $ InvalidPromptName name

    resourceList _ = pure
      [ (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
      [ mkToolDefinition "echo" "Echo the text"
          (Schema Nothing (SchemaObject
            [("text", Schema (Just "The text") (SchemaString Nothing))] ["text"]))
      ]
    toolCall _ name args = case name of
      "echo" -> case Map.lookup "text" args of
        Just (String t) -> pure $ Right $ toolResult [ContentText ("echo: " <> t)]
        _               -> pure $ Left $ MissingRequiredParams "field 'text' is missing"
      "boom" -> pure $ Right $ toolError "kaboom"
      _      -> pure $ Left $ UnknownTool name

-- 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
      [ (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
           )
  }

-- | One manifest entry: which case, and which reference-server variant
-- answers it.
data GoldenCase = GoldenCase
  { caseName          :: FilePath
  , caseHandlers      :: Text  -- ^ "base" or "extended"
  , caseNotifications :: Bool
  }

instance FromJSON GoldenCase where
  parseJSON = withObject "GoldenCase" $ \o -> GoldenCase
    <$> o .: "name"
    <*> o .: "handlers"
    <*> o .: "notifications"

newtype Manifest = Manifest [GoldenCase]

instance FromJSON Manifest where
  parseJSON = withObject "Manifest" $ \o -> Manifest <$> o .: "cases"

-- | 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 :: 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 (\_ -> pure ()) handlers anonymousContext message
  case maybeResponse of
    Just responseMsg -> pure $ encodeJsonRpcMessage responseMsg
    Nothing          -> fail "expected a response"

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 responsePath (encode actual)
      else do
        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
  cases <- runIO loadManifest
  forM_ cases goldenCase