Target: https://mcp.cometchat.com (subdomain to confirm with DevOps).
docker build --target runtime -t cometchat-mcp:0.1.7 .
docker run --rm -p 3000:3000 \
-v $(pwd)/data:/app/data \
-e NODE_ENV=production \
-e ALLOWED_HOSTS=mcp.cometchat.com \
cometchat-mcp:0.1.7
The image bundles skills/ and the compiled server. Implementation bundles
are compiled from live docs pages on request (src/bundles/manifest.ts). The SQLite
index is not bundled — mount it at /app/data or build it inside the
container before launch (see "Index build" below). The server still boots
without the index; /health will report degraded and the search tool will
return backend_unavailable per call until the index is in place.
Build a mounted index with the builder of the release you run. A newer server
serves an index from an older builder, but without the search text its ranking
relies on: 0.1.7 on a 0.1.6 index has no search keywords ("unsend", "blue
ticks"), title keys or code languages, and logs sqlite_index_outdated at warn,
naming the missing columns, when it opens one.
The image ships without the SQLite index. Build the index against the latest cometchat/docs clone, then mount or COPY it into the container.
git clone --depth 1 https://github.com/cometchat/docs.git /tmp/cometchat-docs
DOCS_REPO=/tmp/cometchat-docs INDEX_PATH=./data/index.sqlite npm run build:index
The provided GitHub Actions workflow (.github/workflows/ci.yml) already does this and uploads the index as an artifact — wire that artifact to your deployment job.
npm run eval:search scores ranking against the golden queries in scripts/eval-search.ts (INDEX_PATH is the index; with DOCS_REPO set it first checks that every page a case names exists). It exits 1 when a check fails and 2 when a case names a missing page.
CI runs it as the search-eval job in ci.yml, on the cometchat/docs commit pinned in .github/eval-docs-commit: a blobless, sparse checkout of the top-level files, *.mdx and *.md, as the Dockerfile clones it, then an index build and the eval. A workflow job blocks a merge only once it is a required status check, which lives in GitHub settings, not in this repository: add search-eval (with test and index-refresh) under Settings → Branches → the main protection rule, or the job goes red without stopping anything. A docs change therefore never fails a pull request. search-eval-drift.yml runs the same eval against docs main every day, gating nothing, and its job summary says whether main has drifted: exit 2 means a case names a page main moved or removed, exit 1 that a check fails on main's docs. Update the cases and bump the pin in the same change.
- Daily cron rebuild of the index against
cometchat/docs@main. - Optional: GitHub webhook on
pushtocometchat/docstriggers a rebuild + roll.
- Public HTTPS endpoint terminated at the load balancer or CDN.
- Anthropic outbound traffic originates from
160.79.104.0/21. Allowlist explicitly if a WAF is in front. - No inbound auth — docs are public. Per Anthropic's
noneauth-type spec, accept any incoming connection on/mcp. - DNS rebinding protection is on by default. Set
ALLOWED_HOSTSto the production hostname(s) the server is reachable at (comma-separated, including ports if non-standard, e.g.mcp.cometchat.com,mcp.cometchat.com:443). SetDNS_REBINDING_PROTECTION=falseonly if you have an upstream that already validates theHostheader. - Browser origins are refused by default. Browsers attach an
Originheader; native MCP clients (IDEs, CLIs, the SDK, and hosted connectors that call from their own backends, such as Claude's from the range above) send none and are unaffected. A/mcprequest whoseOriginis not listed inALLOWED_ORIGINSgets403, preflights included, and only listed origins get CORS headers. To use a client that connects straight from a browser page (MCP Inspector in direct mode, an in-tab client), setALLOWED_ORIGINSto its exact origins, comma-separated, e.g.ALLOWED_ORIGINS=http://localhost:6274. Matching is exact on scheme, host and port, with no wildcard.
| Env var | Default | Notes |
|---|---|---|
ALLOWED_HOSTS |
<HOST>:<PORT>,localhost:<PORT>,127.0.0.1:<PORT> |
Comma-separated Host allowlist. |
ALLOWED_ORIGINS |
unset | Comma-separated browser Origin allowlist for /mcp. Unset allows no browser origin (403); requests without an Origin are unaffected. |
DNS_REBINDING_PROTECTION |
true |
Set false to disable. |
NODE_ENV |
development |
Standard Node.js setting (Express production mode); set production in containers. |
RATE_LIMIT_ENABLED |
true |
Per-IP rate limit on /mcp. Set false for dev or behind an upstream limiter. Exactly true or false; any other value fails startup. |
RATE_LIMIT_MAX |
120 |
Max requests per window per IP. A positive integer: 0 or a non-number fails startup rather than silently switching the limiter off (use RATE_LIMIT_ENABLED=false). |
RATE_LIMIT_WINDOW_MS |
60000 |
Window in ms. A positive integer. |
The limiter reads the client IP from the rightmost X-Forwarded-For hop when present, otherwise from the socket. That hop is the one our own proxy appends, so a client cannot forge it; this assumes exactly one trusted proxy in front (see the client-IP note under analytics below). In a multi-replica deploy each replica keeps its own counter — for global limits, terminate at the LB or front the replicas with a shared limiter.
GET /health→200 { status: "ok", indexReady: true, indexAgeSeconds: N, bundles: 11, bundlesReady: N }when the SQLite index exists.bundlescounts the manifest;bundlesReadycounts bundles cached with every listed page in them; a bundle missing a deleted page is still served but not counted. Bundles are built on first use, so it starts at 0 after a restart. A docs outage doesn't change readiness: cached bundles keep being served. (There is nosessionscount: the server is stateless — see "Sessions".)GET /health→503 { status: "degraded", indexReady: false, indexAgeSeconds: null, ... }when the index is missing. Use this for readiness gating: a container without an index should be marked not-ready so the load balancer doesn't route to it.indexAgeSecondsis the file mtime of the SQLite index. The daily-rebuild cadence above means a healthy production should always be under ~26 hours (~94000s). Alert above that.
The server is stateless. Each POST is handled by a fresh transport and MCP
Server; there is no session map and nothing is held between requests.
Mcp-Session-Id is still issued at initialize and echoed by the client, but
it is a correlation label only — no state hangs off it. That is what makes
the server horizontally scalable:
- No load-balancer affinity is required. Any replica serves any request.
Do not configure sticky sessions; they are unnecessary (and cookie-based
stickiness would not work anyway — MCP clients are
fetch-based and do not persist cookies). - Rolling restarts no longer drop conversations, because no conversation state lives in a pod. A client's next request simply lands somewhere else.
- The id is server-minted at
initializeand validated as a UUID on echo; a client-supplied value of any other shape is ignored. GET /mcpreturns 405 — the server declareslistChanged: falseon both capabilities and sends no server-initiated messages, so there is no stream to offer.DELETE /mcpreturns 204 and is a no-op.
Structured JSON via pino. Log per tool invocation:
tool(name)duration_msstatus(success/ error code)session_id— correlates the calls of one working sessionclient_name,client_version— stdio only. Over HTTP these appear on thesession_openedline, not on per-call lines (theServerhandling a tool call never sawinitialize); correlate onsession_idref— install-source attribution, captured from?ref=<source>on the connect URL (only links we control carry it; marketplace installs attribute viaclient_name)query_length/path_length/bundle(for the relevant tool)
With INDEX_AUTO_REFRESH=true the server keeps its own search index current
without a redeploy. Every INDEX_POLL_INTERVAL_MS (default 10 min) it runs a
git ls-remote against the public docs repo (~1s, anonymous, no credentials).
When HEAD moves it sparse-clones only the .mdx blobs (~44 MB, ~2s), rebuilds
the index in a child process (~2s, ~245 MB peak, never blocking the event
loop), validates it, and hot-swaps it in.
A bad index cannot reach traffic. A candidate is rejected unless it clears all of:
| Guard | Default | Catches |
|---|---|---|
INDEX_MIN_PAGES |
2000 | empty or catastrophically broken build |
INDEX_MAX_DROP_RATIO |
0.2 | a docs merge that silently deletes a chunk of pages |
| docs.json navigation coverage | INDEX_MAX_DROP_RATIO (0.2) |
a docs.json the builder cannot use (missing, unparseable, reshaped, or no longer matching page paths): every page still builds, but versions silently fall back to paths. Applies only when the served index took versions from docs.json |
journal_mode != wal |
— | a file that would fail on a read-only mount |
| post-swap smoke queries | — | an index that validates but cannot answer |
If the smoke queries fail after the swap, the server reverts to the previous generation immediately (an in-memory reference swap on a file already on disk — sub-second) and marks that commit poisoned so the poller stops retrying it. A later, healthy commit is accepted normally.
Three fallback tiers: current generation → previous generation
(INDEX_KEEP_GENERATIONS, on the work volume) → the index baked into the image, which is
immutable and always present. The server therefore never depends on GitHub
being reachable at boot.
Requirements: git in the runtime image (already added), a writable
INDEX_WORK_DIR, and ~1 GB task memory to cover the builder's peak. The image
ships production dependencies only (npm prune --omit=dev in the
Dockerfile build stage); tsx is a production dependency because the
refresh runs the index build under it, and tests/image-layout.test.ts
fails if it moves back to devDependencies. The work
dir is the only writable path the task needs: the clone, the candidate
index, and the build child's TMPDIR/SQLITE_TMPDIR all live under it, because
/tmp is unwritable when the root filesystem is read-only (the tsx runner
creates an IPC socket in the temp dir and fails otherwise). On Fargate
that writable path is an empty task volume backed by ephemeral storage
(volumes: [{ name: index-work }] + a mountPoints entry) — Fargate does not
support the tmpfs container parameter, which is EC2-launch-type only. The
volume is writable while readonlyRootFilesystem: true still applies to the
rest of the filesystem, and it does not consume task memory.
Fargate mounts that volume root-owned, and does not inherit the image
directory's ownership the way a local Docker named volume does — so a
container running as non-root (node) gets EACCES on first use, and the
refresh never runs while /health still reports ok. The task definition
therefore includes an init-perms container: the same image with user: "0",
which chowns the work dir to 1000:1000 and exits, with the app container
gated on it via dependsOn: SUCCESS. Verified on staging 2026-09-04 —
without it: EACCES: permission denied, mkdtemp; with it: refresh succeeds.
Incident escape hatch: set DOCS_COMMIT_PIN=<good-sha> to freeze on a known
commit, or INDEX_AUTO_REFRESH=false to fall back to the baked index. Both
take effect on the next deploy.
A navigation_regression rejection keeps the last good index serving until a
later docs commit restores the navigation, or until the next deploy rebaselines:
the deploy bakes its index from docs HEAD, so an intentional large navigation
change (delisting a legacy version while keeping its pages, say) applies then.
INDEX_MAX_DROP_RATIO=1 disables both relative rules, page count and
navigation coverage; it too takes effect on the next deploy.
/health reports indexRefresh with docsCommit, builtAt, lastCheckedAt,
retained generations and lastRefreshOk. The diagnostic fields — lastError
(sanitized, but still carrying filesystem paths and errno codes) and poisoned
— are withheld from the public payload and released only when a request carries
HEALTH_DETAIL_TOKEN, via the x-health-token header (preferred; query strings
end up in access logs) or ?detail=<token>. Comparison is timing-safe and fails
closed: with no token configured the detail is unreachable.
Log events: index_refresh_started, index_refresh_build_finished,
index_refresh_succeeded (with pages, bytes and navPages),
index_refresh_rejected, index_refresh_reverted, index_refresh_cycle_failed,
index_refresh_commit_abandoned (a commit whose refresh kept failing is given
up on) and index_refresh_ref_unresolved (DOCS_REF matched nothing).
index_refresh_build_finished summarizes the builder's stderr once per build:
docsCommit, duration_ms, ok, warnings (the count of WARNING: lines),
lines (at most 20, each at most 300 characters, stack frames dropped, and
control characters, line and paragraph separators and format characters such as
bidi and zero-width marks replaced with spaces) and linesOmitted; a failed
build adds exitCode, signal and timedOut. The builder runs under tsx,
which exits 128+n when the builder dies of signal n, so signal is also read
from that exit code: an out-of-memory kill logs exitCode 137 and signal
SIGKILL. It logs at info when the build is clean, and at warn when the
builder printed a WARNING: line (a degraded index: docs.json unusable, pages skipped for executable frontmatter, version
labels the filter cannot match) or the build failed, which also logs
index_refresh_cycle_failed. index_refresh_rejected carries code (such as
page_count_regression or navigation_regression), reason, and candidate
and served as {pages, navPages}, with served null when no index was being
served. Alert on warn-level index_refresh_build_finished, on
index_refresh_rejected, index_refresh_reverted and
index_refresh_ref_unresolved, and on index_refresh_commit_abandoned or a
sustained run of index_refresh_cycle_failed: a refresh that fails before it
builds (the EACCES above, a clone or ls-remote error) logs no build or
rejection event, while /health still reports status: ok (only
indexRefresh.lastRefreshOk turns false). A failed build logs both
index_refresh_build_finished and index_refresh_cycle_failed, so count it
once.
With POSTHOG_KEY + ANALYTICS_SALT set, the server emits four events tagged
source: "docs-mcp": mcp_session_started (emitted once the transport has
accepted the handshake, so rejected requests are not counted as installs; includes client_name/client_version from
clientInfo, protocol_version, ref, is_anthropic_egress, ip_hash),
mcp_tool_called / mcp_tool_failed (per call, with tool, status,
duration_ms, bundle_id, error_code). distinct_id is a salted
mcp:-namespaced fingerprint of the client IP alone — clientInfo exists
only in the initialize body and cannot be reproduced per request, and hashing
client_version made every client auto-update look like a new install. Person
profiles are disabled; raw IPs and search-query text are never sent.
There is no mcp_session_ended: with a stateless, multi-replica server no
single process sees a session end. Session duration and per-session call counts
are derived at query time instead (the last event is the end) — see
scripts/analytics/weekly-report.sql §3b.
Per-call client identity is only available on stdio. Over HTTP the
tool_invocation log lines and mcp_tool_called events carry session_id and
ref but not client_name/client_version, because the Server handling a
tool call never saw initialize. Correlate on session_id with
session_opened / mcp_session_started to attribute calls to a client.
- Client split:
client_namedistinguishes agents (claude-ai / Claude Code / cursor / codex / …) — enumerate values from real traffic, don't hardcode a match list.is_anthropic_egress(160.79.104.0/21) marks claude.ai/Desktop/API traffic, whose shared egress IPs make per-client dedupe impossible — report those as sessions. - Client IP = rightmost X-Forwarded-For hop (shared with the rate
limiter). Proxies append to XFF, so the last hop is the address our own
ALB/nginx saw — the only one a client can't forge. The first hop is
attacker-chosen and would allow minting unlimited fingerprints and spoofing
is_anthropic_egress. This assumes exactly ONE trusted proxy in front; inserting a CDN makes the last hop the CDN's address — every client then collapses into one fingerprint. Verify distinct fingerprints on real traffic after any LB/CDN change. - Session end is not an event.
DELETE /mcpis a no-op and a stateless server never observes an abandonment, so duration and per-session call counts are derived at query time — the last event for asession_idis its end. - Absent
POSTHOG_KEY, everything is a no-op (local dev, stdio, tests). - Point at the dev PostHog project first; numbers only accrue once prod has the key. Nothing is retroactive.
- Weekly report: runnable HogQL in
scripts/analytics/weekly-report.sql(headline table, client split,refbreakdown, top bundles).
Aggregate at the platform level (Datadog, CloudWatch, etc.) and watch for:
- p95 tool latency > 1.5 s.
backend_unavailablerate > 1% of calls.- Sustained
unknown_bundlecalls — signal that we're missing a popular scenario.
The image is stateless. Roll back by pointing the load balancer at the previous tag. The index is a build artifact (the full target bakes its own). An old image plus the current index works as long as the schema didn't change, or changed compatibly: 0.1.6 and 0.1.7 each serve the other's index. 0.1.6 reads the columns 0.1.7 adds as plain search text, and 0.1.7 serves a 0.1.6 index without its search fixes (see "Container").
Once mcp.cometchat.com is accepted into Anthropic's directory:
- Update
mcp-server.mdxincometchat/docsto point users at the new connector. - Add an HTTP 301 from
cometchat.com/docs/mcp→mcp.cometchat.com/mcp(or simply leave both up for one release; do not register two CometChat listings in the directory).