Skip to content

Commit 79e7c2a

Browse files
kennethphoughclaude
andcommitted
fix(mcp): widen KyteMCPSession.payload TEXT -> LONGTEXT (v4.6.0 regression)
The streamable-HTTP SDK stages full JSON-RPC tool responses in the session payload (_mcp.outgoing_queue). v4.6.0's DbSessionStore made payload a TEXT column (64KB), so a large read (read_page on a 300KB+ page produces a ~786KB response) overflowed it; MySQL silently truncated at 65535 bytes, corrupting the stored JSON, which then failed json_decode on the next read (ctrl-char error) and surfaced to the client as HTTP 400. The FileSessionStore this replaced had no size cap, so this is a parity regression. - src/Mvc/Model/KyteMCPSession.php payload type t -> lt (LONGTEXT) - migrations/4.6.1_mcp_session_payload_longtext.sql ALTER MODIFY LONGTEXT + purge sessions truncated under the old column (LENGTH(payload) >= 65535) - tests/DbSessionStoreTest.php regression test: >64KB payload round-trip - CHANGELOG.md v4.6.1 Verified on dev: read_page on a 312KB current-version page returns clean JSON (previously 400). Existing installs only need the ALTER; fresh installs get LONGTEXT from the model. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b12a3da commit 79e7c2a

4 files changed

Lines changed: 78 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,15 @@
1+
## 4.6.1
2+
3+
### Bug Fix (regression from 4.6.0): large MCP tool responses corrupt the session → `read_page` (and other large reads) fail
4+
5+
A `read_page` against a large page (≈300KB+ of HTML) failed with a 400 and `"Control character error, possibly incorrectly encoded"`. Root cause is the new `DbSessionStore` from 4.6.0: it defined `KyteMCPSession.payload` as **`TEXT` (64KB max)**.
6+
7+
The streamable-HTTP SDK persists its outgoing-message queue (`_mcp.outgoing_queue`) — the full JSON-RPC tool **response** — inside the session payload between handling and delivery. A large read produces a ~800KB response (the SDK also duplicates content as a text block *and* `structuredContent`); that overflows the 64KB column, MySQL **silently truncates** it at 65535 bytes, and the truncated JSON then fails `json_decode` on the next read → the ctrl-char error, surfaced to the client as a 400.
8+
9+
The `FileSessionStore` that 4.6.0 replaced wrote to files with no size cap, so large reads worked there — this is a parity regression, not a pre-existing bug.
10+
11+
Fix: `KyteMCPSession.payload` is now **`LONGTEXT`** (model type `lt`). `migrations/4.6.1_mcp_session_payload_longtext.sql` runs `ALTER TABLE ... MODIFY payload LONGTEXT` and purges any sessions already truncated under the old column (`LENGTH(payload) >= 65535`) so stale corrupt rows don't keep failing on resume. Single-instance installs on the `file` backend are unaffected. **Run the migration on every install that took 4.6.0 with the default DB store.**
12+
113
## 4.6.0
214

315
### Feature: DB-backed MCP session store (cross-instance / load-balanced support)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
-- =========================================================================
2+
-- Kyte v4.6.1 - widen KyteMCPSession.payload TEXT -> LONGTEXT (regression fix)
3+
-- =========================================================================
4+
-- IMPORTANT: Backup your database before running this migration.
5+
--
6+
-- Fixes a regression introduced in v4.6.0. The DB-backed MCP session store
7+
-- (DbSessionStore) defined `payload` as TEXT (64KB max). The streamable-HTTP
8+
-- SDK persists its outgoing-message queue (`_mcp.outgoing_queue`) — i.e. full
9+
-- JSON-RPC tool *responses* — inside the session payload between request
10+
-- handling and delivery. A single large read (e.g. `read_page` on a 300KB+
11+
-- page) produces a ~800KB response that overflows the 64KB column; MySQL
12+
-- silently truncates it at 65535 bytes, corrupting the stored JSON. The next
13+
-- read of that session does `json_decode` on the truncated string and throws
14+
-- "Control character error, possibly incorrectly encoded", which surfaces to
15+
-- the MCP client as a 400 and breaks the tool call.
16+
--
17+
-- The FileSessionStore that DbSessionStore replaced wrote to files with no
18+
-- size cap, so large responses worked. LONGTEXT (4GB) restores that parity.
19+
--
20+
-- Also purges any already-corrupt/truncated sessions so stale rows don't keep
21+
-- failing on resume — MCP sessions are ephemeral, clients simply re-initialize.
22+
--
23+
-- Safe to re-run. See src/Mvc/Model/KyteMCPSession.php.
24+
-- =========================================================================
25+
26+
ALTER TABLE `KyteMCPSession` MODIFY `payload` LONGTEXT NOT NULL;
27+
28+
-- Clear any sessions truncated under the old TEXT column (corrupt JSON).
29+
DELETE FROM `KyteMCPSession` WHERE LENGTH(`payload`) >= 65535;

