From 10b44452764b90444022c75b61163fd9cc366b6c Mon Sep 17 00:00:00 2001 From: Christophe Combelles Date: Sat, 22 Aug 2026 19:44:09 +0200 Subject: [PATCH 1/2] Refuse GET /mcp instead of answering with a closed stream A GET opens the stream a server uses to speak to a client unprompted. The engine never speaks first, so it answered with an empty stream that closed at once, which a client reads as a dropped connection and reconnects. The pair then loops for as long as both are up: one instance took 71 644 GETs in 21 hours, and since each wake ran a garbage collection over several gigabytes of loaded data, it burned two and a half cores while idle. 405 is what the protocol asks of a server that offers no such stream, and the endpoint already answered every other method that way. --- CHANGELOG.md | 10 ++++++++++ src/API/MCP.hs | 17 +++++------------ test/MCPStreamSpec.hs | 41 +++++++++++++++++++++++++++++++++++++++++ volca.cabal | 1 + 4 files changed, 57 insertions(+), 12 deletions(-) create mode 100644 test/MCPStreamSpec.hs diff --git a/CHANGELOG.md b/CHANGELOG.md index 36c42777..80636f84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## [Unreleased] +### Fixed +- A `GET /mcp` is now refused with 405 instead of answered with an empty + stream. That stream is how a server speaks to a client unprompted; VoLCA + never speaks first, so it was returned already closed, which a client reads + as a dropped connection and reconnects at once. Server and client then loop + for as long as both are up: one such pair sent 71 644 requests in 21 hours, + and on an engine holding several gigabytes of loaded data each wake cost a + full garbage collection, so the engine burned two and a half cores doing + nothing. A 405 says there is no stream to open, and the client stops asking. + ## [0.10.0] - 2026-08-22 ### Added diff --git a/src/API/MCP.hs b/src/API/MCP.hs index 1a69da84..29fd2223 100644 --- a/src/API/MCP.hs +++ b/src/API/MCP.hs @@ -177,18 +177,6 @@ mcpApp dbManager presets hasFrontend mHosting mName markActivity = do wantsSse = "text/event-stream" `BS.isInfixOf` acceptHdr st <- readIORef stateRef case method of - -- GET: open SSE stream for server-initiated messages. - -- VoLCA is stateless so we return an empty stream immediately. - "GET" -> - respond $ - responseLBS - status200 - [ (hContentType, "text/event-stream; charset=utf-8") - , ("Cache-Control", "no-cache") - , ("Connection", "keep-alive") - , ("Mcp-Session-Id", TE.encodeUtf8 (mcpSessionId st)) - ] - "" "POST" -> do body <- strictRequestBody req case eitherDecode body of @@ -210,6 +198,11 @@ mcpApp dbManager presets hasFrontend mHosting mName markActivity = do if wantsSse then respond $ sseResponse (mcpSessionId st) val else respond $ jsonResponse (mcpSessionId st) val + -- Everything else, GET included. A GET opens the stream a server + -- uses to speak first; VoLCA never does, and answering it with a + -- stream that closes at once reads to a client as a dropped + -- connection, which it reconnects, forever. 405 says there is no + -- stream to open, and the client stops asking. _ -> respond $ responseLBS status405 [(hContentType, "application/json")] $ diff --git a/test/MCPStreamSpec.hs b/test/MCPStreamSpec.hs new file mode 100644 index 00000000..783b04fd --- /dev/null +++ b/test/MCPStreamSpec.hs @@ -0,0 +1,41 @@ +{-# LANGUAGE OverloadedStrings #-} + +{- | The @\/mcp@ endpoint answers POST and refuses everything else. + +A GET opens the stream a server uses to speak to a client unprompted. VoLCA +never speaks first, and an empty stream closed at once reads to a client as a +dropped connection: it reconnects, and the pair loops for as long as both are +up. One such pair sent 71 644 GETs in 21 hours. 405 says there is no stream, +and the client stops asking. +-} +module MCPStreamSpec (spec) where + +import Data.IORef +import Network.HTTP.Types (Method) +import Network.HTTP.Types.Status (statusCode) +import Network.Wai (defaultRequest, requestMethod, responseStatus) +import Network.Wai.Internal (ResponseReceived (..)) +import Test.Hspec + +import API.MCP (mcpApp) +import Config (defaultConfig) +import Database.Manager (initDatabaseManager) + +-- | Drive one request of the given method through the endpoint, report its status. +status :: Method -> IO Int +status m = do + manager <- initDatabaseManager defaultConfig True + app <- mcpApp manager [] False Nothing Nothing (pure ()) + ref <- newIORef Nothing + _ <- app defaultRequest{requestMethod = m} $ \resp -> do + writeIORef ref (Just resp) + pure ResponseReceived + maybe (fail "no response") (pure . statusCode . responseStatus) =<< readIORef ref + +spec :: Spec +spec = describe "the /mcp endpoint" $ do + it "refuses a GET rather than hand back a stream that closes at once" $ + status "GET" `shouldReturn` 405 + + it "refuses a DELETE the same way" $ + status "DELETE" `shouldReturn` 405 diff --git a/volca.cabal b/volca.cabal index 09066997..a2829a38 100644 --- a/volca.cabal +++ b/volca.cabal @@ -296,6 +296,7 @@ test-suite lca-tests , ILCDWriterSpec , MCPSchemaSpec , MCPDispatchSpec + , MCPStreamSpec , BatchImpactsSpec , MCPEnrichSpec , MCPColumnarSpec From 9c22a63f3d96d6e8685082d823dbfa8a518d4480 Mon Sep 17 00:00:00 2001 From: Christophe Combelles Date: Sat, 22 Aug 2026 20:12:57 +0200 Subject: [PATCH 2/2] Say which method the endpoint allows when it refuses one A 405 has to carry an Allow header naming the methods the resource does take (RFC 9110). The arm answered without one, which mattered little while it only ever saw a stray method, and matters now that every GET lands there: a GET is the request an off-the-shelf client probes with, and the header is what tells it to try POST instead. The CHANGELOG entry also states how the change reads for the next release number, since the release step decides that by what a release removes. --- CHANGELOG.md | 5 ++++- src/API/MCP.hs | 12 +++++++----- test/MCPStreamSpec.hs | 19 ++++++++++++------- 3 files changed, 23 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80636f84..b3f11414 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,10 @@ for as long as both are up: one such pair sent 71 644 requests in 21 hours, and on an engine holding several gigabytes of loaded data each wake cost a full garbage collection, so the engine burned two and a half cores doing - nothing. A 405 says there is no stream to open, and the client stops asking. + nothing. A 405 says there is no stream to open, and the client stops + asking, and it is what the protocol asks of a server that offers no such + stream. This reads as a fix and not a removal: no working client relied + on the old answer, the one observed behaviour was the loop. ## [0.10.0] - 2026-08-22 diff --git a/src/API/MCP.hs b/src/API/MCP.hs index 29fd2223..fb43d593 100644 --- a/src/API/MCP.hs +++ b/src/API/MCP.hs @@ -51,7 +51,7 @@ import qualified Method.Explain as Explain import Method.Mapping (LCIAOutcome (..), MappingStats (..), SimilarCF (..), SimilarReason (..), UncharacterizedFlow (..), applyLongTermMode, computeLCIAScoreAuto, computeLCIAScoreFromTables, computeMappingStats, defaultUncharacterizedOpts, inventoryContributions, longTermModeFromExclude) import qualified Method.Mapping as Mapping import Method.Types (FlowDirection (..), Method (..), MethodCF (..), MethodCollection (..), ScoringSet (..)) -import Network.HTTP.Types.Header (RequestHeaders, hAccept, hHost) +import Network.HTTP.Types.Header (RequestHeaders, hAccept, hAllow, hHost) import Numeric (showFFloat) import Progress (ProgressLevel (Warning), reportProgress) import qualified Service @@ -204,10 +204,12 @@ mcpApp dbManager presets hasFrontend mHosting mName markActivity = do -- connection, which it reconnects, forever. 405 says there is no -- stream to open, and the client stops asking. _ -> - respond $ - responseLBS status405 [(hContentType, "application/json")] $ - encode $ - rpcError Null (-32700) "Method not allowed" + respond + $ responseLBS + status405 + [(hContentType, "application/json"), (hAllow, "POST")] + $ encode + $ rpcError Null (-32700) "Method not allowed" where jsonResponse sid v = responseLBS diff --git a/test/MCPStreamSpec.hs b/test/MCPStreamSpec.hs index 783b04fd..c346a36b 100644 --- a/test/MCPStreamSpec.hs +++ b/test/MCPStreamSpec.hs @@ -10,10 +10,12 @@ and the client stops asking. -} module MCPStreamSpec (spec) where +import Data.ByteString (ByteString) import Data.IORef import Network.HTTP.Types (Method) +import Network.HTTP.Types.Header (hAllow) import Network.HTTP.Types.Status (statusCode) -import Network.Wai (defaultRequest, requestMethod, responseStatus) +import Network.Wai (defaultRequest, requestMethod, responseHeaders, responseStatus) import Network.Wai.Internal (ResponseReceived (..)) import Test.Hspec @@ -21,21 +23,24 @@ import API.MCP (mcpApp) import Config (defaultConfig) import Database.Manager (initDatabaseManager) --- | Drive one request of the given method through the endpoint, report its status. -status :: Method -> IO Int -status m = do +{- | Drive one request of the given method through the endpoint, +report its status and the methods it says are allowed. +-} +answer :: Method -> IO (Int, Maybe ByteString) +answer m = do manager <- initDatabaseManager defaultConfig True app <- mcpApp manager [] False Nothing Nothing (pure ()) ref <- newIORef Nothing _ <- app defaultRequest{requestMethod = m} $ \resp -> do writeIORef ref (Just resp) pure ResponseReceived - maybe (fail "no response") (pure . statusCode . responseStatus) =<< readIORef ref + let read' resp = (statusCode (responseStatus resp), lookup hAllow (responseHeaders resp)) + maybe (fail "no response") (pure . read') =<< readIORef ref spec :: Spec spec = describe "the /mcp endpoint" $ do it "refuses a GET rather than hand back a stream that closes at once" $ - status "GET" `shouldReturn` 405 + answer "GET" `shouldReturn` (405, Just "POST") it "refuses a DELETE the same way" $ - status "DELETE" `shouldReturn` 405 + answer "DELETE" `shouldReturn` (405, Just "POST")