Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,18 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
gate instead of the answer.

### Changed
- **The MCP client talks to the public Streamable-HTTP servers** (ADR-181).
It now offers both media types the transport requires
(`Accept: application/json, text/event-stream`) — the reference servers
answered the JSON-only header with 406 — and reads an answer a server
frames as `text/event-stream` exactly like a plain JSON one: the single
JSON-RPC response is unwrapped, server notifications on the same stream
are passed over, a stream with no response is a malformed answer. Nothing
else changes: no stream is held open or resumed, no server-initiated
request is answered, stdio stays out of scope. Measured against
`mcp.deepwiki.com`, `mcp.context7.com` and `learn.microsoft.com/api/mcp`
before the change: 406 each.

- **AGENTS.md files synchronized with the repository state.** Root slimmed from 356 to 119 lines by moving content into the scoped files it belongs to; stale inventories refreshed (TCA files, database tables, backend templates, JS modules, `Services.Dashboard.php`); phantom `TCA/Overrides/` and dead `MEMORY.md` references removed; generic workflow boilerplate in `.github/workflows/AGENTS.md` replaced with this repository's actual conventions (no local jobs, release flow, dependency automation).

## [0.30.0] - 2026-08-18
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ public static function forUnsupportedContentType(string $identifier, string $con
{
return new self(
sprintf(
'MCP server "%s" answered with content type "%s"; this client reads JSON responses only and does not consume an event stream.',
'MCP server "%s" answered with content type "%s"; this client reads a JSON response, plain or framed as a single event-stream message, and nothing else.',
$identifier,
self::clip($contentType),
),
Expand Down
80 changes: 74 additions & 6 deletions Classes/Service/Tool/Mcp/McpHttpTransport.php
Original file line number Diff line number Diff line change
Expand Up @@ -185,11 +185,15 @@
$request = $this->requestFactory
->createRequest('POST', $server->url)
->withHeader('Content-Type', 'application/json')
// JSON only, stated honestly: this client does not consume an event
// stream, so it does not claim to accept one. A server that can
// only answer with a stream rejects the request, which is a clearer
// failure than a body we would refuse after the fact.
->withHeader('Accept', 'application/json')
// Both media types, because the Streamable HTTP transport requires
// a client to offer both on every POST — the reference servers
// answer `application/json` alone with 406. Offering the stream
// does not mean holding one: a server that frames its answer as
// `text/event-stream` gets the single JSON-RPC response unwrapped
// in {@see self::decodeResult()}, and nothing here keeps a stream
// open, resumes one, or answers a request the server initiates
// (ADR-181).
->withHeader('Accept', 'application/json, text/event-stream')
->withBody($this->streamFactory->createStream($body));

if ($sessionId !== null && $sessionId !== '') {
Expand Down Expand Up @@ -306,7 +310,9 @@
private function decodeResult(McpServerRecord $server, string $body, int $status, string $contentType): array
{
$mediaType = strtolower(trim(explode(';', $contentType, 2)[0]));
if ($mediaType !== '' && $mediaType !== 'application/json') {
if ($mediaType === 'text/event-stream') {
$body = $this->unframeEventStream($server, $body);
} elseif ($mediaType !== '' && $mediaType !== 'application/json') {
throw McpTransportException::forUnsupportedContentType($server->identifier, $mediaType);
}

Expand Down Expand Up @@ -339,4 +345,66 @@
/** @var array<string, mixed> $result */
return $result;
}

/**
* The one JSON-RPC response inside an event-stream framed answer, as the
* JSON text it was sent as.
*
* The body is read as a whole — it has already been bounded by
* {@see self::readBounded()} and the server has closed it — and parsed by
* the SSE rules that matter for a single request/response exchange: events
* are separated by a blank line, the `data:` lines of one event join with a
* newline, `event:`, `id:`, `retry:` and comment lines carry nothing we
* read. A server may put its own notifications on the same stream before
* the response (the transport allows it); this client declared no
* capabilities, so anything that carries a `method` is passed over and the
* first message that is a response — a `result` or an `error` — is the
* answer. A stream with no such message is a malformed answer, named as one.
*
* @throws McpTransportException when the stream carries no response
*/
private function unframeEventStream(McpServerRecord $server, string $body): string

Check failure on line 366 in Classes/Service/Tool/Mcp/McpHttpTransport.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 21 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=netresearch_t3x-nr-llm&issues=AaAe5IPqVrFtZNaZybmP&open=AaAe5IPqVrFtZNaZybmP&pullRequest=835
{
$messages = [];
$data = [];

foreach (preg_split('/\r\n|\r|\n/', $body) ?: [] as $line) {
if ($line === '') {
if ($data !== []) {
$messages[] = implode("\n", $data);
$data = [];
}

continue;
}

if (str_starts_with($line, 'data:')) {
$payload = substr($line, 5);
$data[] = str_starts_with($payload, ' ') ? substr($payload, 1) : $payload;
}
// `event:`, `id:`, `retry:` and `:` comments are not read.
}

if ($data !== []) {
$messages[] = implode("\n", $data);
}

foreach ($messages as $message) {
$decoded = json_decode($message, true);
if (!\is_array($decoded) || isset($decoded['method'])) {
// Not JSON, or a server-initiated notification/request: neither
// is the answer to the request this client made.
continue;
}

if (\array_key_exists('result', $decoded) || \array_key_exists('error', $decoded)) {
return $message;
}
}

throw McpTransportException::forMalformedResponse(
$server->identifier,
$messages === [] ? 'the event stream carried no message' : 'the event stream carried no response to the request',
);
}
}
11 changes: 11 additions & 0 deletions Documentation/Administration/McpServers.rst
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,17 @@ How it works
switched on one by one, exactly like the builtin tools in the
:ref:`Tools module <administration-tools>`.

What the client speaks
======================

Plain HTTP: one POST per JSON-RPC message to the server's endpoint, offering
both media types the Streamable HTTP transport requires (``application/json``
and ``text/event-stream``). A server may answer a request as plain JSON or as
a single event-stream framed message; both read the same here. What the
client does **not** do: hold a stream open, resume one, answer a request the
server initiates, or speak stdio (:ref:`ADR-116 <adr-116>`,
:ref:`ADR-181 <adr-181>`).

Is the server alive?
====================

Expand Down
4 changes: 3 additions & 1 deletion Documentation/Adr/Adr116CentralToolingAuthority.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
ADR-116: Central tooling authority — nr_llm owns builtin + MCP tools
============================================================================

:Status: Accepted
:Status: Accepted (its transport section no longer refuses an event-stream framed
answer — see :ref:`ADR-181 <adr-181>`)
:Date: 2026-07-22
:Amended: 2026-08-20 by :ref:`ADR-181 <adr-181>`
:Authors: Netresearch DTT GmbH

.. _adr-116-context:
Expand Down
3 changes: 2 additions & 1 deletion Documentation/Adr/Adr161McpClientConformance.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ ADR-161: One conformance suite for every MCP connection we support
:Status: Accepted (the timeouts row now covers the whole operation — see
:ref:`ADR-170 <adr-170>`)
:Date: 2026-08-11
:Amended: 2026-08-13 by :ref:`ADR-170 <adr-170>`
:Amended: 2026-08-13 by :ref:`ADR-170 <adr-170>`; 2026-08-20 by
:ref:`ADR-181 <adr-181>` (the "no SSE" edge is now "no live stream")

Context
=======
Expand Down
104 changes: 104 additions & 0 deletions Documentation/Adr/Adr181EventStreamFramedAnswers.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
.. include:: /Includes.rst.txt

.. _adr-181:

============================================================================
ADR-181: The client reads an event-stream framed answer, and holds no stream
============================================================================

:Status: Accepted
:Date: 2026-08-20
:Amends: :ref:`ADR-116 <adr-116>` (its transport section) and
:ref:`ADR-161 <adr-161>` (its "no SSE" edge)
:Authors: Netresearch DTT GmbH

.. _adr-181-context:

Context
=======

:ref:`ADR-116 <adr-116>` drew the MCP client's edges as "HTTP only, no stdio,
no SSE", and the transport said so on the wire: ``Accept: application/json``,
with a comment calling that honest — a client that does not consume a stream
does not claim to accept one.

The Streamable HTTP transport reads it differently. A client **must** offer
both media types on every POST, and a server may answer a request either as
plain JSON or as ``text/event-stream`` carrying the JSON-RPC response as a
``data:`` event. The reference server SDKs enforce the first half: measured on
2026-08-20 with a plain ``initialize`` against ``mcp.deepwiki.com/mcp``,
``mcp.context7.com/mcp`` and ``learn.microsoft.com/api/mcp``, the JSON-only
header got **406** ("Client must accept both application/json and
text/event-stream") from all three; with both offered, all three answered 200
— and all three framed the answer as an event stream.

So the honest header made the client unable to speak to any public MCP server,
which is exactly what the MCP Servers module exists to show. The edge ADR-116
drew was right about what this client should *do* — not hold a stream, not
answer server-initiated requests, not speak stdio — and wrong about what it
should *say*.

.. _adr-181-decision:

Decision
========

1. The transport offers both media types:
``Accept: application/json, text/event-stream``.

2. An answer with content type ``text/event-stream`` is **unframed**, not
refused: the body — already bounded by the read cap and closed by the
server — is parsed by the SSE rules that matter for one request/response
exchange. Events are separated by a blank line; the ``data:`` lines of one
event join with a newline; ``event:``, ``id:``, ``retry:`` and comment lines
carry nothing that is read; CRLF is a line ending like any other. A server
may put its own notifications on the same stream before the response (the
transport allows it); this client declared no capabilities, so anything
carrying a ``method`` is passed over, and the first message that is a
response — a ``result`` or an ``error`` — is the answer. A stream with no
such message is a malformed answer and is named as one, with the reason
("no message" versus "no response to the request").

3. Nothing else moves. No stream is held open, none is resumed, no
server-initiated request is answered, the operation budget
(:ref:`ADR-170 <adr-170>`) and the response size cap apply unchanged, and
stdio stays out of scope for the reasons ADR-116 gives. ADR-161's edge now
reads "no *live* stream" rather than "no SSE".

4. The conformance suite (:ref:`ADR-161 <adr-161>`) gains the positive case —
an event-stream framed tool answer reads like a plain one — and the two
failing shapes: a stream with no message, and a stream carrying only a
server notification. The transport's own tests pin the header and the
framing rules (multi-line ``data:``, CRLF, framing lines ignored,
notification passed over).

.. _adr-181-consequences:

Consequences
============

✓ The MCP Servers module can be pointed at a public server and shown working
— the reason the typo3-demo wanted one in its seed.

✓ The refusal vocabulary is unchanged in shape: a content type the client
cannot read is still refused by the same exception and code; only the message
stops claiming "JSON only".

✕ The client now reads a body it previously would have rejected outright.
The read stays bounded and the parse is a line scan, so the new surface is
small; but it is a surface, and the tests for it are the contract.

✕ A server that answers a single request with a *long-lived* stream — keeping
the connection open for notifications after the response — is read only up
to the size cap or the operation deadline, whichever comes first, and then
treated as answered or as timed out. That is acceptable for a request/response
client and is stated here so nobody mistakes it for support.

.. _adr-181-revisit:

Revisit when
============

Something on the far side needs more than one message per request — server
requests, progress notifications that must be surfaced, resumption. That is a
stream, it is what ADR-116 declined to hold, and it would be its own record.
1 change: 1 addition & 0 deletions Documentation/Adr/Index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -531,3 +531,4 @@ Tools
Adr177CallerSourceAttribution
Adr178CallerSourceOnTheCostPath
Adr179ADroppedForcedSourceIsRecordedOnTheRun
Adr181EventStreamFramedAnswers
3 changes: 2 additions & 1 deletion Tests/Fixtures/Mcp/McpTestServer.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ final class McpTestServer implements ClientInterface
* re-encodes as `[]` and would hide the very distinction a strict server
* cares about.
*
* @var list<array{method: string|null, body: array<string, mixed>, raw: string, session: string}>
* @var list<array{method: string|null, body: array<string, mixed>, raw: string, session: string, accept: string}>
*/
public array $received = [];

Expand Down Expand Up @@ -74,6 +74,7 @@ public function sendRequest(RequestInterface $request): ResponseInterface
'body' => $body,
'raw' => $raw,
'session' => $request->getHeaderLine('Mcp-Session-Id'),
'accept' => $request->getHeaderLine('Accept'),
];

// A notification carries no id and gets no reply, so it must not eat a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,30 @@ public function executesAToolUnderItsRemoteNameAndReturnsTheResult(): void
self::assertSame('mcp_' . $this->connection()->identifier . '_read_page', $tool->getSpec()->name);
}

/**
* TOOL EXECUTION, answered as an event stream.
*
* The Streamable HTTP transport lets a server frame the response to a POST
* as `text/event-stream`, and the public reference servers do exactly that
* for every request. The answer must read the same as a plain JSON one:
* same result, same contact recorded — nothing about the framing is
* visible above the transport (ADR-181).
*/
#[Test]
public function readsAnEventStreamFramedAnswerLikeAPlainOne(): void
{
$fake = $this->connection()->scriptedServer()->willReturnRaw(
"event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"framed: hello\"}]}}\n\n",
200,
'text/event-stream',
);

$result = $this->toolFor($fake)->execute(['uid' => 3], ToolExecutionContext::none());

self::assertFalse($result->isError, $result->content);
self::assertSame('framed: hello', $result->content);
}

/**
* TOOL EXECUTION, failed by the TOOL rather than by the wire.
*
Expand Down Expand Up @@ -604,7 +628,8 @@ public static function serverFailures(): array
'an authentication refusal' => ['{}', 401, 'application/json'],
'a JSON-RPC error object' => ['{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"Method not found"}}', 200, 'application/json'],
'a maintenance page' => ['<html>maintenance</html>', 200, 'text/html'],
'an event stream we do not read' => ["data: {}\n\n", 200, 'text/event-stream'],
'an event stream with no message' => [": ping\n\n", 200, 'text/event-stream'],
'an event stream carrying only a server notification' => ["data: {\"jsonrpc\":\"2.0\",\"method\":\"notifications/message\",\"params\":{}}\n\n", 200, 'text/event-stream'],
'a body with neither result nor error' => ['{"jsonrpc":"2.0","id":1}', 200, 'application/json'],
'an empty body' => ['', 200, 'application/json'],
];
Expand Down
Loading
Loading