src/Mvc/Model/KyteMCPSession.php

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,15 @@
2828
* - `session_id` carries the RFC4122 UUID and must be UNIQUE; the index is
2929
* created in the Phase-2/4.6.0 migration, not here (the model framework
3030
* doesn't declare indexes).
31-
* - `payload` is the raw `json_encode` of the SDK session array — small and
32-
* bounded (a handful of scalar/array keys), TEXT is ample headroom.
31+
* - `payload` is the raw `json_encode` of the SDK session array. It is NOT
32+
* small/bounded: the streamable-HTTP SDK persists its outgoing-message
33+
* queue (`_mcp.outgoing_queue`) — i.e. full JSON-RPC tool *responses* —
34+
* inside the session between request and delivery. A single large read
35+
* (e.g. `read_page` on a 300KB+ page) puts a ~800KB response in here. So
36+
* `payload` is LONGTEXT, not TEXT: a TEXT column (64KB) silently truncates
37+
* the queued response → corrupt JSON → `json_decode` ctrl-char error on the
38+
* next read. The FileSessionStore this replaced had no size cap, so
39+
* LONGTEXT restores parity. See migrations/4.6.1_mcp_session_payload_longtext.sql.
3340
*/
3441

3542
$KyteMCPSession = [
@@ -45,11 +52,14 @@
4552
'date' => false,
4653
],
4754

48-
// Encoded session state: json_encode of the SDK session data array
49-
// (initialized, client_info, client_capabilities, protocol_version,
50-
// log_level). Opaque to Kyte — written and read verbatim by the store.
55+
// Encoded session state: json_encode of the SDK session data array.
56+
// Includes the SDK's `_mcp.outgoing_queue` — full JSON-RPC tool
57+
// responses staged for delivery — so this can run to hundreds of KB
58+
// for a large read. LONGTEXT ('lt'), NOT TEXT: a 64KB TEXT column
59+
// truncates large queued responses and corrupts the session. Opaque
60+
// to Kyte — written and read verbatim by the store.
5161
'payload' => [
52-
'type' => 't',
62+
'type' => 'lt',
5363
'required' => true,
5464
'date' => false,
5565
],

tests/DbSessionStoreTest.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,27 @@ public function testWriteIsUpsertNotDuplicate(): void
6868
$this->assertSame(1, (int)$rows[0]['c']);
6969
}
7070

71+
public function testLargePayloadRoundTripsWithoutTruncation(): void
72+
{
73+
// Regression (v4.6.1): the streamable-HTTP SDK stages full tool
74+
// responses in the session payload (`_mcp.outgoing_queue`). A 64KB
75+
// TEXT column silently truncated large reads (e.g. read_page on a
76+
// 300KB+ page) → corrupt JSON → ctrl-char error on the next read.
77+
// payload is LONGTEXT; this writes >64KB and reads it back intact.
78+
$store = new DbSessionStore(self::ACCOUNT_A);
79+
$id = new UuidV4();
80+
$payload = json_encode([
81+
'initialized' => true,
82+
'_mcp' => ['outgoing_queue' => [['message' => str_repeat('x', 200000)]]],
83+
]);
84+
$this->assertGreaterThan(65535, strlen($payload));
85+
86+
$this->assertTrue($store->write($id, $payload));
87+
// Would fail on a TEXT column (truncated at 65535); passes on LONGTEXT.
88+
$this->assertSame($payload, $store->read($id));
89+
$this->assertSame(strlen($payload), strlen((string)$store->read($id)));
90+
}
91+
7192
public function testDestroyRemovesSession(): void
7293
{
7394
$store = new DbSessionStore(self::ACCOUNT_A);

0 commit comments

Comments
 (0)