From b9d53095c3b7a472e2a7a611dccbe7262a0d9cd7 Mon Sep 17 00:00:00 2001 From: enesgules Date: Mon, 29 Jun 2026 13:38:30 +0300 Subject: [PATCH 1/5] feat: publish official Docker image for the MCP server Closes #214. Adds a `Publish Docker Image` workflow that builds packages/mcp/Dockerfile (multi-arch: linux/amd64, linux/arm64) and pushes it to both the GitHub Container Registry (ghcr.io/upstash/context7-mcp) and Docker Hub (upstash/context7-mcp). It runs automatically on each MCP release (tags matching `@upstash/context7-mcp@*`) and via manual dispatch, tagging both `latest` and the released version. Splits the Dockerfile's final command into ENTRYPOINT + CMD so the default behaviour is unchanged (HTTP on :8080 when run with no args), but users can cleanly append `--transport stdio` to run it as a local MCP server. Updates the "Using Docker" docs to pull the official image instead of building one, with a note that HTTP mode is for self-hosted deployments and requires Upstash Redis credentials. Requires repo secrets DOCKERHUB_USERNAME and DOCKERHUB_TOKEN for the Docker Hub push (GHCR uses the built-in GITHUB_TOKEN). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/docker-publish.yml | 88 ++++++++++++++++++++++++++++ docs/resources/all-clients.mdx | 49 +++++++++++----- packages/mcp/Dockerfile | 4 +- 3 files changed, 126 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/docker-publish.yml diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 000000000..05a20103d --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,88 @@ +name: Publish Docker Image + +on: + push: + tags: + - "@upstash/context7-mcp@*" + workflow_dispatch: + inputs: + version: + description: "Image version tag (defaults to packages/mcp/package.json version)" + required: false + type: string + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write # Required to push to GHCR with GITHUB_TOKEN + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Determine version + run: | + if [ "${{ github.event_name }}" = "push" ]; then + # Tag looks like "@upstash/context7-mcp@3.2.2" + VERSION="${GITHUB_REF_NAME##*@}" + elif [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + VERSION="${VERSION#v}" + else + VERSION=$(node -p "require('./packages/mcp/package.json').version") + fi + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + echo "Publishing version: $VERSION" + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push Docker image + id: build-push + uses: docker/build-push-action@v6 + with: + context: . + file: packages/mcp/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ghcr.io/upstash/context7-mcp:latest + ghcr.io/upstash/context7-mcp:${{ env.VERSION }} + upstash/context7-mcp:latest + upstash/context7-mcp:${{ env.VERSION }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Create GitHub summary + run: | + { + echo "## Official Docker Image Published" + echo "" + echo "**Version:** \`${{ env.VERSION }}\`" + echo "**Platforms:** \`linux/amd64\`, \`linux/arm64\`" + echo "**Digest:** \`${{ steps.build-push.outputs.digest }}\`" + echo "" + echo "**Tags:**" + echo "- \`ghcr.io/upstash/context7-mcp:latest\`" + echo "- \`ghcr.io/upstash/context7-mcp:${{ env.VERSION }}\`" + echo "- \`upstash/context7-mcp:latest\`" + echo "- \`upstash/context7-mcp:${{ env.VERSION }}\`" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/docs/resources/all-clients.mdx b/docs/resources/all-clients.mdx index b30b8105a..61e508114 100644 --- a/docs/resources/all-clients.mdx +++ b/docs/resources/all-clients.mdx @@ -967,35 +967,56 @@ qwen mcp add context7 -- npx -y @upstash/context7-mcp --api-key YOUR_API_KEY -1. Create a `Dockerfile`: - -```Dockerfile -FROM node:22-alpine -WORKDIR /app -RUN npm install -g @upstash/context7-mcp -CMD ["context7-mcp", "--transport", "stdio"] -``` - -2. Build the image: +Pull the official image instead of building your own. It is published to both the GitHub Container Registry and Docker Hub, and the `latest` tag follows the most recent release: ```bash -docker build -t context7-mcp . +docker pull ghcr.io/upstash/context7-mcp +# or +docker pull upstash/context7-mcp ``` -3. Configure your MCP client: +**As a stdio MCP server** (for clients such as Claude Desktop, Cline, or Roo Code), append `--transport stdio`: ```json { "mcpServers": { "context7": { "command": "docker", - "args": ["run", "-i", "--rm", "context7-mcp"], - "transportType": "stdio" + "args": [ + "run", + "-i", + "--rm", + "ghcr.io/upstash/context7-mcp", + "--transport", + "stdio", + "--api-key", + "YOUR_API_KEY" + ] } } } ``` + + The image also supports an HTTP transport (`--transport http`, the default) used for + self-hosted deployments. That mode keeps session state in Upstash Redis and requires the + `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` environment variables. For local use + with an MCP client, stick with `--transport stdio` as shown above. + + + + Prefer to build it yourself? Use the + [`Dockerfile`](https://github.com/upstash/context7/blob/master/packages/mcp/Dockerfile) in + the repository, or a minimal one: + +```Dockerfile +FROM node:22-alpine +RUN npm install -g @upstash/context7-mcp +ENTRYPOINT ["context7-mcp"] +``` + + + diff --git a/packages/mcp/Dockerfile b/packages/mcp/Dockerfile index 81720b4e2..8b8b4371a 100644 --- a/packages/mcp/Dockerfile +++ b/packages/mcp/Dockerfile @@ -26,4 +26,6 @@ COPY --from=builder /app/packages/mcp/dist ./packages/mcp/dist WORKDIR /app/packages/mcp EXPOSE 8080 -CMD ["node", "dist/index.js", "--transport", "http", "--port", "8080"] +# Default to HTTP on :8080. Override args (e.g. `--transport stdio`) are appended to the entrypoint. +ENTRYPOINT ["node", "dist/index.js"] +CMD ["--transport", "http", "--port", "8080"] From a29e56b8b7e04efc454f80fdf025a3eb90662108 Mon Sep 17 00:00:00 2001 From: enesgules Date: Mon, 29 Jun 2026 17:06:12 +0300 Subject: [PATCH 2/5] docs: mention the Docker Hardened Image as an enterprise option Context7 is also published as a Docker Hardened Image (dhi.io/context7-mcp) with signed provenance, an SBOM, and near-zero CVEs. Add it to the "Using Docker" section as a hardened/enterprise option alongside the free public image. Note that it runs over stdio and requires `docker login dhi.io` and a Docker Hardened Images subscription. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/resources/all-clients.mdx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/resources/all-clients.mdx b/docs/resources/all-clients.mdx index 61e508114..42c404760 100644 --- a/docs/resources/all-clients.mdx +++ b/docs/resources/all-clients.mdx @@ -1017,6 +1017,25 @@ ENTRYPOINT ["context7-mcp"] + + For a hardened, enterprise-grade image with signed provenance, an SBOM, and near-zero known + CVEs, Context7 is also available as a [Docker Hardened Image](https://dhi.io) at + `dhi.io/context7-mcp`. It runs over stdio out of the box. Pulling it requires authenticating + with `docker login dhi.io` and a Docker Hardened Images subscription: + +```json +{ + "mcpServers": { + "context7": { + "command": "docker", + "args": ["run", "-i", "--rm", "dhi.io/context7-mcp", "--api-key", "YOUR_API_KEY"] + } + } +} +``` + + + From 3a9d3d7bf2a5cbd458cee668fc31de567f46cd92 Mon Sep 17 00:00:00 2001 From: enesgules Date: Tue, 30 Jun 2026 12:38:55 +0300 Subject: [PATCH 3/5] feat(mcp): make Redis optional for the HTTP transport The HTTP transport previously required UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN and crashed on startup without them, which made the official Docker image (HTTP by default) unusable out of the box. Add an in-memory session store and fall back to it when Redis is not configured, so `--transport http` runs standalone on a single instance. Redis is still used when configured, for sharing sessions across multiple instances. Session IDs are opaque correlation identifiers (not auth), so an in-memory store is safe for single-instance deployments. Update the Docker docs to show the HTTP run command works without Redis, and add tests for the in-memory store and the fallback selection. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/optional-redis-http.md | 5 ++ docs/resources/all-clients.mdx | 13 +++-- packages/mcp/src/lib/sessionStore.ts | 70 ++++++++++++++++++++++- packages/mcp/test/sessionStore.test.ts | 77 ++++++++++++++++++++++++++ 4 files changed, 158 insertions(+), 7 deletions(-) create mode 100644 .changeset/optional-redis-http.md create mode 100644 packages/mcp/test/sessionStore.test.ts diff --git a/.changeset/optional-redis-http.md b/.changeset/optional-redis-http.md new file mode 100644 index 000000000..6fc129a65 --- /dev/null +++ b/.changeset/optional-redis-http.md @@ -0,0 +1,5 @@ +--- +"@upstash/context7-mcp": minor +--- + +Make Upstash Redis optional for the HTTP transport. When `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` are not set, the server now falls back to an in-memory session store instead of failing to start, so `--transport http` runs standalone (for example in the official Docker image). Configure Redis to share sessions across multiple instances. diff --git a/docs/resources/all-clients.mdx b/docs/resources/all-clients.mdx index 42c404760..c145e4963 100644 --- a/docs/resources/all-clients.mdx +++ b/docs/resources/all-clients.mdx @@ -997,11 +997,16 @@ docker pull upstash/context7-mcp } ``` +**As a remote HTTP server** (the image's default), expose port 8080: + +```bash +docker run --rm -p 8080:8080 ghcr.io/upstash/context7-mcp +``` + - The image also supports an HTTP transport (`--transport http`, the default) used for - self-hosted deployments. That mode keeps session state in Upstash Redis and requires the - `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` environment variables. For local use - with an MCP client, stick with `--transport stdio` as shown above. + The HTTP server runs standalone with an in-memory session store, so no extra configuration is + needed. If you run multiple instances behind a load balancer, set `UPSTASH_REDIS_REST_URL` and + `UPSTASH_REDIS_REST_TOKEN` so sessions are shared across them. diff --git a/packages/mcp/src/lib/sessionStore.ts b/packages/mcp/src/lib/sessionStore.ts index e9a7cf56f..0b1feac91 100644 --- a/packages/mcp/src/lib/sessionStore.ts +++ b/packages/mcp/src/lib/sessionStore.ts @@ -4,13 +4,17 @@ 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 const SESSION_KEY_PREFIX = "#mcp#session#"; +export interface SessionStore { + create(sessionId: string): Promise; + refresh(sessionId: string): Promise; + delete(sessionId: string): Promise; +} + // Fail-open: log Redis errors and proceed. The session ID isn't an auth/authz // 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). -export function createSessionStore() { - const redis = getRedis(); - +function createRedisSessionStore(redis: ReturnType): SessionStore { const getSessionKey = (sessionId: string) => `${SESSION_KEY_PREFIX}${sessionId}`; return { @@ -47,3 +51,63 @@ export function createSessionStore() { }, }; } + +// In-memory session store used when Redis is not configured. Sessions live in a +// Map keyed by session ID with an absolute expiry timestamp. This is correct for a +// single instance; it does not share sessions across replicas, so multi-instance +// deployments should configure Redis. Sessions are lost on restart, which is safe — +// clients get a 404 on the next request and re-initialize. +export function createMemorySessionStore(): SessionStore { + const expiries = new Map(); // sessionId -> expiry epoch ms + + const sweepExpired = () => { + const now = Date.now(); + for (const [id, expiresAt] of expiries) { + if (expiresAt <= now) expiries.delete(id); + } + }; + + return { + async create(sessionId: string) { + // Opportunistically drop expired entries so abandoned sessions don't accumulate. + sweepExpired(); + expiries.set(sessionId, Date.now() + SESSION_TTL_SECONDS * 1000); + }, + + async refresh(sessionId: string) { + const expiresAt = expiries.get(sessionId); + if (expiresAt === undefined) return false; + if (expiresAt <= Date.now()) { + expiries.delete(sessionId); + return false; + } + // Only extend the TTL when the session is approaching expiry. + if (expiresAt - Date.now() < REFRESH_THRESHOLD_SECONDS * 1000) { + expiries.set(sessionId, Date.now() + SESSION_TTL_SECONDS * 1000); + } + return true; + }, + + async delete(sessionId: string) { + expiries.delete(sessionId); + }, + }; +} + +/** + * Returns a session store for the HTTP transport. Uses Upstash Redis when + * UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN are set (required for + * multi-instance deployments where sessions must be shared), and falls back to + * an in-memory store otherwise so the server runs standalone without Redis. + */ +export function createSessionStore(): SessionStore { + if (process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN) { + return createRedisSessionStore(getRedis()); + } + + console.error( + "UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKEN not set — using an in-memory session store. " + + "This is fine for a single instance; configure Redis to share sessions across multiple instances." + ); + return createMemorySessionStore(); +} diff --git a/packages/mcp/test/sessionStore.test.ts b/packages/mcp/test/sessionStore.test.ts new file mode 100644 index 000000000..f3f2676be --- /dev/null +++ b/packages/mcp/test/sessionStore.test.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { createMemorySessionStore, createSessionStore } from "../src/lib/sessionStore.js"; + +const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; +const REFRESH_THRESHOLD_MS = 24 * 60 * 60 * 1000; + +describe("createMemorySessionStore", () => { + test("refreshes a created session and rejects unknown ones", async () => { + const store = createMemorySessionStore(); + await store.create("a"); + + expect(await store.refresh("a")).toBe(true); + expect(await store.refresh("unknown")).toBe(false); + }); + + test("rejects a session after it is deleted", async () => { + const store = createMemorySessionStore(); + await store.create("a"); + await store.delete("a"); + + expect(await store.refresh("a")).toBe(false); + }); + + test("expires a session once its TTL elapses", async () => { + vi.useFakeTimers(); + try { + const store = createMemorySessionStore(); + await store.create("a"); + + vi.advanceTimersByTime(SESSION_TTL_MS + 1000); + + expect(await store.refresh("a")).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + + test("extends the TTL when a session is close to expiring", async () => { + vi.useFakeTimers(); + try { + const store = createMemorySessionStore(); + await store.create("a"); + + // Advance to within the refresh threshold, then refresh to extend. + vi.advanceTimersByTime(SESSION_TTL_MS - REFRESH_THRESHOLD_MS + 1000); + expect(await store.refresh("a")).toBe(true); + + // Without the extension the session would now be expired; with it, still valid. + vi.advanceTimersByTime(REFRESH_THRESHOLD_MS); + expect(await store.refresh("a")).toBe(true); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("createSessionStore", () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + vi.restoreAllMocks(); + }); + + test("falls back to the in-memory store when Redis is not configured", async () => { + delete process.env.UPSTASH_REDIS_REST_URL; + delete process.env.UPSTASH_REDIS_REST_TOKEN; + vi.spyOn(console, "error").mockImplementation(() => {}); + + const store = createSessionStore(); + + // Would throw inside getRedis() if it tried to use Redis without credentials. + await store.create("s"); + expect(await store.refresh("s")).toBe(true); + }); +}); From b43ba09c777b6209eca819d18de58e2737c8c6f0 Mon Sep 17 00:00:00 2001 From: enesgules Date: Tue, 30 Jun 2026 12:44:03 +0300 Subject: [PATCH 4/5] fix(mcp): bound the in-memory session store to prevent OOM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The in-memory fallback could grow without limit: every initialize mints a session that lived for the 7-day TTL, so a flood of inits would exhaust the heap (and the per-create full-map sweep was O(n), degrading to O(n^2) under a burst). Cap the store at MAX_MEMORY_SESSIONS (100k) with O(1) oldest-first eviction on create and lazy expiry on access — no scans. Refresh re-inserts to approximate LRU so active sessions are evicted last; an evicted live session just gets a 404 and re-initializes. Memory is now bounded to tens of MB regardless of load. Add tests for eviction and unbounded-flood behaviour; the cap is injectable for testing. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/mcp/src/lib/sessionStore.ts | 33 +++++++++++++++++--------- packages/mcp/test/sessionStore.test.ts | 25 +++++++++++++++++++ 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/packages/mcp/src/lib/sessionStore.ts b/packages/mcp/src/lib/sessionStore.ts index 0b1feac91..225e9a294 100644 --- a/packages/mcp/src/lib/sessionStore.ts +++ b/packages/mcp/src/lib/sessionStore.ts @@ -4,6 +4,11 @@ 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 const SESSION_KEY_PREFIX = "#mcp#session#"; +// Hard cap on entries in the in-memory store, bounding worst-case memory (~tens of MB) so a +// flood of initialize requests can't exhaust the heap. At capacity, the oldest session is +// evicted; an evicted-but-still-active client simply gets a 404 and re-initializes. +const MAX_MEMORY_SESSIONS = 100_000; + export interface SessionStore { create(sessionId: string): Promise; refresh(sessionId: string): Promise; @@ -57,20 +62,24 @@ function createRedisSessionStore(redis: ReturnType): SessionSto // single instance; it does not share sessions across replicas, so multi-instance // deployments should configure Redis. Sessions are lost on restart, which is safe — // clients get a 404 on the next request and re-initialize. -export function createMemorySessionStore(): SessionStore { +// +// Memory is bounded two ways: entries expire lazily on access (no scan), and the Map is +// capped at MAX_MEMORY_SESSIONS with oldest-first eviction on create. The Map's insertion +// order gives us that ordering for free; refreshing a session re-inserts it so active +// sessions move to the back and are evicted last (approximate LRU). +export function createMemorySessionStore(maxSessions = MAX_MEMORY_SESSIONS): SessionStore { const expiries = new Map(); // sessionId -> expiry epoch ms - const sweepExpired = () => { - const now = Date.now(); - for (const [id, expiresAt] of expiries) { - if (expiresAt <= now) expiries.delete(id); - } - }; - return { async create(sessionId: string) { - // Opportunistically drop expired entries so abandoned sessions don't accumulate. - sweepExpired(); + // Drop the entry first so re-creating an existing id doesn't keep its old position. + expiries.delete(sessionId); + // Evict oldest entries until there is room. Expired entries sort oldest, so they go first. + while (expiries.size >= maxSessions) { + const oldest = expiries.keys().next().value; + if (oldest === undefined) break; + expiries.delete(oldest); + } expiries.set(sessionId, Date.now() + SESSION_TTL_SECONDS * 1000); }, @@ -81,8 +90,10 @@ export function createMemorySessionStore(): SessionStore { expiries.delete(sessionId); return false; } - // Only extend the TTL when the session is approaching expiry. + // Only extend the TTL when the session is approaching expiry. Re-insert so the entry + // moves to the back of the eviction order (most-recently-used). if (expiresAt - Date.now() < REFRESH_THRESHOLD_SECONDS * 1000) { + expiries.delete(sessionId); expiries.set(sessionId, Date.now() + SESSION_TTL_SECONDS * 1000); } return true; diff --git a/packages/mcp/test/sessionStore.test.ts b/packages/mcp/test/sessionStore.test.ts index f3f2676be..dece7f9e6 100644 --- a/packages/mcp/test/sessionStore.test.ts +++ b/packages/mcp/test/sessionStore.test.ts @@ -53,6 +53,31 @@ describe("createMemorySessionStore", () => { vi.useRealTimers(); } }); + + test("caps memory by evicting the oldest sessions past the limit", async () => { + const store = createMemorySessionStore(3); + await store.create("a"); + await store.create("b"); + await store.create("c"); + await store.create("d"); // exceeds cap of 3 -> evicts oldest ("a") + + expect(await store.refresh("a")).toBe(false); + expect(await store.refresh("b")).toBe(true); + expect(await store.refresh("c")).toBe(true); + expect(await store.refresh("d")).toBe(true); + }); + + test("does not grow unbounded under a flood of new sessions", async () => { + const cap = 100; + const store = createMemorySessionStore(cap); + for (let i = 0; i < cap * 10; i++) { + await store.create(`session-${i}`); + } + + // Everything older than the last `cap` sessions must have been evicted. + expect(await store.refresh("session-0")).toBe(false); + expect(await store.refresh(`session-${cap * 10 - 1}`)).toBe(true); + }); }); describe("createSessionStore", () => { From 45a9f9a93ab5074c8a7b842808ff78e769d3c342 Mon Sep 17 00:00:00 2001 From: enesgules Date: Tue, 30 Jun 2026 12:52:14 +0300 Subject: [PATCH 5/5] refactor(mcp): run statelessly without Redis instead of tracking sessions Each HTTP request already builds a fresh transport and the server keeps no per-session state between requests, so the session store's only job was to 404 sessions an instance doesn't recognize. On a single instance there is nothing to recognize, so tracking sessions in memory bought nothing and only risked unbounded growth. Replace the in-memory store with a stateless no-op store for the no-Redis path: create/delete do nothing and refresh always succeeds, accepting any session ID. Zero memory growth, no OOM surface. Redis remains required for multi-instance deployments, where sessions must be shared and validated across replicas. Verified end to end: with no Redis, initialize -> tools/list returns both tools over HTTP (200), including for a never-initialized session ID. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/optional-redis-http.md | 2 +- docs/resources/all-clients.mdx | 6 +- packages/mcp/src/lib/sessionStore.ts | 65 ++++--------------- packages/mcp/test/sessionStore.test.ts | 90 +++----------------------- 4 files changed, 27 insertions(+), 136 deletions(-) diff --git a/.changeset/optional-redis-http.md b/.changeset/optional-redis-http.md index 6fc129a65..5da041b29 100644 --- a/.changeset/optional-redis-http.md +++ b/.changeset/optional-redis-http.md @@ -2,4 +2,4 @@ "@upstash/context7-mcp": minor --- -Make Upstash Redis optional for the HTTP transport. When `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` are not set, the server now falls back to an in-memory session store instead of failing to start, so `--transport http` runs standalone (for example in the official Docker image). Configure Redis to share sessions across multiple instances. +Make Upstash Redis optional for the HTTP transport. When `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` are not set, the server now runs statelessly (no session tracking) instead of failing to start, so `--transport http` works standalone on a single instance — for example in the official Docker image. Configure Redis to share and validate sessions across multiple instances. diff --git a/docs/resources/all-clients.mdx b/docs/resources/all-clients.mdx index c145e4963..3f06dfa10 100644 --- a/docs/resources/all-clients.mdx +++ b/docs/resources/all-clients.mdx @@ -1004,9 +1004,9 @@ docker run --rm -p 8080:8080 ghcr.io/upstash/context7-mcp ``` - The HTTP server runs standalone with an in-memory session store, so no extra configuration is - needed. If you run multiple instances behind a load balancer, set `UPSTASH_REDIS_REST_URL` and - `UPSTASH_REDIS_REST_TOKEN` so sessions are shared across them. + The HTTP server runs standalone with no extra configuration. If you run multiple instances + behind a load balancer, set `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` so sessions + are shared across them. diff --git a/packages/mcp/src/lib/sessionStore.ts b/packages/mcp/src/lib/sessionStore.ts index 225e9a294..cfb3bfe0f 100644 --- a/packages/mcp/src/lib/sessionStore.ts +++ b/packages/mcp/src/lib/sessionStore.ts @@ -4,11 +4,6 @@ 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 const SESSION_KEY_PREFIX = "#mcp#session#"; -// Hard cap on entries in the in-memory store, bounding worst-case memory (~tens of MB) so a -// flood of initialize requests can't exhaust the heap. At capacity, the oldest session is -// evicted; an evicted-but-still-active client simply gets a 404 and re-initializes. -const MAX_MEMORY_SESSIONS = 100_000; - export interface SessionStore { create(sessionId: string): Promise; refresh(sessionId: string): Promise; @@ -57,59 +52,27 @@ function createRedisSessionStore(redis: ReturnType): SessionSto }; } -// In-memory session store used when Redis is not configured. Sessions live in a -// Map keyed by session ID with an absolute expiry timestamp. This is correct for a -// single instance; it does not share sessions across replicas, so multi-instance -// deployments should configure Redis. Sessions are lost on restart, which is safe — -// clients get a 404 on the next request and re-initialize. -// -// Memory is bounded two ways: entries expire lazily on access (no scan), and the Map is -// capped at MAX_MEMORY_SESSIONS with oldest-first eviction on create. The Map's insertion -// order gives us that ordering for free; refreshing a session re-inserts it so active -// sessions move to the back and are evicted last (approximate LRU). -export function createMemorySessionStore(maxSessions = MAX_MEMORY_SESSIONS): SessionStore { - const expiries = new Map(); // sessionId -> expiry epoch ms - +// Stateless store used when Redis is not configured. Each HTTP request already builds a +// fresh transport (sessionIdGenerator is undefined), so the server keeps no per-session +// state between requests — the store only existed to 404 sessions an instance doesn't +// recognize. On a single instance there is nothing to recognize, so we track nothing and +// accept every session ID. This means zero memory growth. Multi-instance deployments must +// configure Redis so sessions are shared (and validated) across replicas. +function createStatelessSessionStore(): SessionStore { return { - async create(sessionId: string) { - // Drop the entry first so re-creating an existing id doesn't keep its old position. - expiries.delete(sessionId); - // Evict oldest entries until there is room. Expired entries sort oldest, so they go first. - while (expiries.size >= maxSessions) { - const oldest = expiries.keys().next().value; - if (oldest === undefined) break; - expiries.delete(oldest); - } - expiries.set(sessionId, Date.now() + SESSION_TTL_SECONDS * 1000); - }, - - async refresh(sessionId: string) { - const expiresAt = expiries.get(sessionId); - if (expiresAt === undefined) return false; - if (expiresAt <= Date.now()) { - expiries.delete(sessionId); - return false; - } - // Only extend the TTL when the session is approaching expiry. Re-insert so the entry - // moves to the back of the eviction order (most-recently-used). - if (expiresAt - Date.now() < REFRESH_THRESHOLD_SECONDS * 1000) { - expiries.delete(sessionId); - expiries.set(sessionId, Date.now() + SESSION_TTL_SECONDS * 1000); - } + async create() {}, + async refresh() { return true; }, - - async delete(sessionId: string) { - expiries.delete(sessionId); - }, + async delete() {}, }; } /** * Returns a session store for the HTTP transport. Uses Upstash Redis when * UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN are set (required for - * multi-instance deployments where sessions must be shared), and falls back to - * an in-memory store otherwise so the server runs standalone without Redis. + * multi-instance deployments where sessions must be shared across replicas), and + * otherwise runs statelessly so the server works standalone without Redis. */ export function createSessionStore(): SessionStore { if (process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN) { @@ -117,8 +80,8 @@ export function createSessionStore(): SessionStore { } console.error( - "UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKEN not set — using an in-memory session store. " + + "UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKEN not set — running without session tracking. " + "This is fine for a single instance; configure Redis to share sessions across multiple instances." ); - return createMemorySessionStore(); + return createStatelessSessionStore(); } diff --git a/packages/mcp/test/sessionStore.test.ts b/packages/mcp/test/sessionStore.test.ts index dece7f9e6..1b169b4f9 100644 --- a/packages/mcp/test/sessionStore.test.ts +++ b/packages/mcp/test/sessionStore.test.ts @@ -1,86 +1,8 @@ import { afterEach, describe, expect, test, vi } from "vitest"; -import { createMemorySessionStore, createSessionStore } from "../src/lib/sessionStore.js"; +import { createSessionStore } from "../src/lib/sessionStore.js"; -const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; -const REFRESH_THRESHOLD_MS = 24 * 60 * 60 * 1000; - -describe("createMemorySessionStore", () => { - test("refreshes a created session and rejects unknown ones", async () => { - const store = createMemorySessionStore(); - await store.create("a"); - - expect(await store.refresh("a")).toBe(true); - expect(await store.refresh("unknown")).toBe(false); - }); - - test("rejects a session after it is deleted", async () => { - const store = createMemorySessionStore(); - await store.create("a"); - await store.delete("a"); - - expect(await store.refresh("a")).toBe(false); - }); - - test("expires a session once its TTL elapses", async () => { - vi.useFakeTimers(); - try { - const store = createMemorySessionStore(); - await store.create("a"); - - vi.advanceTimersByTime(SESSION_TTL_MS + 1000); - - expect(await store.refresh("a")).toBe(false); - } finally { - vi.useRealTimers(); - } - }); - - test("extends the TTL when a session is close to expiring", async () => { - vi.useFakeTimers(); - try { - const store = createMemorySessionStore(); - await store.create("a"); - - // Advance to within the refresh threshold, then refresh to extend. - vi.advanceTimersByTime(SESSION_TTL_MS - REFRESH_THRESHOLD_MS + 1000); - expect(await store.refresh("a")).toBe(true); - - // Without the extension the session would now be expired; with it, still valid. - vi.advanceTimersByTime(REFRESH_THRESHOLD_MS); - expect(await store.refresh("a")).toBe(true); - } finally { - vi.useRealTimers(); - } - }); - - test("caps memory by evicting the oldest sessions past the limit", async () => { - const store = createMemorySessionStore(3); - await store.create("a"); - await store.create("b"); - await store.create("c"); - await store.create("d"); // exceeds cap of 3 -> evicts oldest ("a") - - expect(await store.refresh("a")).toBe(false); - expect(await store.refresh("b")).toBe(true); - expect(await store.refresh("c")).toBe(true); - expect(await store.refresh("d")).toBe(true); - }); - - test("does not grow unbounded under a flood of new sessions", async () => { - const cap = 100; - const store = createMemorySessionStore(cap); - for (let i = 0; i < cap * 10; i++) { - await store.create(`session-${i}`); - } - - // Everything older than the last `cap` sessions must have been evicted. - expect(await store.refresh("session-0")).toBe(false); - expect(await store.refresh(`session-${cap * 10 - 1}`)).toBe(true); - }); -}); - -describe("createSessionStore", () => { +describe("createSessionStore (no Redis configured)", () => { const originalEnv = { ...process.env }; afterEach(() => { @@ -88,7 +10,7 @@ describe("createSessionStore", () => { vi.restoreAllMocks(); }); - test("falls back to the in-memory store when Redis is not configured", async () => { + test("runs statelessly without Redis credentials", async () => { delete process.env.UPSTASH_REDIS_REST_URL; delete process.env.UPSTASH_REDIS_REST_TOKEN; vi.spyOn(console, "error").mockImplementation(() => {}); @@ -97,6 +19,12 @@ describe("createSessionStore", () => { // Would throw inside getRedis() if it tried to use Redis without credentials. await store.create("s"); + + // Accepts any session ID, tracking nothing — so unknown sessions are accepted too. + expect(await store.refresh("s")).toBe(true); + expect(await store.refresh("never-created")).toBe(true); + + await store.delete("s"); expect(await store.refresh("s")).toBe(true); }); });