Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/optional-redis-http.md
Original file line number Diff line number Diff line change
@@ -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.
88 changes: 88 additions & 0 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
@@ -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"
67 changes: 56 additions & 11 deletions docs/resources/all-clients.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -967,35 +967,80 @@ qwen mcp add context7 -- npx -y @upstash/context7-mcp --api-key YOUR_API_KEY

<Accordion title="Using Docker">

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
```

<Note>
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.
</Note>

<Tip>
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:
</Tip>

<Note>
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"]
}
}
}
```

</Note>

</Accordion>

<Accordion title="Docker MCP Toolkit">
Expand Down
4 changes: 3 additions & 1 deletion packages/mcp/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
44 changes: 41 additions & 3 deletions packages/mcp/src/lib/sessionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
refresh(sessionId: string): Promise<boolean>;
delete(sessionId: string): Promise<void>;
}

// 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<typeof getRedis>): SessionStore {
const getSessionKey = (sessionId: string) => `${SESSION_KEY_PREFIX}${sessionId}`;

return {
Expand Down Expand Up @@ -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();
}
30 changes: 30 additions & 0 deletions packages/mcp/test/sessionStore.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading