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); + }); +});