From 79e7c2a330e7a18585decbc5f204db2fa165cbc3 Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Thu, 28 May 2026 21:59:57 -0500 Subject: [PATCH] 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) --- CHANGELOG.md | 12 ++++++++ .../4.6.1_mcp_session_payload_longtext.sql | 29 +++++++++++++++++++ src/Mvc/Model/KyteMCPSession.php | 22 ++++++++++---- tests/DbSessionStoreTest.php | 21 ++++++++++++++ 4 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 migrations/4.6.1_mcp_session_payload_longtext.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index da2bbfa..e93ca37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## 4.6.1 + +### Bug Fix (regression from 4.6.0): large MCP tool responses corrupt the session → `read_page` (and other large reads) fail + +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)**. + +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. + +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. + +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.** + ## 4.6.0 ### Feature: DB-backed MCP session store (cross-instance / load-balanced support) diff --git a/migrations/4.6.1_mcp_session_payload_longtext.sql b/migrations/4.6.1_mcp_session_payload_longtext.sql new file mode 100644 index 0000000..5c1e59d --- /dev/null +++ b/migrations/4.6.1_mcp_session_payload_longtext.sql @@ -0,0 +1,29 @@ +-- ========================================================================= +-- Kyte v4.6.1 - widen KyteMCPSession.payload TEXT -> LONGTEXT (regression fix) +-- ========================================================================= +-- IMPORTANT: Backup your database before running this migration. +-- +-- Fixes a regression introduced in v4.6.0. The DB-backed MCP session store +-- (DbSessionStore) defined `payload` as TEXT (64KB max). The streamable-HTTP +-- SDK persists its outgoing-message queue (`_mcp.outgoing_queue`) — i.e. full +-- JSON-RPC tool *responses* — inside the session payload between request +-- handling and delivery. A single large read (e.g. `read_page` on a 300KB+ +-- page) produces a ~800KB response that overflows the 64KB column; MySQL +-- silently truncates it at 65535 bytes, corrupting the stored JSON. The next +-- read of that session does `json_decode` on the truncated string and throws +-- "Control character error, possibly incorrectly encoded", which surfaces to +-- the MCP client as a 400 and breaks the tool call. +-- +-- The FileSessionStore that DbSessionStore replaced wrote to files with no +-- size cap, so large responses worked. LONGTEXT (4GB) restores that parity. +-- +-- Also purges any already-corrupt/truncated sessions so stale rows don't keep +-- failing on resume — MCP sessions are ephemeral, clients simply re-initialize. +-- +-- Safe to re-run. See src/Mvc/Model/KyteMCPSession.php. +-- ========================================================================= + +ALTER TABLE `KyteMCPSession` MODIFY `payload` LONGTEXT NOT NULL; + +-- Clear any sessions truncated under the old TEXT column (corrupt JSON). +DELETE FROM `KyteMCPSession` WHERE LENGTH(`payload`) >= 65535; diff --git a/src/Mvc/Model/KyteMCPSession.php b/src/Mvc/Model/KyteMCPSession.php index 8bed9be..9c8b4db 100644 --- a/src/Mvc/Model/KyteMCPSession.php +++ b/src/Mvc/Model/KyteMCPSession.php @@ -28,8 +28,15 @@ * - `session_id` carries the RFC4122 UUID and must be UNIQUE; the index is * created in the Phase-2/4.6.0 migration, not here (the model framework * doesn't declare indexes). - * - `payload` is the raw `json_encode` of the SDK session array — small and - * bounded (a handful of scalar/array keys), TEXT is ample headroom. + * - `payload` is the raw `json_encode` of the SDK session array. It is NOT + * small/bounded: the streamable-HTTP SDK persists its outgoing-message + * queue (`_mcp.outgoing_queue`) — i.e. full JSON-RPC tool *responses* — + * inside the session between request and delivery. A single large read + * (e.g. `read_page` on a 300KB+ page) puts a ~800KB response in here. So + * `payload` is LONGTEXT, not TEXT: a TEXT column (64KB) silently truncates + * the queued response → corrupt JSON → `json_decode` ctrl-char error on the + * next read. The FileSessionStore this replaced had no size cap, so + * LONGTEXT restores parity. See migrations/4.6.1_mcp_session_payload_longtext.sql. */ $KyteMCPSession = [ @@ -45,11 +52,14 @@ 'date' => false, ], - // Encoded session state: json_encode of the SDK session data array - // (initialized, client_info, client_capabilities, protocol_version, - // log_level). Opaque to Kyte — written and read verbatim by the store. + // Encoded session state: json_encode of the SDK session data array. + // Includes the SDK's `_mcp.outgoing_queue` — full JSON-RPC tool + // responses staged for delivery — so this can run to hundreds of KB + // for a large read. LONGTEXT ('lt'), NOT TEXT: a 64KB TEXT column + // truncates large queued responses and corrupts the session. Opaque + // to Kyte — written and read verbatim by the store. 'payload' => [ - 'type' => 't', + 'type' => 'lt', 'required' => true, 'date' => false, ], diff --git a/tests/DbSessionStoreTest.php b/tests/DbSessionStoreTest.php index f22ed41..9df2bac 100644 --- a/tests/DbSessionStoreTest.php +++ b/tests/DbSessionStoreTest.php @@ -68,6 +68,27 @@ public function testWriteIsUpsertNotDuplicate(): void $this->assertSame(1, (int)$rows[0]['c']); } + public function testLargePayloadRoundTripsWithoutTruncation(): void + { + // Regression (v4.6.1): the streamable-HTTP SDK stages full tool + // responses in the session payload (`_mcp.outgoing_queue`). A 64KB + // TEXT column silently truncated large reads (e.g. read_page on a + // 300KB+ page) → corrupt JSON → ctrl-char error on the next read. + // payload is LONGTEXT; this writes >64KB and reads it back intact. + $store = new DbSessionStore(self::ACCOUNT_A); + $id = new UuidV4(); + $payload = json_encode([ + 'initialized' => true, + '_mcp' => ['outgoing_queue' => [['message' => str_repeat('x', 200000)]]], + ]); + $this->assertGreaterThan(65535, strlen($payload)); + + $this->assertTrue($store->write($id, $payload)); + // Would fail on a TEXT column (truncated at 65535); passes on LONGTEXT. + $this->assertSame($payload, $store->read($id)); + $this->assertSame(strlen($payload), strlen((string)$store->read($id))); + } + public function testDestroyRemovesSession(): void { $store = new DbSessionStore(self::ACCOUNT_A);