From 5de13538d3a0dabf42d39191a088589be82ff9bd Mon Sep 17 00:00:00 2001 From: enesgules Date: Fri, 3 Jul 2026 17:12:49 +0300 Subject: [PATCH] feat(mcp): make Redis session storage optional Without UPSTASH_REDIS_REST_URL/TOKEN the HTTP server now starts with a no-op session store instead of throwing: sessions are still minted per the MCP Streamable HTTP spec but not persisted or expired (refresh always succeeds). stdio mode is unaffected and never touches Redis. Co-Authored-By: Claude Fable 5 --- packages/mcp/README.md | 2 ++ packages/mcp/src/lib/redis.ts | 9 ++++++- packages/mcp/src/lib/sessionStore.ts | 18 ++++++++++++- packages/mcp/test/sessionStore.test.ts | 35 ++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 packages/mcp/test/sessionStore.test.ts diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 3c53ca531..c3ba0355a 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -1448,6 +1448,8 @@ You can use the `CONTEXT7_API_KEY` environment variable instead of passing the ` **Note:** The `--api-key` CLI flag takes precedence over the environment variable when both are provided. +You can optionally set `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` to persist MCP session IDs in [Upstash Redis](https://upstash.com/). This lets multiple server instances share session state and expires idle sessions after 7 days. Without them, the server still works normally — sessions are simply not persisted or expired. + **Example with .env file:** ```bash diff --git a/packages/mcp/src/lib/redis.ts b/packages/mcp/src/lib/redis.ts index c71bb0cba..929dd20dd 100644 --- a/packages/mcp/src/lib/redis.ts +++ b/packages/mcp/src/lib/redis.ts @@ -2,12 +2,19 @@ import { Redis } from "@upstash/redis"; let cached: Redis | undefined; +/** + * Whether Upstash Redis credentials are configured. + */ +export function hasRedisCredentials(): boolean { + return Boolean(process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN); +} + /** * Returns the shared Upstash Redis client. Throws if credentials are missing. */ export function getRedis(): Redis { if (cached) return cached; - if (!process.env.UPSTASH_REDIS_REST_URL || !process.env.UPSTASH_REDIS_REST_TOKEN) { + if (!hasRedisCredentials()) { throw new Error( "Upstash Redis credentials are required. Set UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN." ); diff --git a/packages/mcp/src/lib/sessionStore.ts b/packages/mcp/src/lib/sessionStore.ts index e9a7cf56f..7fe35e517 100644 --- a/packages/mcp/src/lib/sessionStore.ts +++ b/packages/mcp/src/lib/sessionStore.ts @@ -1,4 +1,4 @@ -import { getRedis } from "./redis.js"; +import { getRedis, hasRedisCredentials } from "./redis.js"; const SESSION_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days const REFRESH_THRESHOLD_SECONDS = 24 * 60 * 60; // 1 day — only extend TTL when below this @@ -8,7 +8,23 @@ const SESSION_KEY_PREFIX = "#mcp#session#"; // primitive — only an opaque identifier for log correlation and spec compliance — // so an unreachable Redis shouldn't block clients. Ghost sessions self-heal on // the next refresh (returns false → client gets 404 → re-inits). +// +// Redis itself is optional: without credentials, sessions are minted but not +// persisted or expired — refresh always succeeds. export function createSessionStore() { + if (!hasRedisCredentials()) { + console.warn( + "Upstash Redis credentials not set — MCP sessions will not be persisted or expired." + ); + return { + async create(_sessionId: string) {}, + async refresh(_sessionId: string) { + return true; + }, + async delete(_sessionId: string) {}, + }; + } + const redis = getRedis(); const getSessionKey = (sessionId: string) => `${SESSION_KEY_PREFIX}${sessionId}`; diff --git a/packages/mcp/test/sessionStore.test.ts b/packages/mcp/test/sessionStore.test.ts new file mode 100644 index 000000000..6f4107e0f --- /dev/null +++ b/packages/mcp/test/sessionStore.test.ts @@ -0,0 +1,35 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { createSessionStore } from "../src/lib/sessionStore.js"; + +beforeEach(() => { + vi.stubEnv("UPSTASH_REDIS_REST_URL", undefined); + vi.stubEnv("UPSTASH_REDIS_REST_TOKEN", undefined); + vi.spyOn(console, "warn").mockImplementation(() => {}); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); +}); + +describe("createSessionStore without Redis credentials", () => { + test("returns a no-op store", async () => { + const store = createSessionStore(); + await expect(store.create("some-session-id")).resolves.toBeUndefined(); + await expect(store.refresh("some-session-id")).resolves.toBe(true); + await expect(store.delete("some-session-id")).resolves.toBeUndefined(); + }); + + test("warns about missing credentials", () => { + createSessionStore(); + expect(console.warn).toHaveBeenCalledTimes(1); + }); +}); + +describe("createSessionStore with Redis credentials", () => { + test("constructs the Redis-backed store without throwing", () => { + vi.stubEnv("UPSTASH_REDIS_REST_URL", "https://example.upstash.io"); + vi.stubEnv("UPSTASH_REDIS_REST_TOKEN", "fake-token"); + expect(() => createSessionStore()).not.toThrow(); + }); +});