diff --git a/.changeset/optional-redis-http.md b/.changeset/optional-redis-http.md
new file mode 100644
index 000000000..5da041b29
--- /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 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/.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..3f06dfa10 100644
--- a/docs/resources/all-clients.mdx
+++ b/docs/resources/all-clients.mdx
@@ -967,35 +967,80 @@ qwen mcp add context7 -- npx -y @upstash/context7-mcp --api-key YOUR_API_KEY
-1. Create a `Dockerfile`:
+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:
-```Dockerfile
-FROM node:22-alpine
-WORKDIR /app
-RUN npm install -g @upstash/context7-mcp
-CMD ["context7-mcp", "--transport", "stdio"]
+```bash
+docker pull ghcr.io/upstash/context7-mcp
+# or
+docker pull upstash/context7-mcp
```
-2. Build the image:
+**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",
+ "ghcr.io/upstash/context7-mcp",
+ "--transport",
+ "stdio",
+ "--api-key",
+ "YOUR_API_KEY"
+ ]
+ }
+ }
+}
+```
+
+**As a remote HTTP server** (the image's default), expose port 8080:
```bash
-docker build -t context7-mcp .
+docker run --rm -p 8080:8080 ghcr.io/upstash/context7-mcp
+```
+
+
+ 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.
+
+
+
+ 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"]
```
-3. Configure your MCP client:
+
+
+
+ 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", "context7-mcp"],
- "transportType": "stdio"
+ "args": ["run", "-i", "--rm", "dhi.io/context7-mcp", "--api-key", "YOUR_API_KEY"]
}
}
}
```
+
+
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"]
diff --git a/packages/mcp/src/lib/sessionStore.ts b/packages/mcp/src/lib/sessionStore.ts
index e9a7cf56f..cfb3bfe0f 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,37 @@ export function createSessionStore() {
},
};
}
+
+// 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() {},
+ async refresh() {
+ return true;
+ },
+ 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 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) {
+ return createRedisSessionStore(getRedis());
+ }
+
+ console.error(
+ "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 createStatelessSessionStore();
+}
diff --git a/packages/mcp/test/sessionStore.test.ts b/packages/mcp/test/sessionStore.test.ts
new file mode 100644
index 000000000..1b169b4f9
--- /dev/null
+++ b/packages/mcp/test/sessionStore.test.ts
@@ -0,0 +1,30 @@
+import { afterEach, describe, expect, test, vi } from "vitest";
+
+import { createSessionStore } from "../src/lib/sessionStore.js";
+
+describe("createSessionStore (no Redis configured)", () => {
+ const originalEnv = { ...process.env };
+
+ afterEach(() => {
+ process.env = { ...originalEnv };
+ vi.restoreAllMocks();
+ });
+
+ 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(() => {});
+
+ const store = 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);
+ });
+});