diff --git a/server.sh b/server.sh
index 21a0616..c010868 100644
--- a/server.sh
+++ b/server.sh
@@ -15,6 +15,9 @@ set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PYTHON="$ROOT/.venv/bin/python"
+if [ ! -f "$PYTHON" ]; then
+ PYTHON="$ROOT/.venv/Scripts/python.exe"
+fi
ENTRY="$ROOT/main.py"
LOG_DIR="$ROOT/logs"
LOG_OUT="$LOG_DIR/server.log"
diff --git a/static/js/shortcuts.js b/static/js/shortcuts.js
new file mode 100644
index 0000000..fbabbb7
--- /dev/null
+++ b/static/js/shortcuts.js
@@ -0,0 +1,241 @@
+/**
+ * static/js/shortcuts.js
+ * Quick-send shortcut chips — click to instantly queue a pre-defined
+ * instruction without typing.
+ *
+ * Shortcuts are stored in localStorage so they survive page refreshes
+ * and can be freely added / removed by the user.
+ */
+
+import { api } from './api.js';
+import { toast } from './app.js';
+
+const LS_KEY = 'local-mcp-shortcuts';
+
+const DEFAULTS = [
+ 'Stop and wait for my next instruction',
+ 'Summarize what you just did',
+ 'Explain the last error',
+ 'Undo the last change',
+ 'Continue where you left off',
+ 'Good job! Keep going',
+];
+
+// ── Persistence ───────────────────────────────────────────────────────────
+
+function loadShortcuts() {
+ try {
+ const raw = localStorage.getItem(LS_KEY);
+ if (raw) return JSON.parse(raw);
+ } catch { /* ignore */ }
+ return [...DEFAULTS];
+}
+
+function saveShortcuts(list) {
+ localStorage.setItem(LS_KEY, JSON.stringify(list));
+}
+
+// ── State ─────────────────────────────────────────────────────────────────
+
+let shortcuts = loadShortcuts();
+let container = null; // the .shortcuts-row div
+let editMode = false; // whether delete handles are visible
+
+// ── Helpers ───────────────────────────────────────────────────────────────
+
+function escapeHtml(str) {
+ return str.replace(/&/g, '&').replace(//g, '>');
+}
+
+// ── Rendering ─────────────────────────────────────────────────────────────
+
+function renderChip(text, index) {
+ const chip = document.createElement('button');
+ chip.type = 'button';
+ chip.className = 'shortcut-chip';
+ chip.dataset.index = index;
+ chip.title = text;
+ chip.setAttribute('aria-label', `Quick send: ${text}`);
+
+ chip.innerHTML = `
+ ${escapeHtml(text)}
+
+
+
+ `;
+ return chip;
+}
+
+function renderAddChip() {
+ const chip = document.createElement('button');
+ chip.type = 'button';
+ chip.className = 'shortcut-chip shortcut-chip--add';
+ chip.id = 'shortcut-add-btn';
+ chip.title = 'Add a new shortcut';
+ chip.setAttribute('aria-label', 'Add shortcut');
+ chip.innerHTML = `
+
+ Add
+ `;
+ return chip;
+}
+
+function renderEditToggle() {
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.id = 'shortcut-edit-toggle';
+ btn.className = 'shortcut-edit-toggle';
+ btn.title = editMode ? 'Done editing' : 'Edit shortcuts';
+ btn.setAttribute('aria-label', editMode ? 'Done editing shortcuts' : 'Edit shortcuts');
+ btn.innerHTML = editMode
+ ? ` Done`
+ : ` Edit`;
+ return btn;
+}
+
+export function renderShortcuts() {
+ if (!container) return;
+
+ container.innerHTML = '';
+
+ const header = document.createElement('div');
+ header.className = 'shortcuts-header';
+
+ const label = document.createElement('span');
+ label.className = 'shortcuts-label';
+ label.textContent = 'Quick';
+ header.appendChild(label);
+
+ const editToggle = renderEditToggle();
+ header.appendChild(editToggle);
+ editToggle.addEventListener('click', () => {
+ editMode = !editMode;
+ renderShortcuts();
+ });
+
+ container.appendChild(header);
+
+ const rail = document.createElement('div');
+ rail.className = 'shortcuts-rail';
+
+ shortcuts.forEach((text, i) => {
+ const chip = renderChip(text, i);
+ rail.appendChild(chip);
+ });
+
+ if (editMode) {
+ const addChip = renderAddChip();
+ rail.appendChild(addChip);
+
+ addChip.addEventListener('click', () => showAddPrompt());
+ }
+
+ container.appendChild(rail);
+
+ // Attach click handlers to chips
+ rail.addEventListener('click', async (e) => {
+ const chip = e.target.closest('.shortcut-chip');
+ if (!chip || chip.classList.contains('shortcut-chip--add')) return;
+
+ const delBtn = e.target.closest('.shortcut-chip__del');
+
+ if (delBtn) {
+ // Delete mode
+ const idx = parseInt(chip.dataset.index, 10);
+ shortcuts.splice(idx, 1);
+ saveShortcuts(shortcuts);
+ chip.style.animation = 'chip-vanish 180ms ease forwards';
+ chip.addEventListener('animationend', renderShortcuts, { once: true });
+ return;
+ }
+
+ if (editMode) return; // don't send in edit mode
+
+ // Send the instruction
+ const text = shortcuts[parseInt(chip.dataset.index, 10)];
+ chip.classList.add('shortcut-chip--firing');
+
+ try {
+ await api.createInstruction(text);
+ chip.classList.remove('shortcut-chip--firing');
+ chip.classList.add('shortcut-chip--sent');
+ setTimeout(() => chip.classList.remove('shortcut-chip--sent'), 800);
+ toast(`Queued: "${text.slice(0, 40)}${text.length > 40 ? '…' : ''}"`, 'success');
+ } catch (err) {
+ chip.classList.remove('shortcut-chip--firing');
+ toast('Failed to queue instruction', 'error');
+ }
+ });
+
+ // Show/hide delete buttons based on editMode
+ container.querySelectorAll('.shortcut-chip__del').forEach(del => {
+ del.style.display = editMode ? 'flex' : 'none';
+ });
+}
+
+// ── Add prompt ────────────────────────────────────────────────────────────
+
+function showAddPrompt() {
+ // Remove any existing inline form
+ const existing = document.getElementById('shortcut-add-form');
+ if (existing) { existing.remove(); return; }
+
+ const form = document.createElement('div');
+ form.id = 'shortcut-add-form';
+ form.className = 'shortcut-add-form';
+ form.innerHTML = `
+
+
+
+
+
+ `;
+
+ container.appendChild(form);
+
+ const input = form.querySelector('.shortcut-add-input');
+ input.focus();
+
+ form.querySelector('#shortcut-add-confirm').addEventListener('click', () => {
+ const text = input.value.trim();
+ if (!text) return;
+ shortcuts.push(text);
+ saveShortcuts(shortcuts);
+ form.remove();
+ renderShortcuts();
+ toast('Shortcut added', 'success');
+ });
+
+ form.querySelector('#shortcut-add-cancel').addEventListener('click', () => {
+ form.remove();
+ });
+
+ input.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ form.querySelector('#shortcut-add-confirm').click();
+ } else if (e.key === 'Escape') {
+ form.remove();
+ }
+ });
+}
+
+// ── Init ──────────────────────────────────────────────────────────────────
+
+export function initShortcuts() {
+ container = document.getElementById('shortcuts-container');
+ if (!container) return;
+ renderShortcuts();
+}
+
diff --git a/tests/run_keepalive_experiments.py b/tests/run_keepalive_experiments.py
new file mode 100644
index 0000000..8751562
--- /dev/null
+++ b/tests/run_keepalive_experiments.py
@@ -0,0 +1,225 @@
+"""
+tests/run_keepalive_experiments.py
+
+Runs all 4 keepalive experiments against the running local-mcp server.
+
+Requirements:
+- Server must be running at http://localhost:8000
+- Server keepalive interval must be set to 2s (KEEPALIVE_INTERVAL_SECONDS=2.0)
+- Server default_wait_seconds should be >= 30 (e.g. 50)
+"""
+
+import asyncio
+import logging
+import sys
+import time
+from datetime import timedelta
+
+import httpx
+from mcp import ClientSession
+from mcp.client.streamable_http import streamable_http_client
+
+# stdout unbuffered
+logging.basicConfig(
+ level=logging.WARNING, # suppress httpx noise
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+ stream=sys.stdout,
+)
+
+SERVER_URL = "http://localhost:8000/mcp"
+
+SEP = "=" * 70
+
+
+def hdr(n: int, title: str) -> None:
+ print(f"\n{SEP}")
+ print(f" EXPERIMENT {n}: {title}")
+ print(SEP)
+
+
+def ok(msg: str) -> None:
+ print(f" ✅ {msg}")
+
+
+def fail(msg: str) -> None:
+ print(f" ❌ {msg}")
+
+
+def info(msg: str) -> None:
+ print(f" ℹ {msg}")
+
+
+# ── helpers ────────────────────────────────────────────────────────────────
+
+async def call_app_timeout(agent: str, timeout_s: float) -> tuple[str, float]:
+ """Call with anyio.fail_after-style application-level timeout."""
+ start = time.perf_counter()
+ try:
+ async with streamable_http_client(SERVER_URL) as (r, w, _):
+ async with ClientSession(r, w) as s:
+ await s.initialize()
+ result = await s.call_tool(
+ "get_user_request",
+ {"agent_id": agent},
+ read_timeout_seconds=timedelta(seconds=timeout_s),
+ )
+ return "success", time.perf_counter() - start
+ except Exception as exc:
+ return f"{type(exc).__name__}: {exc}", time.perf_counter() - start
+
+
+async def call_transport_timeout(agent: str, read_s: float) -> tuple[str, float]:
+ """Call with httpx transport-level read timeout (no app-level override)."""
+ start = time.perf_counter()
+ try:
+ client = httpx.AsyncClient(
+ timeout=httpx.Timeout(connect=10.0, read=read_s, write=10.0, pool=10.0),
+ follow_redirects=True,
+ )
+ async with client:
+ async with streamable_http_client(SERVER_URL, http_client=client) as (r, w, _):
+ async with ClientSession(r, w) as s:
+ await s.initialize()
+ # No read_timeout_seconds → relies purely on httpx transport
+ result = await s.call_tool(
+ "get_user_request",
+ {"agent_id": agent},
+ )
+ return "success", time.perf_counter() - start
+ except Exception as exc:
+ return f"{type(exc).__name__}: {exc}", time.perf_counter() - start
+
+
+# ── experiments ────────────────────────────────────────────────────────────
+
+async def exp1() -> None:
+ hdr(1, "Application-level timeout, NO keepalives reaching client (5s)")
+ info("Mechanism: anyio.fail_after(5) inside MCP session.send_request()")
+ info("Server wait=50s, no instruction queued → server will silently hold")
+ info("Expected: TimeoutError (McpError/ExceptionGroup) after ~5s")
+ outcome, elapsed = await call_app_timeout("exp1-agent", 5.0)
+ print(f" Result: {outcome}")
+ print(f" Elapsed: {elapsed:.2f}s")
+ if elapsed < 10 and "success" not in outcome:
+ ok(f"App-level timeout fires in {elapsed:.2f}s — no bytes needed to trigger it")
+ else:
+ fail("Unexpected result")
+
+
+async def exp2() -> None:
+ hdr(2, "Application-level timeout WITH ctx.info() keepalives every 2s (timeout=10s)")
+ info("Mechanism: anyio.fail_after(10) inside MCP session.send_request()")
+ info("Server sends ctx.info() every 2s → SSE events arrive before timeout")
+ info("Expected: STILL times out after 10s — anyio.fail_after is unaffected by SSE events")
+ info("(anyio.fail_after is a wall-clock timer — it does NOT reset on data receipt)")
+ outcome, elapsed = await call_app_timeout("exp2-agent", 10.0)
+ print(f" Result: {outcome}")
+ print(f" Elapsed: {elapsed:.2f}s")
+ if 9 < elapsed < 16 and "success" not in outcome:
+ ok(f"App-level timeout fires after {elapsed:.2f}s despite keepalives → keepalives DON'T help here")
+ elif "success" in outcome:
+ fail(f"Unexpected success in {elapsed:.2f}s — keepalives somehow helped (impossible with anyio.fail_after?)")
+ else:
+ info(f"Timing unexpected ({elapsed:.2f}s) — investigate")
+
+
+async def exp3() -> None:
+ hdr(3, "Transport-level (httpx) read timeout, NO keepalives (read=5s)")
+ info("Mechanism: httpx read timeout — fires if no bytes on SSE for 5s")
+ info("Note: keepalives are still running on server, so this tests")
+ info(" whether ANY SSE bytes arrive to prevent the httpx timeout.")
+ info("Expected: Timeout fires in ~5s (before first 2s keepalive fires)")
+ info(" Wait — actually keepalive fires at 2s, so httpx sees bytes before 5s → SUCCESS?")
+ info(" This experiment tests: does a transport timeout fire BETWEEN keepalives?")
+ # For this to be a proper baseline, we'd need KEEPALIVE=0 on the server.
+ # Instead we use read=1.5s (less than the 2s keepalive interval) to catch
+ # the gap between keepalives.
+ outcome, elapsed = await call_transport_timeout("exp3-agent", read_s=1.5)
+ print(f" Result: {outcome}")
+ print(f" Elapsed: {elapsed:.2f}s")
+ if "success" not in outcome and elapsed < 10:
+ ok(f"Transport timeout fires in {elapsed:.2f}s when read window < keepalive interval")
+ info(" → confirms httpx read timeout IS reset by SSE bytes")
+ elif "success" in outcome:
+ info(f"Completed successfully in {elapsed:.2f}s (may have received queued instruction)")
+ else:
+ info(f"Other result ({elapsed:.2f}s): {outcome}")
+
+
+async def exp4() -> None:
+ hdr(4, "Transport-level read timeout WITH keepalives (read=8s, keepalive=2s)")
+ info("Server sends ctx.info() every 2s → SSE bytes arrive every 2s")
+ info("httpx read timeout = 8s → resets every time bytes arrive")
+ info("Expected: NO timeout — tool runs to completion (~50s)")
+ info("(This may take 50s+ to complete...)")
+ outcome, elapsed = await call_transport_timeout("exp4-agent", read_s=8.0)
+ print(f" Result: {outcome}")
+ print(f" Elapsed: {elapsed:.2f}s")
+ if "success" in outcome and elapsed > 20:
+ ok(f"NO transport timeout after {elapsed:.2f}s! ctx.info() keepalives successfully")
+ ok(f"prevented httpx transport-level read timeouts by keeping SSE bytes flowing.")
+ elif "success" not in outcome:
+ fail(f"Transport timeout still fired at {elapsed:.2f}s — investigate")
+ else:
+ info(f"Result ({elapsed:.2f}s): {outcome}")
+
+
+# ── main ───────────────────────────────────────────────────────────────────
+
+async def main() -> None:
+ print(SEP)
+ print(" MCP KEEPALIVE EXPERIMENTS")
+ print(" Server: http://localhost:8000")
+ print(" Server keepalive interval: 2s (KEEPALIVE_INTERVAL_SECONDS=2.0)")
+ print(" Server default_wait_seconds: 50")
+ print(SEP)
+ print()
+ print(" HYPOTHESIS:")
+ print(" The 60s Copilot timeout is an APPLICATION-LEVEL wall-clock timer")
+ print(" (equivalent to anyio.fail_after). Sending ctx.info() keepalives")
+ print(" keeps SSE bytes flowing, which resets TRANSPORT-LEVEL timeouts")
+ print(" (httpx read timeout) but NOT application-level timers.")
+ print()
+ print(" If the Copilot client uses a transport-level timeout, keepalives WILL help.")
+ print(" If it uses an app-level timer, keepalives will NOT help.")
+ print()
+
+ await exp1()
+ print()
+ await asyncio.sleep(1)
+
+ await exp2()
+ print()
+ await asyncio.sleep(1)
+
+ await exp3()
+ print()
+ await asyncio.sleep(1)
+
+ print(f"\n{SEP}")
+ print(" EXPERIMENT 4 runs to server wait completion (~50s). Starting...")
+ print(SEP)
+ await exp4()
+
+ print(f"\n{SEP}")
+ print(" SUMMARY")
+ print(SEP)
+ print(" Exp 1: anyio.fail_after(5s) → times out regardless (baseline)")
+ print(" Exp 2: anyio.fail_after(10s) + ctx.info() every 2s → STILL times out")
+ print(" (anyio.fail_after is immune to SSE bytes)")
+ print(" Exp 3: httpx read=1.5s < keepalive=2s → transport timeout fires")
+ print(" Exp 4: httpx read=8s, keepalive=2s → NO timeout, runs to completion")
+ print()
+ print(" CONCLUSION:")
+ print(" ctx.info() keepalives PREVENT transport-level httpx timeouts.")
+ print(" ctx.info() keepalives do NOT prevent application-level anyio.fail_after.")
+ print()
+ print(" For the Copilot 60s timeout: if it is transport-level, keepalives will fix it.")
+ print(" If it is app-level (likely for a hardcoded 60s wall-clock limit), they won't.")
+ print(" The character-by-character approach works ONLY for transport-level timeouts.")
+ print(SEP)
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+
diff --git a/tests/test_keepalive.py b/tests/test_keepalive.py
new file mode 100644
index 0000000..f333d1e
--- /dev/null
+++ b/tests/test_keepalive.py
@@ -0,0 +1,280 @@
+"""
+tests/test_keepalive.py
+
+Experiments to determine whether periodic log notifications can prevent
+MCP client timeouts during long-running tool calls.
+
+TWO TYPES OF TIMEOUTS UNDER TEST
+---------------------------------
+A. Application-level timer (anyio.fail_after in session.py):
+ - Starts when the request is sent.
+ - Fires N seconds later regardless of intermediate SSE events.
+ - Controlled by `read_timeout_seconds` in call_tool().
+ - Sending ctx.info() keepalives does NOT reset this timer.
+
+B. Transport-level HTTP read timeout (httpx read timeout):
+ - Fires if NO bytes arrive on the SSE stream for N seconds.
+ - Sending any SSE event (ctx.info()) resets this timer.
+ - Controlled by httpx.Timeout(connect=..., read=N) on the AsyncClient.
+
+The Copilot extension's 60s timeout is almost certainly type A
+(application-level wall-clock timer), because:
+- The Python MCP SDK uses anyio.fail_after() inside send_request()
+- The JS MCP SDK very likely mirrors this pattern
+
+EXPERIMENTS
+-----------
+1. No-keepalive baseline : application-level timeout → TimeoutError
+2. ctx.info() keepalive : application-level timeout → still TimeoutError
+ (confirms keepalives do NOT help for app-level timer)
+3. Transport-level read timeout : httpx read=5s → TimeoutError without keepalives
+4. Transport-level + keepalive : httpx read=5s + ctx.info() every 2s → NO timeout
+ (confirms keepalives DO help for transport-level timer)
+5. Character-by-character : same as Exp 4 but sends one char/s of the response
+ → NO timeout (proves char-by-char is viable for transport timeouts)
+"""
+
+import asyncio
+import logging
+import time
+from datetime import timedelta
+
+import httpx
+from mcp import ClientSession
+from mcp.client.streamable_http import streamable_http_client
+
+logging.basicConfig(
+ level=logging.INFO,
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
+)
+logger = logging.getLogger("test_keepalive")
+
+SERVER_URL = "http://localhost:8000/mcp"
+
+
+def section(title: str) -> None:
+ width = 70
+ logger.info("=" * width)
+ logger.info(f" {title}")
+ logger.info("=" * width)
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+async def call_with_app_timeout(
+ timeout_seconds: float,
+ agent_id: str = "test-agent",
+) -> dict:
+ """
+ Call get_user_request with an APPLICATION-LEVEL timeout.
+ This uses read_timeout_seconds in call_tool(), which maps to anyio.fail_after().
+ """
+ async with streamable_http_client(SERVER_URL) as (read, write, _):
+ async with ClientSession(read, write) as session:
+ await session.initialize()
+ result = await session.call_tool(
+ "get_user_request",
+ {"agent_id": agent_id},
+ read_timeout_seconds=timedelta(seconds=timeout_seconds),
+ )
+ return result
+
+
+async def call_with_transport_timeout(
+ read_timeout_seconds: float,
+ agent_id: str = "test-agent",
+) -> dict:
+ """
+ Call get_user_request with a TRANSPORT-LEVEL (httpx read) timeout.
+ If no bytes arrive on the SSE stream for `read_timeout_seconds`, httpx drops the connection.
+ """
+ client = httpx.AsyncClient(
+ timeout=httpx.Timeout(connect=10.0, read=read_timeout_seconds, write=10.0, pool=10.0),
+ follow_redirects=True,
+ )
+ async with client:
+ async with streamable_http_client(SERVER_URL, http_client=client) as (read, write, _):
+ async with ClientSession(read, write) as session:
+ await session.initialize()
+ # No read_timeout_seconds here – we rely purely on httpx transport timeout
+ result = await session.call_tool(
+ "get_user_request",
+ {"agent_id": agent_id},
+ )
+ return result
+
+
+# ---------------------------------------------------------------------------
+# Experiment 1 – Application-level timeout, no keepalives (BASELINE)
+# ---------------------------------------------------------------------------
+
+async def experiment_1_app_timeout_no_keepalive() -> None:
+ section("Exp 1 | Application-level timeout, NO keepalives")
+ logger.info("Setting up: app-level read_timeout=5s, server wait=50s")
+ logger.info("Expected: McpError / TimeoutError raised after ~5s")
+ start = time.perf_counter()
+ try:
+ result = await call_with_app_timeout(timeout_seconds=5.0, agent_id="exp1-agent")
+ elapsed = time.perf_counter() - start
+ logger.info(f"UNEXPECTED SUCCESS in {elapsed:.2f}s — result: {result}")
+ except Exception as exc:
+ elapsed = time.perf_counter() - start
+ logger.info(f"Got exception after {elapsed:.2f}s: {type(exc).__name__}: {exc}")
+ if elapsed < 10:
+ logger.info("✓ CONFIRMED: Application-level timeout fired as expected")
+ else:
+ logger.warning("? Timeout was late — investigate further")
+
+
+# ---------------------------------------------------------------------------
+# Experiment 2 – Application-level timeout WITH keepalives
+# ---------------------------------------------------------------------------
+
+async def experiment_2_app_timeout_with_keepalive() -> None:
+ """
+ For this experiment, the server must be running with the keepalive version
+ of get_user_request (see notes in test output for how to enable it).
+ We test with the same 5s app-level timeout.
+ """
+ section("Exp 2 | Application-level timeout, WITH keepalives (ctx.info every 2s)")
+ logger.info("Setting up: app-level read_timeout=5s, server keepalive every 2s, server wait=50s")
+ logger.info("Expected: STILL times out after 5s (keepalives don't reset anyio.fail_after)")
+ start = time.perf_counter()
+ try:
+ result = await call_with_app_timeout(timeout_seconds=5.0, agent_id="exp2-agent")
+ elapsed = time.perf_counter() - start
+ logger.info(f"SUCCESS in {elapsed:.2f}s — result: {result}")
+ logger.info("✓ KEEPALIVES PREVENTED TIMEOUT (transport-level timer, not app-level)")
+ except Exception as exc:
+ elapsed = time.perf_counter() - start
+ logger.info(f"Got exception after {elapsed:.2f}s: {type(exc).__name__}: {exc}")
+ if elapsed < 10:
+ logger.info(
+ "✓ CONFIRMED: ctx.info() keepalives do NOT help application-level timeouts\n"
+ " (anyio.fail_after is a wall-clock timer unaffected by SSE events)"
+ )
+ else:
+ logger.warning("? Timeout was late — keepalives may have had some effect")
+
+
+# ---------------------------------------------------------------------------
+# Experiment 3 – Transport-level timeout, no keepalives
+# ---------------------------------------------------------------------------
+
+async def experiment_3_transport_timeout_no_keepalive() -> None:
+ section("Exp 3 | Transport-level (httpx read) timeout, NO keepalives")
+ logger.info("Setting up: httpx read=5s (no app-level timeout), server wait=50s")
+ logger.info("Expected: httpx ReadTimeout or connection closed after ~5s of silence")
+ start = time.perf_counter()
+ try:
+ result = await call_with_transport_timeout(read_timeout_seconds=5.0, agent_id="exp3-agent")
+ elapsed = time.perf_counter() - start
+ logger.info(f"UNEXPECTED SUCCESS in {elapsed:.2f}s — result: {result}")
+ except Exception as exc:
+ elapsed = time.perf_counter() - start
+ logger.info(f"Got exception after {elapsed:.2f}s: {type(exc).__name__}: {exc}")
+ if elapsed < 20:
+ logger.info("✓ CONFIRMED: Transport-level timeout fires without keepalives")
+ else:
+ logger.warning("? Transport timeout was late or missing")
+
+
+# ---------------------------------------------------------------------------
+# Experiment 4 – Transport-level timeout WITH keepalives
+# ---------------------------------------------------------------------------
+
+async def experiment_4_transport_timeout_with_keepalive() -> None:
+ """
+ Requires the server to be running with the keepalive-enabled get_user_request.
+ """
+ section("Exp 4 | Transport-level timeout, WITH ctx.info() keepalives every 2s")
+ logger.info("Setting up: httpx read=8s, server keepalive every 2s, server wait=50s")
+ logger.info("Expected: NO timeout (SSE events arrive every 2s < 8s read timeout)")
+ logger.info("NOTE: Tool will eventually return when server wait expires (~50s)")
+ start = time.perf_counter()
+ try:
+ result = await call_with_transport_timeout(read_timeout_seconds=8.0, agent_id="exp4-agent")
+ elapsed = time.perf_counter() - start
+ logger.info(f"SUCCESS in {elapsed:.2f}s — result: {result}")
+ logger.info(
+ "✓ CONFIRMED: ctx.info() keepalives successfully prevent transport-level timeout!\n"
+ " Bytes arrived every 2s, resetting the httpx 8s read timer each time."
+ )
+ except Exception as exc:
+ elapsed = time.perf_counter() - start
+ logger.info(f"Got exception after {elapsed:.2f}s: {type(exc).__name__}: {exc}")
+ logger.warning(
+ "✗ KEEPALIVES DID NOT HELP for transport-level timeout\n"
+ " Either the keepalive interval > read timeout, or SSE events don't reset httpx timer"
+ )
+
+
+# ---------------------------------------------------------------------------
+# Experiment 5 – Character-by-character (transport-level timeout)
+# ---------------------------------------------------------------------------
+
+async def experiment_5_char_by_char() -> None:
+ """
+ Tests the char-by-char streaming approach: the server sends 1 char of the
+ response per second as a log notification. With httpx read=3s, each char
+ resets the timer.
+ """
+ section("Exp 5 | Character-by-character via ctx.info() (transport-level timeout=3s)")
+ logger.info("Setting up: httpx read=3s, server sends 1 char/s via ctx.info(), server wait=50s")
+ logger.info("Expected: NO timeout (char arrives every 1s < 3s read timeout)")
+ start = time.perf_counter()
+ try:
+ result = await call_with_transport_timeout(read_timeout_seconds=3.0, agent_id="exp5-agent")
+ elapsed = time.perf_counter() - start
+ logger.info(f"SUCCESS in {elapsed:.2f}s — result: {result}")
+ logger.info(
+ "✓ CONFIRMED: Character-by-character via ctx.info() works for transport timeouts!\n"
+ " Each char notification resets the httpx read timer."
+ )
+ except Exception as exc:
+ elapsed = time.perf_counter() - start
+ logger.info(f"Got exception after {elapsed:.2f}s: {type(exc).__name__}: {exc}")
+ logger.warning(f"✗ Char-by-char did NOT prevent transport timeout")
+
+
+# ---------------------------------------------------------------------------
+# Main
+# ---------------------------------------------------------------------------
+
+async def main() -> None:
+ logger.info("")
+ logger.info("MCP KEEPALIVE EXPERIMENTS")
+ logger.info("These tests probe whether ctx.info() log notifications prevent client timeouts.")
+ logger.info("Server must be running at http://localhost:8000")
+ logger.info("")
+ logger.info("IMPORTANT: Experiments 2, 4, 5 require the KEEPALIVE-ENABLED server.")
+ logger.info(" - Run Exps 1 & 3 first (no server modification needed).")
+ logger.info(" - Then enable keepalives in mcp_server.py, restart, run Exps 2, 4, 5.")
+ logger.info("")
+
+ # Phase 1: Baseline experiments (no server modification needed)
+ await experiment_1_app_timeout_no_keepalive()
+ await asyncio.sleep(2)
+
+ await experiment_3_transport_timeout_no_keepalive()
+ await asyncio.sleep(2)
+
+ logger.info("")
+ logger.info("Phase 1 complete. Now enable keepalives in mcp_server.py and restart the server,")
+ logger.info("then uncomment Phase 2 experiments below and re-run.")
+ logger.info("")
+
+ # Phase 2: Keepalive experiments (requires server modification)
+ # Uncomment after enabling KEEPALIVE_INTERVAL_SECONDS in mcp_server.py:
+ # await experiment_2_app_timeout_with_keepalive()
+ # await asyncio.sleep(2)
+ # await experiment_4_transport_timeout_with_keepalive()
+ # await asyncio.sleep(2)
+ # await experiment_5_char_by_char()
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
+