diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,14 @@
 # Revision history for mcp-server
 
+## 0.3.0.0 - 2026-10-16
+
+* Raw JSON Schema keywords (ADR 0014): `SchemaRaw` is a new `SchemaType`
+  that serializes its keywords as given, for schemas the other shapes
+  cannot express, such as `null`, `anyOf`, `$defs`/`$ref` or numeric
+  bounds. A `schemaDescription` alongside it replaces any `description`
+  among the keywords. BREAKING: exhaustive matches on `SchemaType` need a
+  case for it.
+
 ## 0.2.0.2 - 2026-09-08
 
 * stdio: in-flight requests are drained at stdin EOF instead of being
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -420,6 +420,24 @@
 handlers = noHandlers { prompts = Just (promptListHandler, promptGetHandler) }
 ```
 
+Tool schemas built by hand can use `SchemaRaw` for anything the typed
+shapes cannot express — `null`, `anyOf`, `$defs` and `$ref`, numeric bounds
+— and it is serialized as given:
+
+```haskell
+import Data.Aeson (object, (.=))
+import qualified Data.Aeson.KeyMap as KeyMap
+import Data.Text (Text)
+
+nullableCount :: Schema
+nullableCount = schema $ SchemaRaw $ KeyMap.fromList
+    [ "anyOf" .= [object ["type" .= ("null" :: Text)], object ["type" .= ("integer" :: Text), "minimum" .= (0 :: Int)]] ]
+```
+
+A `$ref` such as `#/$defs/node` resolves against the root of the whole
+input schema, not the raw fragment it appears in, so a recursive schema is
+best written raw from the root down.
+
 ## Transports
 
 ### stdio
diff --git a/mcp-server.cabal b/mcp-server.cabal
--- a/mcp-server.cabal
+++ b/mcp-server.cabal
@@ -15,7 +15,7 @@
 -- PVP summary:     +-+------- breaking API changes
 --                  | | +----- non-breaking API additions
 --                  | | | +--- code changes with no API change
-version: 0.2.0.2
+version: 0.3.0.0
 -- A short (one-line) description of the package.
 synopsis: Library for building Model Context Protocol (MCP) servers
 -- A longer description of the package.
@@ -191,6 +191,7 @@
     Spec.ModernEra
     Spec.Progress
     Spec.ProtocolVersionNegotiation
+    Spec.RawSchema
     Spec.SchemaValidation
     Spec.Subscriptions
     Spec.TemplatesCompletions
diff --git a/src/MCP/Server/Types.hs b/src/MCP/Server/Types.hs
--- a/src/MCP/Server/Types.hs
+++ b/src/MCP/Server/Types.hs
@@ -528,6 +528,11 @@
   | SchemaArray Schema
   | SchemaObject [(Text, Schema)] [Text]
     -- ^ Properties and the names of the required ones
+  | SchemaRaw Object
+    -- ^ JSON Schema keywords, serialized as given. An escape hatch for
+    -- schemas the other shapes cannot express, such as @null@, @anyOf@,
+    -- @$defs@ and @$ref@, or numeric bounds. The library does not check
+    -- them; a tool's input schema must still describe an object.
   deriving (Show, Eq, Generic)
 
 -- | A schema with no description.
@@ -539,6 +544,8 @@
 describedSchema desc = Schema (Just desc)
 
 instance ToJSON Schema where
+  -- The description comes last, so for raw keywords it replaces any
+  -- description they contain.
   toJSON (Schema desc shape) = object $
     typeFields shape ++ maybe [] (\d -> ["description" .= d]) desc
     where
@@ -558,6 +565,7 @@
         , "properties" .= object (map (\(k, v) -> fromText k .= v) props)
         , "required" .= req
         ]
+      typeFields (SchemaRaw keywords) = KM.toList keywords
 
 -- | Prompt definition (2025-06-18 enhanced)
 data PromptDefinition = PromptDefinition
diff --git a/test/HspecMain.hs b/test/HspecMain.hs
--- a/test/HspecMain.hs
+++ b/test/HspecMain.hs
@@ -17,6 +17,7 @@
 import qualified Spec.ModernEra
 import qualified Spec.Progress
 import qualified Spec.ProtocolVersionNegotiation
+import qualified Spec.RawSchema
 import qualified Spec.Subscriptions
 import qualified Spec.TemplatesCompletions
 import qualified Spec.ToolCallParsing
@@ -39,6 +40,7 @@
     Spec.ModernEra.spec
     Spec.Progress.spec
     Spec.ProtocolVersionNegotiation.spec
+    Spec.RawSchema.spec
     Spec.Subscriptions.spec
     Spec.TemplatesCompletions.spec
     Spec.ToolCallParsing.spec
diff --git a/test/Spec/RawSchema.hs b/test/Spec/RawSchema.hs
new file mode 100644
--- /dev/null
+++ b/test/Spec/RawSchema.hs
@@ -0,0 +1,55 @@
+{-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE OverloadedStrings #-}
+
+-- | Coverage for raw JSON Schema keywords (ADR 0014).
+module Spec.RawSchema (spec) where
+
+import Data.Aeson
+import qualified Data.Aeson.KeyMap as KM
+import Data.Text (Text)
+import MCP.Server
+import Test.Hspec
+
+-- | Keywords none of the other shapes can express: a nullable union and a
+-- recursive definition.
+treeKeywords :: Object
+treeKeywords = KM.fromList
+  [ "$defs" .= object
+      [ "tree" .= object
+          [ "type" .= ("object" :: Text)
+          , "properties" .= object
+              [ "label" .= object ["type" .= ("string" :: Text)]
+              , "children" .= object ["type" .= ("array" :: Text), "items" .= object ["$ref" .= ("#/$defs/tree" :: Text)]]
+              ]
+          , "required" .= (["label", "children"] :: [Text])
+          ]
+      ]
+  , "type" .= ("object" :: Text)
+  , "properties" .= object
+      [ "root" .= object ["anyOf" .= [object ["type" .= ("null" :: Text)], object ["$ref" .= ("#/$defs/tree" :: Text)]]]
+      ]
+  , "required" .= (["root"] :: [Text])
+  ]
+
+spec :: Spec
+spec = describe "Raw schemas" $ do
+  it "serializes raw keywords as given" $
+    toJSON (schema (SchemaRaw treeKeywords)) `shouldBe` Object treeKeywords
+
+  it "adds a description, replacing one in the keywords" $
+    toJSON (describedSchema "Outer" (SchemaRaw (KM.fromList ["type" .= ("integer" :: Text), "description" .= ("Inner" :: Text)])))
+      `shouldBe` object ["type" .= ("integer" :: Text), "description" .= ("Outer" :: Text)]
+
+  it "nests inside the other shapes" $
+    toJSON (schema (SchemaObject [("count", schema (SchemaRaw (KM.fromList ["type" .= ("integer" :: Text), "minimum" .= (0 :: Int)])))] ["count"]))
+      `shouldBe` object
+        [ "type" .= ("object" :: Text)
+        , "properties" .= object ["count" .= object ["type" .= ("integer" :: Text), "minimum" .= (0 :: Int)]]
+        , "required" .= (["count"] :: [Text])
+        ]
+
+  it "reaches the wire as a tool's input schema" $ do
+    let definition = mkToolDefinition "plant" "Plant a tree" (schema (SchemaRaw treeKeywords))
+    toJSON definition `shouldSatisfy` \case
+      Object fields -> KM.lookup "inputSchema" fields == Just (Object treeKeywords)
+      _ -> False
diff --git a/test/TestHelpers.hs b/test/TestHelpers.hs
--- a/test/TestHelpers.hs
+++ b/test/TestHelpers.hs
@@ -18,6 +18,7 @@
   ) where
 
 import           Data.Aeson (Value (..))
+import qualified Data.Aeson.KeyMap as KM
 import           Data.Map   (Map)
 import qualified Data.Map   as Map
 import           Data.Text  (Text)
@@ -71,6 +72,9 @@
   SchemaBoolean    -> "boolean"
   SchemaArray _    -> "array"
   SchemaObject _ _ -> "object"
+  SchemaRaw keywords -> case KM.lookup "type" keywords of
+    Just (String name) -> name
+    _                  -> "raw"
 
 -- | The properties of an object schema (empty for non-objects).
 schemaProps :: Schema -> [(Text, Schema)]
