Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
@@ -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)
Expand Down
29 changes: 29 additions & 0 deletions migrations/4.6.1_mcp_session_payload_longtext.sql
Original file line number Diff line number Diff line change
@@ -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;
22 changes: 16 additions & 6 deletions src/Mvc/Model/KyteMCPSession.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -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,
],
Expand Down
21 changes: 21 additions & 0 deletions tests/DbSessionStoreTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading