Bound shared MCP container lifecycle

This commit is contained in:
2026-07-24 13:03:09 -07:00
parent 8de9658b8c
commit 6177a995d5
17 changed files with 1351 additions and 117 deletions

View File

@@ -16,6 +16,9 @@ CONTEXT_KIT_WEB_SEARCH_MAX_BYTES=52428800
# Web-search defaults. Search uses SearXNG first, then falls back to # Web-search defaults. Search uses SearXNG first, then falls back to
# DuckDuckGo and Bing. Bing requires Chromium inside the web-search image. # DuckDuckGo and Bing. Bing requires Chromium inside the web-search image.
CONTEXT_KIT_WEB_SEARCH_PORT=8777
# Override only for a loopback proxy that preserves the Host/Origin policy.
# CONTEXT_KIT_WEB_SEARCH_HTTP_URL=http://127.0.0.1:8777/mcp
CONTEXT_KIT_WEB_SEARCH_PROVIDER=searxng CONTEXT_KIT_WEB_SEARCH_PROVIDER=searxng
CONTEXT_KIT_WEB_SEARCH_HTTP_TIMEOUT=15000 CONTEXT_KIT_WEB_SEARCH_HTTP_TIMEOUT=15000
CONTEXT_KIT_WEB_SEARCH_MAX_RESULTS=10 CONTEXT_KIT_WEB_SEARCH_MAX_RESULTS=10

View File

@@ -56,9 +56,17 @@ config that will not be committed.
## How It Runs ## How It Runs
- SearXNG binds to `127.0.0.1:8099` only. - SearXNG binds to `127.0.0.1:8099` only.
- `context-web-search` and `context-repomix` run as local stdio MCP commands. - `context-web-search` and `context-docs` are long-lived HTTP MCP services on
- `context-docs` runs as a local HTTP MCP service. `bin/context-kit docs` is a `127.0.0.1:8777` and `127.0.0.1:8776`. All assistant projects reuse them.
stdio fallback for clients that cannot use HTTP MCP. - `bin/context-kit web-search` and `bin/context-kit docs` are stdio bridges for
local clients that cannot use HTTP MCP directly.
- `context-repomix` remains a per-call stdio container because its read-only
project mount is caller-specific.
- Shared containers and the network have Compose-derived deterministic names.
Web search records the owning host uid; client containers are named and
labeled per launcher process and remove only themselves.
- Web search uses stateless MCP HTTP sessions, validates Host, rejects every
supplied Origin, and exits for Docker restart if its stdio backend dies.
- `context-docs` browser CORS is disabled by default; set exact local origins - `context-docs` browser CORS is disabled by default; set exact local origins
only when a browser-based client needs direct access. only when a browser-based client needs direct access.
- Docs and model caches live in `$HOME/.local/share/context-kit`. - Docs and model caches live in `$HOME/.local/share/context-kit`.
@@ -137,13 +145,24 @@ bin/context-kit docs
bin/context-kit repomix bin/context-kit repomix
``` ```
After pulling Context Kit updates, rebuild local images and restart services: For this upgrade from `origin/main`, build images and safely add the missing
shared web-search service without recreating the existing SearXNG or docs
containers:
```sh ```sh
bin/context-kit build bin/context-kit build
bin/context-kit restart bin/context-kit start
``` ```
`start` always uses Compose `--no-recreate`. `restart` restarts the same
container IDs and does not apply a rebuilt image or changed container
environment. Context Kit intentionally has no implicit destructive replacement
command.
When an update changes an MCP transport, regenerate the assistant snippet and
replace the corresponding configuration before restarting the assistant. The
current snippets connect both web search and docs directly over HTTP.
## Security Model ## Security Model
Context Kit is local-first, but MCP tools still extend what your agent can do. Context Kit is local-first, but MCP tools still extend what your agent can do.
@@ -163,6 +182,7 @@ See `docs/security.md` for details.
- Docker with Compose v2 - Docker with Compose v2
- Bash - Bash
- `curl` for health checks - `curl` for health checks
- `flock` from util-linux for serialized service lifecycle operations
No hosted API keys are required for the default stack. No hosted API keys are required for the default stack.

View File

@@ -48,20 +48,21 @@ fi
DEFAULT_DATA_DIR="${HOME:-}/.local/share/context-kit" DEFAULT_DATA_DIR="${HOME:-}/.local/share/context-kit"
PROJECT="${CONTEXT_KIT_COMPOSE_PROJECT:-context-kit}" PROJECT="${CONTEXT_KIT_COMPOSE_PROJECT:-context-kit}"
[[ "${PROJECT}" =~ ^[a-z0-9][a-z0-9_-]*$ ]] || fail "invalid CONTEXT_KIT_COMPOSE_PROJECT: ${PROJECT}"
HOST_UID="$(id -u)"
COMPOSE_FILE="${ROOT}/compose.yml" COMPOSE_FILE="${ROOT}/compose.yml"
DATA_DIR="${CONTEXT_KIT_DATA_DIR:-${DEFAULT_DATA_DIR}}" DATA_DIR="${CONTEXT_KIT_DATA_DIR:-${DEFAULT_DATA_DIR}}"
NETWORK="${PROJECT}_default" NETWORK="${PROJECT}_default"
SEARXNG_PORT="${CONTEXT_KIT_SEARXNG_PORT:-8099}" SEARXNG_PORT="${CONTEXT_KIT_SEARXNG_PORT:-8099}"
WEB_SEARCH_PORT="${CONTEXT_KIT_WEB_SEARCH_PORT:-8777}"
WEB_SEARCH_HTTP_URL="${CONTEXT_KIT_WEB_SEARCH_HTTP_URL:-http://127.0.0.1:${WEB_SEARCH_PORT}/mcp}"
DOCS_PORT="${CONTEXT_KIT_DOCS_PORT:-8776}" DOCS_PORT="${CONTEXT_KIT_DOCS_PORT:-8776}"
DOCS_HTTP_URL="${CONTEXT_KIT_DOCS_HTTP_URL:-http://127.0.0.1:${DOCS_PORT}/mcp}" DOCS_HTTP_URL="${CONTEXT_KIT_DOCS_HTTP_URL:-http://127.0.0.1:${DOCS_PORT}/mcp}"
WEB_SEARCH_MAX_BYTES="${CONTEXT_KIT_WEB_SEARCH_MAX_BYTES:-52428800}" WEB_SEARCH_SERVICE_NAME="web-search-mcp"
WEB_SEARCH_PROVIDER="${CONTEXT_KIT_WEB_SEARCH_PROVIDER:-searxng}"
WEB_SEARCH_HTTP_TIMEOUT="${CONTEXT_KIT_WEB_SEARCH_HTTP_TIMEOUT:-15000}"
WEB_SEARCH_MAX_RESULTS="${CONTEXT_KIT_WEB_SEARCH_MAX_RESULTS:-10}"
WEB_SEARCH_CHROME_PATH="${CONTEXT_KIT_WEB_SEARCH_CHROME_PATH:-/usr/bin/chromium}"
WEB_SEARCH_BROWSER_USER_AGENT="${CONTEXT_KIT_WEB_SEARCH_BROWSER_USER_AGENT:-}"
WEB_SEARCH_MCP_COMPAT_MODE="${CONTEXT_KIT_WEB_SEARCH_MCP_COMPAT_MODE:-}"
DOCS_SERVICE_NAME="docs-mcp" DOCS_SERVICE_NAME="docs-mcp"
SHARED_SERVICES=(searxng "${WEB_SEARCH_SERVICE_NAME}" "${DOCS_SERVICE_NAME}")
SNAPSHOT_IDS=()
SNAPSHOT_RUNNING=()
DOCS_SOURCES_FILE="${DATA_DIR}/docs-sources.txt" DOCS_SOURCES_FILE="${DATA_DIR}/docs-sources.txt"
DOCS_DATA_DIR="${DATA_DIR}/docs" DOCS_DATA_DIR="${DATA_DIR}/docs"
MODELS_DATA_DIR="${DATA_DIR}/models" MODELS_DATA_DIR="${DATA_DIR}/models"
@@ -77,16 +78,16 @@ usage() {
context-kit: local context tools for coding agents context-kit: local context tools for coding agents
Usage: Usage:
context-kit start Start SearXNG + the long-lived docs-mcp service context-kit start Start the shared SearXNG and HTTP MCP services
context-kit stop Stop SearXNG + docs-mcp context-kit stop Stop the shared services without removing them
context-kit restart Restart SearXNG + docs-mcp context-kit restart Restart the shared services
context-kit build Build MCP images context-kit build Build MCP images
context-kit status Show services, images, sources, and the docs HTTP endpoint context-kit status Show services, images, sources, and shared HTTP endpoints
context-kit doctor Check Docker, services, images, sources, and HTTP endpoints context-kit doctor Check Docker, services, images, sources, and HTTP endpoints
context-kit redaction-check Scan this repo for local paths and secret patterns context-kit redaction-check Scan this repo for local paths and secret patterns
MCP server commands: MCP server commands:
context-kit web-search Per-call SearXNG-backed web-search MCP (stdio) context-kit web-search Stdio bridge to the shared web-search service
context-kit docs Stdio bridge to the long-lived docs-mcp service context-kit docs Stdio bridge to the long-lived docs-mcp service
(clients that speak HTTP MCP should connect (clients that speak HTTP MCP should connect
directly to the URL printed by `status`) directly to the URL printed by `status`)
@@ -102,7 +103,10 @@ USAGE
compose() { compose() {
CONTEXT_KIT_DATA_DIR="${DATA_DIR}" \ CONTEXT_KIT_DATA_DIR="${DATA_DIR}" \
CONTEXT_KIT_COMPOSE_PROJECT="${PROJECT}" \
CONTEXT_KIT_HOST_UID="${HOST_UID}" \
CONTEXT_KIT_SEARXNG_PORT="${SEARXNG_PORT}" \ CONTEXT_KIT_SEARXNG_PORT="${SEARXNG_PORT}" \
CONTEXT_KIT_WEB_SEARCH_PORT="${WEB_SEARCH_PORT}" \
CONTEXT_KIT_DOCS_PORT="${DOCS_PORT}" \ CONTEXT_KIT_DOCS_PORT="${DOCS_PORT}" \
CONTEXT_KIT_DOCS_UID="$(id -u)" \ CONTEXT_KIT_DOCS_UID="$(id -u)" \
CONTEXT_KIT_DOCS_GID="$(id -g)" \ CONTEXT_KIT_DOCS_GID="$(id -g)" \
@@ -128,7 +132,7 @@ write_docs_sources_file() {
mkdir -p "$(dirname "${DOCS_SOURCES_FILE}")" mkdir -p "$(dirname "${DOCS_SOURCES_FILE}")"
local tmp="${DOCS_SOURCES_FILE}.tmp.$$" local tmp="${DOCS_SOURCES_FILE}.tmp.$$"
{ {
printf '# generated by context-kit start; edit your CONTEXT_KIT_DOCS_SOURCES file(s) instead\n' printf '# generated by context-kit lifecycle commands; edit your CONTEXT_KIT_DOCS_SOURCES file(s) instead\n'
resolved_sources resolved_sources
} > "${tmp}" } > "${tmp}"
mv "${tmp}" "${DOCS_SOURCES_FILE}" mv "${tmp}" "${DOCS_SOURCES_FILE}"
@@ -204,6 +208,114 @@ require_network() {
docker network inspect "${NETWORK}" >/dev/null 2>&1 || fail "missing Docker network ${NETWORK}; run: context-kit start" docker network inspect "${NETWORK}" >/dev/null 2>&1 || fail "missing Docker network ${NETWORK}; run: context-kit start"
} }
with_lifecycle_lock() {
command -v flock >/dev/null 2>&1 || fail "flock is required for shared service lifecycle operations"
command -v stat >/dev/null 2>&1 || fail "stat is required for shared service lifecycle operations"
local lock_dir="/tmp/context-kit-${PROJECT}.lock" owner mode
[[ -d /tmp && ! -L /tmp ]] || fail "canonical lifecycle lock parent /tmp is unsafe"
if mkdir -m 700 "${lock_dir}" 2>/dev/null; then
chmod 700 "${lock_dir}"
fi
[[ -d "${lock_dir}" && ! -L "${lock_dir}" ]] || fail "unsafe lifecycle lock path: ${lock_dir}"
owner="$(stat -c %u "${lock_dir}")"
mode="$(stat -c %a "${lock_dir}")"
[[ "${owner}" == "${HOST_UID}" && "${mode}" == "700" ]] \
|| fail "shared Compose project ${PROJECT} lock has uid ${owner} and mode ${mode}; expected uid ${HOST_UID} and mode 700"
(
flock -x 9
assert_project_owner
"$@"
) 9>"${lock_dir}/lifecycle"
}
service_container_id() {
local service="$1" ids
ids="$(compose ps --all --quiet "${service}")"
[[ "${ids}" != *$'\n'* ]] || fail "multiple containers found for shared service ${service}"
printf '%s' "${ids}"
}
container_running() {
docker inspect -f '{{.State.Running}}' "$1" 2>/dev/null | grep -qx true
}
container_exists() {
docker inspect "$1" >/dev/null 2>&1
}
shared_service_running() {
local container_id
container_id="$(service_container_id "$1" 2>/dev/null || true)"
[[ -n "${container_id}" ]] && container_running "${container_id}"
}
container_matches_shared_service() {
local container_id="$1" service="$2" labels
labels="$(docker inspect -f '{{ index .Config.Labels "com.docker.compose.project" }}:{{ index .Config.Labels "com.docker.compose.service" }}' "${container_id}" 2>/dev/null || true)"
[[ "${labels}" == "${PROJECT}:${service}" ]]
}
assert_project_owner() {
local container_id configured_uid
container_id="$(service_container_id "${DOCS_SERVICE_NAME}" 2>/dev/null || true)"
if [[ -n "${container_id}" ]]; then
configured_uid="$(docker inspect -f '{{.Config.User}}' "${container_id}" 2>/dev/null || true)"
configured_uid="${configured_uid%%:*}"
[[ "${configured_uid}" == "${HOST_UID}" ]] \
|| fail "shared Compose project ${PROJECT} docs service belongs to uid ${configured_uid:-unknown}; cross-user ownership is unsupported"
fi
container_id="$(service_container_id "${WEB_SEARCH_SERVICE_NAME}" 2>/dev/null || true)"
if [[ -n "${container_id}" ]]; then
configured_uid="$(docker inspect -f '{{ index .Config.Labels "dev.context-kit.uid" }}' "${container_id}" 2>/dev/null || true)"
[[ "${configured_uid}" == "${HOST_UID}" ]] \
|| fail "shared Compose project ${PROJECT} web-search service belongs to uid ${configured_uid:-unknown}; cross-user ownership is unsupported"
fi
}
cleanup_owned_container() {
local container_id="$1" owner="$2" actual_owner
[[ -n "${container_id}" ]] || return 0
actual_owner="$(docker inspect -f '{{ index .Config.Labels "dev.context-kit.owner" }}' "${container_id}" 2>/dev/null || true)"
[[ "${actual_owner}" == "${owner}" ]] || return 0
docker rm -f "${container_id}" >/dev/null 2>&1 || true
}
run_owned_stdio_container() {
local role="$1"
shift
local uid owner name container_id='' status=0
uid="$(id -u)"
owner="${PROJECT}:${role}:${uid}:$$"
name="${PROJECT}-${role}-${uid}-$$"
trap 'cleanup_owned_container "${container_id}" "${owner}"' EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
container_id="$(docker create -i --rm \
--name "${name}" \
--label dev.context-kit=true \
--label dev.context-kit.lifecycle=client \
--label "dev.context-kit.owner=${owner}" \
--label "dev.context-kit.role=${role}" \
"$@")" || status=$?
if [[ "${status}" -ne 0 ]]; then
cleanup_owned_container "${container_id}" "${owner}"
trap - EXIT HUP INT TERM
return "${status}"
fi
if [[ -n "${CONTEXT_KIT_DOCKER_CIDFILE:-}" ]]; then
printf '%s\n' "${container_id}" > "${CONTEXT_KIT_DOCKER_CIDFILE}"
fi
docker start -ai "${container_id}" <&0 || status=$?
cleanup_owned_container "${container_id}" "${owner}"
trap - EXIT HUP INT TERM
return "${status}"
}
wait_for_searxng() { wait_for_searxng() {
command -v curl >/dev/null 2>&1 || return 0 command -v curl >/dev/null 2>&1 || return 0
@@ -219,11 +331,39 @@ wait_for_searxng() {
return 1 return 1
} }
docs_service_running() { wait_for_web_search_mcp() {
local container_id command -v curl >/dev/null 2>&1 || return 1
container_id="$(compose ps -q "${DOCS_SERVICE_NAME}" 2>/dev/null || true)"
[[ -n "${container_id}" ]] || return 1 local attempt
docker inspect -f '{{.State.Running}}' "${container_id}" 2>/dev/null | grep -qx true for attempt in {1..60}; do
if probe_web_search_mcp; then
return 0
fi
sleep 1
done
warn "web-search-mcp did not become ready on 127.0.0.1:${WEB_SEARCH_PORT} after 60s (check: docker compose logs ${WEB_SEARCH_SERVICE_NAME})"
return 1
}
probe_web_search_mcp() {
local url="http://127.0.0.1:${WEB_SEARCH_PORT}/mcp" initialized tools
initialized="$(curl -fsS --max-time 10 \
-H 'Accept: application/json, text/event-stream' \
-H 'Content-Type: application/json' \
-H 'MCP-Protocol-Version: 2024-11-05' \
--data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"context-kit-doctor","version":"1"}}}' \
"${url}" 2>/dev/null)" || return 1
printf '%s' "${initialized}" | grep -Eq '"serverInfo"[[:space:]]*:' || return 1
tools="$(curl -fsS --max-time 10 \
-H 'Accept: application/json, text/event-stream' \
-H 'Content-Type: application/json' \
-H 'MCP-Protocol-Version: 2024-11-05' \
--data '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \
"${url}" 2>/dev/null)" || return 1
printf '%s' "${tools}" | grep -Eq '"name"[[:space:]]*:[[:space:]]*"search_web"' || return 1
printf '%s' "${tools}" | grep -Eq '"name"[[:space:]]*:[[:space:]]*"fetch_url"'
} }
wait_for_docs_mcp() { wait_for_docs_mcp() {
@@ -231,7 +371,7 @@ wait_for_docs_mcp() {
# First run can take a while: model download plus optional eager preindexing. # First run can take a while: model download plus optional eager preindexing.
local attempt http_ready=0 local attempt http_ready=0
for attempt in {1..180}; do for ((attempt=1; attempt <= 180; attempt++)); do
if curl -fsS -o /dev/null "http://127.0.0.1:${DOCS_PORT}/status" 2>/dev/null; then if curl -fsS -o /dev/null "http://127.0.0.1:${DOCS_PORT}/status" 2>/dev/null; then
http_ready=1 http_ready=1
break break
@@ -286,36 +426,130 @@ resolved_sources() {
cmd_build() { cmd_build() {
require_no_args "usage: context-kit build" "$@" require_no_args "usage: context-kit build" "$@"
require_docker require_docker
# web-search-mcp is still profile-gated (built but not auto-started); compose build web-search-mcp docs-mcp
# docs-mcp is a regular long-lived service so it builds without a profile.
compose --profile mcp build web-search-mcp
compose build docs-mcp
docker pull "${REPOMIX_IMAGE}" docker pull "${REPOMIX_IMAGE}"
} }
cmd_start() { snapshot_shared_services() {
require_no_args "usage: context-kit start" "$@" local service container_id
require_docker SNAPSHOT_IDS=()
SNAPSHOT_RUNNING=()
for service in "${SHARED_SERVICES[@]}"; do
container_id="$(service_container_id "${service}")"
SNAPSHOT_IDS+=("${container_id}")
if [[ -n "${container_id}" ]] && container_running "${container_id}"; then
SNAPSHOT_RUNNING+=(1)
else
SNAPSHOT_RUNNING+=(0)
fi
done
}
restore_shared_service_states() {
local remove_new="$1" index service current_id prior_id failed=0
for ((index=0; index < ${#SHARED_SERVICES[@]}; index++)); do
service="${SHARED_SERVICES[index]}"
prior_id="${SNAPSHOT_IDS[index]}"
current_id="$(service_container_id "${service}" 2>/dev/null || true)"
if [[ -z "${prior_id}" && "${remove_new}" == true && -n "${current_id}" ]]; then
if container_matches_shared_service "${current_id}" "${service}"; then
if ! docker rm -f "${current_id}" >/dev/null 2>&1; then
warn "failed to remove newly-created ${service} container ${current_id} during rollback"
failed=1
fi
else
warn "new ${service} container ${current_id} lacks the expected Compose ownership; leaving it untouched"
failed=1
fi
continue
fi
[[ -n "${prior_id}" ]] || continue
if [[ -n "${current_id}" && "${current_id}" != "${prior_id}" ]]; then
warn "${service} unexpectedly changed from ${prior_id} to ${current_id}; leaving the replacement untouched"
failed=1
continue
fi
if ! container_exists "${prior_id}"; then
warn "cannot restore missing pre-start ${service} container ${prior_id}"
failed=1
continue
fi
if [[ "${SNAPSHOT_RUNNING[index]}" -eq 1 ]] && ! container_running "${prior_id}"; then
if ! docker start "${prior_id}" >/dev/null 2>&1; then
warn "failed to restart prior ${service} container ${prior_id} during rollback"
failed=1
fi
elif [[ "${SNAPSHOT_RUNNING[index]}" -eq 0 ]] && container_running "${prior_id}"; then
if ! docker stop "${prior_id}" >/dev/null 2>&1; then
warn "failed to stop prior ${service} container ${prior_id} during rollback"
failed=1
fi
fi
done
return "${failed}"
}
shared_services_ready() {
wait_for_searxng && wait_for_web_search_mcp && wait_for_docs_mcp
}
start_locked() {
prepare_data_dirs prepare_data_dirs
if ! docker image inspect "${WEB_SEARCH_IMAGE}" >/dev/null 2>&1 || ! docker image inspect "${DOCS_IMAGE}" >/dev/null 2>&1; then if ! docker image inspect "${WEB_SEARCH_IMAGE}" >/dev/null 2>&1 || ! docker image inspect "${DOCS_IMAGE}" >/dev/null 2>&1; then
cmd_build cmd_build
fi fi
write_docs_sources_file write_docs_sources_file
compose up -d searxng docs-mcp
wait_for_searxng local result=0
wait_for_docs_mcp snapshot_shared_services
compose up -d --no-recreate "${SHARED_SERVICES[@]}" || result=$?
if [[ "${result}" -eq 0 ]] && ! shared_services_ready; then
result=1
fi
[[ "${result}" -ne 0 ]] || return 0
warn "shared service startup failed; restoring prior container states"
restore_shared_service_states true || true
return "${result}"
}
cmd_start() {
require_no_args "usage: context-kit start" "$@"
require_docker
with_lifecycle_lock start_locked
}
stop_locked() {
compose stop "${SHARED_SERVICES[@]}"
} }
cmd_stop() { cmd_stop() {
require_no_args "usage: context-kit stop" "$@" require_no_args "usage: context-kit stop" "$@"
require_docker require_docker
compose stop searxng docs-mcp with_lifecycle_lock stop_locked
} }
cmd_restart() { cmd_restart() {
require_no_args "usage: context-kit restart" "$@" require_no_args "usage: context-kit restart" "$@"
cmd_stop require_docker
cmd_start with_lifecycle_lock restart_locked
}
restart_locked() {
local result=0
write_docs_sources_file
snapshot_shared_services
compose restart "${SHARED_SERVICES[@]}" || result=$?
if [[ "${result}" -eq 0 ]] && ! shared_services_ready; then
result=1
fi
[[ "${result}" -ne 0 ]] || return 0
warn "shared service restart failed; restoring prior container states"
restore_shared_service_states false || true
return "${result}"
} }
cmd_status() { cmd_status() {
@@ -326,10 +560,17 @@ cmd_status() {
printf '\nImages\n' printf '\nImages\n'
docker image ls --format '{{.Repository}}:{{.Tag}}\t{{.Size}}' \ docker image ls --format '{{.Repository}}:{{.Tag}}\t{{.Size}}' \
| grep -E '^(context-kit/|ghcr.io/yamadashy/repomix:)' || true | grep -E '^(context-kit/|ghcr.io/yamadashy/repomix:)' || true
printf '\nActive per-call MCP containers\n' printf '\nClient-owned stdio MCP containers\n'
docker ps -a --filter label=dev.context-kit=true --format '{{.Names}}\t{{.Status}}\t{{.Image}}\t{{.Command}}\t{{.Label "com.docker.compose.service"}}' \ docker ps -a --filter label=dev.context-kit.lifecycle=client --format 'table {{.Names}}\t{{.Status}}\t{{.Label "dev.context-kit.role"}}\t{{.Label "dev.context-kit.owner"}}'
| awk -F '\t' 'BEGIN { print "NAMES\tSTATUS\tIMAGE\tCOMMAND" } $5 !~ /^(searxng|docs-mcp)$/ { print $1 "\t" $2 "\t" $3 "\t" $4 }' printf '\nLegacy unlabeled Context Kit containers (diagnostic only; never auto-removed)\n'
printf '\nDocs MCP endpoint\n- %s (service: %s)\n' "${DOCS_HTTP_URL}" "${DOCS_SERVICE_NAME}" docker ps -a --filter label=dev.context-kit=true \
--format '{{.Names}}\t{{.Status}}\t{{.Label "dev.context-kit.lifecycle"}}\t{{.Label "com.docker.compose.service"}}' \
| awk -F '\t' 'BEGIN { print "NAMES\tSTATUS" } $3 == "" && $4 == "" { print $1 "\t" $2 }'
printf '\nShared MCP endpoints\n- %s (service: %s)\n- %s (service: %s)\n' \
"${WEB_SEARCH_HTTP_URL}" "${WEB_SEARCH_SERVICE_NAME}" \
"${DOCS_HTTP_URL}" "${DOCS_SERVICE_NAME}"
printf '\nShared Docker ownership\n- project: %s\n- uid: %s\n- network: %s\n- cache volume: %s_searxng-cache\n' \
"${PROJECT}" "${HOST_UID}" "${NETWORK}" "${PROJECT}"
printf '\nDocs sources\n' printf '\nDocs sources\n'
resolved_sources | sed 's/^/- /' resolved_sources | sed 's/^/- /'
printf '\nLocal docs source directory\n- %s (served inside docs-mcp at http://127.0.0.1:%s/)\n' "${DOCS_LOCAL_SOURCES_DIR}" "${DOCS_LOCAL_SOURCES_PORT}" printf '\nLocal docs source directory\n- %s (served inside docs-mcp at http://127.0.0.1:%s/)\n' "${DOCS_LOCAL_SOURCES_DIR}" "${DOCS_LOCAL_SOURCES_PORT}"
@@ -384,6 +625,13 @@ cmd_doctor() {
ok=1 ok=1
fi fi
if command -v curl >/dev/null 2>&1 && probe_web_search_mcp; then
printf 'pass web-search-mcp initialize and tools/list on 127.0.0.1:%s\n' "${WEB_SEARCH_PORT}"
else
printf 'fail web-search-mcp MCP protocol probe failed on 127.0.0.1:%s (run context-kit start)\n' "${WEB_SEARCH_PORT}"
ok=1
fi
if command -v curl >/dev/null 2>&1 && curl -fsS -o /dev/null "http://127.0.0.1:${DOCS_PORT}/status" 2>/dev/null; then if command -v curl >/dev/null 2>&1 && curl -fsS -o /dev/null "http://127.0.0.1:${DOCS_PORT}/status" 2>/dev/null; then
printf 'pass docs-mcp HTTP responds on 127.0.0.1:%s\n' "${DOCS_PORT}" printf 'pass docs-mcp HTTP responds on 127.0.0.1:%s\n' "${DOCS_PORT}"
else else
@@ -405,23 +653,16 @@ cmd_web_search() {
require_docker require_docker
require_network require_network
require_image "${WEB_SEARCH_IMAGE}" "context-kit build" require_image "${WEB_SEARCH_IMAGE}" "context-kit build"
local cidfile_args=() if ! shared_service_running "${WEB_SEARCH_SERVICE_NAME}"; then
if [[ -n "${CONTEXT_KIT_DOCKER_CIDFILE:-}" ]]; then fail "long-lived web-search-mcp not running; start it with: context-kit start"
cidfile_args=(--cidfile "${CONTEXT_KIT_DOCKER_CIDFILE}")
fi fi
exec docker run --rm -i \
--label dev.context-kit=true \ run_owned_stdio_container web-search-bridge \
"${cidfile_args[@]}" \
--network "${NETWORK}" \ --network "${NETWORK}" \
-e DEFAULT_SEARCH_PROVIDER="${WEB_SEARCH_PROVIDER}" \ --entrypoint mcp-proxy \
-e SEARXNG_URL="http://searxng:8080" \ "${WEB_SEARCH_IMAGE}" \
-e CHROME_PATH="${WEB_SEARCH_CHROME_PATH}" \ --transport streamablehttp \
-e HTTP_TIMEOUT="${WEB_SEARCH_HTTP_TIMEOUT}" \ "http://${WEB_SEARCH_SERVICE_NAME}:8000/mcp"
-e MAX_BYTES="${WEB_SEARCH_MAX_BYTES}" \
-e MAX_RESULTS="${WEB_SEARCH_MAX_RESULTS}" \
-e BROWSER_SEARCH_USER_AGENT="${WEB_SEARCH_BROWSER_USER_AGENT}" \
-e MCP_COMPAT_MODE="${WEB_SEARCH_MCP_COMPAT_MODE}" \
"${WEB_SEARCH_IMAGE}"
} }
cmd_docs() { cmd_docs() {
@@ -435,18 +676,12 @@ cmd_docs() {
require_network require_network
require_image "${DOCS_IMAGE}" "context-kit build" require_image "${DOCS_IMAGE}" "context-kit build"
if ! docs_service_running; then if ! shared_service_running "${DOCS_SERVICE_NAME}"; then
fail "long-lived docs-mcp not running; start it with: context-kit start" fail "long-lived docs-mcp not running; start it with: context-kit start"
fi fi
local bridge_url="http://${DOCS_SERVICE_NAME}:8000/mcp" local bridge_url="http://${DOCS_SERVICE_NAME}:8000/mcp"
local cidfile_args=() run_owned_stdio_container docs-bridge \
if [[ -n "${CONTEXT_KIT_DOCKER_CIDFILE:-}" ]]; then
cidfile_args=(--cidfile "${CONTEXT_KIT_DOCKER_CIDFILE}")
fi
exec docker run --rm -i \
--label dev.context-kit=true \
"${cidfile_args[@]}" \
--network "${NETWORK}" \ --network "${NETWORK}" \
--entrypoint mcp-proxy \ --entrypoint mcp-proxy \
"${DOCS_IMAGE}" \ "${DOCS_IMAGE}" \
@@ -462,13 +697,7 @@ cmd_repomix() {
dir="$(project_dir)" dir="$(project_dir)"
mount_dir="${CONTEXT_KIT_REPOMIX_MOUNT_DIR:-${dir}}" mount_dir="${CONTEXT_KIT_REPOMIX_MOUNT_DIR:-${dir}}"
mount_dir="$(cd "${mount_dir}" && pwd -P)" mount_dir="$(cd "${mount_dir}" && pwd -P)"
local cidfile_args=() run_owned_stdio_container repomix \
if [[ -n "${CONTEXT_KIT_DOCKER_CIDFILE:-}" ]]; then
cidfile_args=(--cidfile "${CONTEXT_KIT_DOCKER_CIDFILE}")
fi
exec docker run --rm -i \
--label dev.context-kit=true \
"${cidfile_args[@]}" \
-v "${mount_dir}:${mount_dir}:ro" \ -v "${mount_dir}:${mount_dir}:ro" \
--workdir "${dir}" \ --workdir "${dir}" \
"${REPOMIX_IMAGE}" --mcp "${REPOMIX_IMAGE}" --mcp
@@ -483,22 +712,23 @@ snippet_command() {
} }
print_opencode() { print_opencode() {
local bin url local bin docs_url web_search_url
bin="$(json_escape "$(snippet_command "${1:-}")")" bin="$(json_escape "$(snippet_command "${1:-}")")"
url="$(json_escape "${DOCS_HTTP_URL}")" web_search_url="$(json_escape "${WEB_SEARCH_HTTP_URL}")"
docs_url="$(json_escape "${DOCS_HTTP_URL}")"
cat <<JSON cat <<JSON
{ {
"\$schema": "https://opencode.ai/config.json", "\$schema": "https://opencode.ai/config.json",
"mcp": { "mcp": {
"context-web-search": { "context-web-search": {
"type": "local", "type": "remote",
"command": ["${bin}", "web-search"], "url": "${web_search_url}",
"enabled": true, "enabled": true,
"timeout": 150000 "timeout": 150000
}, },
"context-docs": { "context-docs": {
"type": "remote", "type": "remote",
"url": "${url}", "url": "${docs_url}",
"enabled": true, "enabled": true,
"timeout": 150000 "timeout": 150000
}, },
@@ -514,19 +744,20 @@ JSON
} }
print_claude() { print_claude() {
local bin url local bin docs_url web_search_url
bin="$(json_escape "$(snippet_command "${1:-}")")" bin="$(json_escape "$(snippet_command "${1:-}")")"
url="$(json_escape "${DOCS_HTTP_URL}")" web_search_url="$(json_escape "${WEB_SEARCH_HTTP_URL}")"
docs_url="$(json_escape "${DOCS_HTTP_URL}")"
cat <<JSON cat <<JSON
{ {
"mcpServers": { "mcpServers": {
"context-web-search": { "context-web-search": {
"command": "${bin}", "type": "http",
"args": ["web-search"] "url": "${web_search_url}"
}, },
"context-docs": { "context-docs": {
"type": "http", "type": "http",
"url": "${url}" "url": "${docs_url}"
}, },
"context-repomix": { "context-repomix": {
"command": "${bin}", "command": "${bin}",
@@ -564,6 +795,7 @@ cmd_redaction_check() {
local grep_opts=( local grep_opts=(
-RInE -RInE
--exclude-dir=.git --exclude-dir=.git
--exclude=.git
--exclude-dir=.cache --exclude-dir=.cache
--exclude-dir=tmp --exclude-dir=tmp
--exclude=.env --exclude=.env

View File

@@ -21,9 +21,14 @@ services:
args: args:
MCP_WEB_SEARCH_MAX_BYTES: "${CONTEXT_KIT_WEB_SEARCH_MAX_BYTES:-52428800}" MCP_WEB_SEARCH_MAX_BYTES: "${CONTEXT_KIT_WEB_SEARCH_MAX_BYTES:-52428800}"
image: ${CONTEXT_KIT_WEB_SEARCH_IMAGE:-context-kit/web-search-mcp:latest} image: ${CONTEXT_KIT_WEB_SEARCH_IMAGE:-context-kit/web-search-mcp:latest}
profiles: ["mcp"] # Long-lived shared web-search MCP. Clients connect directly over
stdin_open: true # Streamable HTTP; stdio callers use a disposable proxy bridge.
tty: false restart: unless-stopped
init: true
depends_on:
- searxng
ports:
- "127.0.0.1:${CONTEXT_KIT_WEB_SEARCH_PORT:-8777}:8000"
environment: environment:
DEFAULT_SEARCH_PROVIDER: "${CONTEXT_KIT_WEB_SEARCH_PROVIDER:-searxng}" DEFAULT_SEARCH_PROVIDER: "${CONTEXT_KIT_WEB_SEARCH_PROVIDER:-searxng}"
SEARXNG_URL: "http://searxng:8080" SEARXNG_URL: "http://searxng:8080"
@@ -33,8 +38,17 @@ services:
MAX_RESULTS: "${CONTEXT_KIT_WEB_SEARCH_MAX_RESULTS:-10}" MAX_RESULTS: "${CONTEXT_KIT_WEB_SEARCH_MAX_RESULTS:-10}"
BROWSER_SEARCH_USER_AGENT: "${CONTEXT_KIT_WEB_SEARCH_BROWSER_USER_AGENT:-}" BROWSER_SEARCH_USER_AGENT: "${CONTEXT_KIT_WEB_SEARCH_BROWSER_USER_AGENT:-}"
MCP_COMPAT_MODE: "${CONTEXT_KIT_WEB_SEARCH_MCP_COMPAT_MODE:-}" MCP_COMPAT_MODE: "${CONTEXT_KIT_WEB_SEARCH_MCP_COMPAT_MODE:-}"
healthcheck:
test: ["CMD", "node", "/usr/local/lib/context-kit/mcp-probe.mjs", "http://127.0.0.1:8000/mcp"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
labels: labels:
dev.context-kit: "true" dev.context-kit: "true"
dev.context-kit.lifecycle: "shared"
dev.context-kit.owner: "host"
dev.context-kit.uid: "${CONTEXT_KIT_HOST_UID:-1000}"
docs-mcp: docs-mcp:
build: build:

View File

@@ -2,9 +2,11 @@ FROM node:22-bookworm-slim@sha256:813a7480f28fdadac1f7f5c824bcdad435b5bc1322a596
ARG MCP_WEB_SEARCH_VERSION=1.3.0 ARG MCP_WEB_SEARCH_VERSION=1.3.0
ARG MCP_WEB_SEARCH_MAX_BYTES=52428800 ARG MCP_WEB_SEARCH_MAX_BYTES=52428800
ARG MCP_PROXY_VERSION=0.12.0
COPY patch-mcp-web-search.mjs /tmp/patch-mcp-web-search.mjs COPY patch-mcp-web-search.mjs /tmp/patch-mcp-web-search.mjs
COPY overrides/bing.js /tmp/context-kit-bing-provider.js COPY overrides/bing.js /tmp/context-kit-bing-provider.js
COPY --chmod=0444 mcp-probe.mjs http-entrypoint.mjs /usr/local/lib/context-kit/
# Chromium intentionally tracks Debian security updates inside the pinned base # Chromium intentionally tracks Debian security updates inside the pinned base
# image family; Bing's browser path is more likely to break with stale Chromium # image family; Bing's browser path is more likely to break with stale Chromium
@@ -14,8 +16,13 @@ RUN apt-get update \
ca-certificates \ ca-certificates \
chromium \ chromium \
fonts-liberation \ fonts-liberation \
python3-venv \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN python3 -m venv /opt/mcp-proxy \
&& /opt/mcp-proxy/bin/pip install --no-cache-dir "mcp-proxy==${MCP_PROXY_VERSION}" \
&& /opt/mcp-proxy/bin/mcp-proxy --version
RUN npm install -g "@zhafron/mcp-web-search@${MCP_WEB_SEARCH_VERSION}" \ RUN npm install -g "@zhafron/mcp-web-search@${MCP_WEB_SEARCH_VERSION}" \
&& cp /tmp/context-kit-bing-provider.js /usr/local/lib/node_modules/@zhafron/mcp-web-search/dist/src/providers/bing.js \ && cp /tmp/context-kit-bing-provider.js /usr/local/lib/node_modules/@zhafron/mcp-web-search/dist/src/providers/bing.js \
&& node /tmp/patch-mcp-web-search.mjs \ && node /tmp/patch-mcp-web-search.mjs \
@@ -28,9 +35,12 @@ ENV CHROME_PATH=/usr/bin/chromium \
HTTP_TIMEOUT=15000 \ HTTP_TIMEOUT=15000 \
MAX_BYTES=${MCP_WEB_SEARCH_MAX_BYTES} \ MAX_BYTES=${MCP_WEB_SEARCH_MAX_BYTES} \
MAX_RESULTS=10 \ MAX_RESULTS=10 \
PATH=/opt/mcp-proxy/bin:$PATH \
SEARXNG_URL=http://searxng:8080 \ SEARXNG_URL=http://searxng:8080 \
XDG_CACHE_HOME=/tmp/.cache XDG_CACHE_HOME=/tmp/.cache
USER node USER node
ENTRYPOINT ["mcp-web-search"] EXPOSE 8000
ENTRYPOINT ["node", "/usr/local/lib/context-kit/http-entrypoint.mjs"]

View File

@@ -0,0 +1,183 @@
import http from "node:http";
import { spawn } from "node:child_process";
import { once } from "node:events";
import { setTimeout as delay } from "node:timers/promises";
import { fileURLToPath } from "node:url";
import { probeMcp } from "./mcp-probe.mjs";
const defaultUpstream = "http://127.0.0.1:8001";
export const mcpProxyArguments = Object.freeze([
"--host", "127.0.0.1",
"--port", "8001",
"--stateless",
"--pass-environment",
"--",
"mcp-web-search"
]);
const hopByHopHeaders = new Set([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade"
]);
export function hostAllowed(host) {
if (!host) return false;
const normalized = host.toLowerCase();
return /^(localhost|127\.0\.0\.1)(:\d+)?$/.test(normalized)
|| /^\[::1\](:\d+)?$/.test(normalized)
|| normalized === "web-search-mcp:8000";
}
function copyRequestHeaders(headers, upstreamHost) {
const copied = { ...headers, host: upstreamHost };
for (const name of hopByHopHeaders) delete copied[name];
delete copied.origin;
return copied;
}
function copyResponseHeaders(headers) {
const copied = {};
for (const [name, value] of Object.entries(headers)) {
if (!hopByHopHeaders.has(name) && !name.startsWith("access-control-")) copied[name] = value;
}
return copied;
}
export function createSecureMcpServer({ upstream = defaultUpstream, probe = probeMcp } = {}) {
const target = new URL(upstream);
return http.createServer(async (request, response) => {
if (!hostAllowed(request.headers.host)) {
response.writeHead(421, { "Content-Type": "text/plain" });
response.end("Invalid Host header");
return;
}
if (request.headers.origin !== undefined) {
response.writeHead(403, { "Content-Type": "text/plain" });
response.end("Invalid Origin header");
return;
}
if (request.url === "/healthz") {
try {
await probe(`${upstream}/mcp`);
response.writeHead(200, { "Content-Type": "text/plain" });
response.end("ok");
} catch (error) {
response.writeHead(503, { "Content-Type": "text/plain" });
response.end(`backend unavailable: ${error.message}`);
}
return;
}
if (!request.url?.startsWith("/")) {
response.writeHead(400, { "Content-Type": "text/plain" });
response.end("Invalid request target");
return;
}
const upstreamRequest = http.request({
hostname: target.hostname,
port: target.port,
method: request.method,
path: request.url,
headers: copyRequestHeaders(request.headers, target.host)
}, upstreamResponse => {
response.writeHead(
upstreamResponse.statusCode || 502,
copyResponseHeaders(upstreamResponse.headers)
);
upstreamResponse.pipe(response);
});
upstreamRequest.on("error", error => {
if (!response.headersSent) response.writeHead(502, { "Content-Type": "text/plain" });
response.end(`backend unavailable: ${error.message}`);
});
request.pipe(upstreamRequest);
});
}
export function superviseBackend({ probe, intervalMs = 10000, onFailure }) {
let stopped = false;
let timer;
const check = async () => {
if (stopped) return;
try {
await probe();
timer = setTimeout(check, intervalMs);
} catch (error) {
stopped = true;
onFailure(error);
}
};
timer = setTimeout(check, intervalMs);
return () => {
stopped = true;
clearTimeout(timer);
};
}
async function waitForBackend(child, url) {
let lastError;
for (let attempt = 0; attempt < 60; attempt += 1) {
if (child.exitCode !== null) throw new Error(`mcp-proxy exited during startup (${child.exitCode})`);
try {
await probeMcp(url, { timeoutMs: 1000 });
return;
} catch (error) {
lastError = error;
await delay(250);
}
}
throw new Error(`web-search backend did not become ready: ${lastError?.message}`);
}
async function main() {
const upstreamMcp = `${defaultUpstream}/mcp`;
const child = spawn("mcp-proxy", mcpProxyArguments, { stdio: ["ignore", "inherit", "inherit"] });
let server;
let stopSupervisor = () => {};
let shuttingDown = false;
const shutdown = async (code, reason) => {
if (shuttingDown) return;
shuttingDown = true;
if (reason) console.error(`web-search-mcp: ${reason}`);
stopSupervisor();
server?.close();
server?.closeAllConnections();
if (child.exitCode === null) {
child.kill("SIGTERM");
await Promise.race([once(child, "exit"), delay(3000)]).catch(() => {});
if (child.exitCode === null) child.kill("SIGKILL");
}
process.exitCode = code;
};
child.once("exit", (code, signal) => {
if (!shuttingDown) void shutdown(1, `mcp-proxy exited (code=${code}, signal=${signal})`);
});
process.once("SIGINT", () => void shutdown(0));
process.once("SIGTERM", () => void shutdown(0));
try {
await waitForBackend(child, upstreamMcp);
server = createSecureMcpServer();
server.listen(8000, "0.0.0.0");
await once(server, "listening");
stopSupervisor = superviseBackend({
probe: () => probeMcp(upstreamMcp),
onFailure: error => void shutdown(1, `backend probe failed: ${error.message}`)
});
} catch (error) {
await shutdown(1, error.message);
}
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) await main();

View File

@@ -0,0 +1,53 @@
import { fileURLToPath } from "node:url";
const protocolVersion = "2024-11-05";
const expectedTools = ["fetch_url", "search_web"];
async function rpc(url, id, method, params = {}, timeoutMs = 5000) {
const response = await fetch(url, {
method: "POST",
headers: {
Accept: "application/json, text/event-stream",
"Content-Type": "application/json",
"MCP-Protocol-Version": protocolVersion
},
body: JSON.stringify({ jsonrpc: "2.0", id, method, params }),
signal: AbortSignal.timeout(timeoutMs)
});
const text = await response.text();
if (!response.ok) throw new Error(`${method} returned HTTP ${response.status}: ${text.slice(0, 300)}`);
let payload;
if (response.headers.get("content-type")?.includes("text/event-stream")) {
const data = text.split("\n").find(line => line.startsWith("data: "))?.slice(6);
if (!data) throw new Error(`${method} returned an empty event stream`);
payload = JSON.parse(data);
} else {
payload = JSON.parse(text);
}
if (payload.error) throw new Error(`${method} returned ${JSON.stringify(payload.error)}`);
return payload.result;
}
export async function probeMcp(url, { timeoutMs = 5000 } = {}) {
const initialized = await rpc(url, 1, "initialize", {
protocolVersion,
capabilities: {},
clientInfo: { name: "context-kit-health", version: "1" }
}, timeoutMs);
if (!initialized?.serverInfo?.name) throw new Error("initialize response omitted serverInfo");
const listed = await rpc(url, 2, "tools/list", {}, timeoutMs);
const names = new Set((listed?.tools || []).map(tool => tool.name));
for (const name of expectedTools) {
if (!names.has(name)) throw new Error(`tools/list omitted ${name}`);
}
return Array.from(names).sort();
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
const url = process.argv[2];
if (!url) throw new Error("usage: node mcp-probe.mjs <streamable-http-url>");
const tools = await probeMcp(url);
console.log(JSON.stringify({ tools }));
}

View File

@@ -3,12 +3,14 @@
Context Kit supports assistants that can run local stdio MCP servers, HTTP MCP Context Kit supports assistants that can run local stdio MCP servers, HTTP MCP
servers, or both. The default transport split is simple: servers, or both. The default transport split is simple:
- `context-web-search`: local stdio command. - `context-web-search`: local HTTP MCP service.
- `context-docs`: local HTTP MCP service. - `context-docs`: local HTTP MCP service.
- `context-repomix`: local stdio command. - `context-repomix`: local stdio command.
`bin/context-kit docs` is a stdio fallback for clients that cannot use HTTP MCP. `bin/context-kit web-search` and `bin/context-kit docs` are stdio fallbacks for
The included snippets cover Claude Code and OpenCode. clients that cannot use HTTP MCP. They bridge to the shared services and do not
stop those services when the client exits. The included snippets cover Claude
Code and OpenCode.
## Claude Code ## Claude Code

View File

@@ -56,8 +56,10 @@ Only the variables below are part of the public configuration surface. Other
| Variable | Default | Purpose | | Variable | Default | Purpose |
|---|---|---| |---|---|---|
| `CONTEXT_KIT_DATA_DIR` | `$HOME/.local/share/context-kit` | Persistent docs indexes and model cache | | `CONTEXT_KIT_DATA_DIR` | `$HOME/.local/share/context-kit` | Persistent docs indexes and model cache |
| `CONTEXT_KIT_COMPOSE_PROJECT` | `context-kit` | Docker Compose project and network prefix | | `CONTEXT_KIT_COMPOSE_PROJECT` | `context-kit` | Shared-service ownership boundary and Compose name prefix |
| `CONTEXT_KIT_SEARXNG_PORT` | `8099` | Localhost SearXNG port | | `CONTEXT_KIT_SEARXNG_PORT` | `8099` | Localhost SearXNG port |
| `CONTEXT_KIT_WEB_SEARCH_PORT` | `8777` | Localhost port for the long-lived web-search HTTP service |
| `CONTEXT_KIT_WEB_SEARCH_HTTP_URL` | `http://127.0.0.1:${CONTEXT_KIT_WEB_SEARCH_PORT}/mcp` | URL emitted into HTTP MCP install snippets |
| `CONTEXT_KIT_WEB_SEARCH_MAX_BYTES` | `52428800` | Max bytes `context-web-search` accepts and downloads per fetch | | `CONTEXT_KIT_WEB_SEARCH_MAX_BYTES` | `52428800` | Max bytes `context-web-search` accepts and downloads per fetch |
| `CONTEXT_KIT_WEB_SEARCH_PROVIDER` | `searxng` | Default `search_web` provider; fallback order depends on this provider | | `CONTEXT_KIT_WEB_SEARCH_PROVIDER` | `searxng` | Default `search_web` provider; fallback order depends on this provider |
| `CONTEXT_KIT_WEB_SEARCH_HTTP_TIMEOUT` | `15000` | HTTP timeout in milliseconds for search providers | | `CONTEXT_KIT_WEB_SEARCH_HTTP_TIMEOUT` | `15000` | HTTP timeout in milliseconds for search providers |
@@ -76,26 +78,63 @@ Only the variables below are part of the public configuration surface. Other
| `CONTEXT_KIT_DOCS_LOCAL_SOURCES_DIR` | `${CONTEXT_KIT_DATA_DIR}/local-sources` | Machine-local llms.txt tree mounted read-only into docs-mcp | | `CONTEXT_KIT_DOCS_LOCAL_SOURCES_DIR` | `${CONTEXT_KIT_DATA_DIR}/local-sources` | Machine-local llms.txt tree mounted read-only into docs-mcp |
| `CONTEXT_KIT_DOCS_LOCAL_SOURCES_PORT` | `8769` | Loopback port inside docs-mcp for serving local source files | | `CONTEXT_KIT_DOCS_LOCAL_SOURCES_PORT` | `8769` | Loopback port inside docs-mcp for serving local source files |
## Docker Ownership
One Compose project owns the shared `searxng`, `web-search-mcp`, and `docs-mcp`
services. Compose derives stable container and network names from
`CONTEXT_KIT_COMPOSE_PROJECT`; the default network is `context-kit_default`.
Compose's existing labels remain the ownership markers for SearXNG, docs, the
network, and `searxng-cache`. Their definitions are unchanged from
`origin/main`, avoiding a resource-recreation prompt. The new web-search service
also records `dev.context-kit.uid`.
`start`, `stop`, and `restart` use one canonical
`/tmp/context-kit-PROJECT.lock` directory, which must be a non-symlink directory
owned by the current uid with mode `0700`. Existing docs and web-search
containers must have the same uid; cross-user lifecycle control is rejected.
`start` passes `--no-recreate` to Compose. On failure it removes only service
containers that did not exist before the attempt, restores prior running/stopped
states by exact container ID, and leaves the deterministic network and cache
volume intact for reuse. `restart` operates on the same container IDs and uses
the same state restoration. Neither command replaces an existing container.
`stop` stops containers without removing them or their network.
The stdio bridge commands and Repomix create uniquely named client containers
with `dev.context-kit.lifecycle=client` and an invocation-specific owner label.
Their cleanup verifies that owner label before removing the exact container ID.
Web search runs stateless MCP sessions. Its front end accepts only loopback Host
values or the internal `web-search-mcp:8000` service name and returns 403 for any
request carrying Origin. It probes initialize and tools/list periodically; a
dead stdio backend terminates the container so Docker can restart it.
`restart` restarts existing container IDs, so it reloads bind-mounted docs source
files but does not apply rebuilt images or changed container environment. There
is intentionally no automatic replacement path while the old container cannot
be restored transactionally.
## TTL Guidance ## TTL Guidance
`24h` is the default. Most reference docs do not need re-embedding more often, `24h` is the default. Most reference docs do not need re-embedding more often,
and the shared service does not re-fetch sources until the TTL elapses. and the shared service does not re-fetch sources until the TTL elapses.
Use shorter TTLs for fast-moving APIs: Set a shorter TTL in `.env` for fast-moving APIs:
```sh ```dotenv
CONTEXT_KIT_DOCS_TTL=6h bin/context-kit restart CONTEXT_KIT_DOCS_TTL=6h
``` ```
Use longer TTLs for stable specs: Set a longer TTL for stable specs:
```sh ```dotenv
CONTEXT_KIT_DOCS_TTL=30d bin/context-kit restart CONTEXT_KIT_DOCS_TTL=30d
``` ```
The docs-mcp container reads `CONTEXT_KIT_DOCS_TTL` at startup, so changes The docs-mcp container environment is fixed when Compose creates it. A safe
require `bin/context-kit restart`. When freshness matters for one task, prefer same-ID `restart` does not apply a changed TTL; it takes effect only when a new
calling the `docs_refresh` MCP tool instead of lowering the global TTL. container is explicitly provisioned. When freshness matters for one task,
prefer `docs_refresh` instead of replacing the shared container.
## Browser CORS ## Browser CORS
@@ -103,12 +142,13 @@ calling the `docs_refresh` MCP tool instead of lowering the global TTL.
HTTP clients do not need CORS. If a browser-based local client must call the MCP HTTP clients do not need CORS. If a browser-based local client must call the MCP
endpoint directly, allow only the exact local origin(s) it uses: endpoint directly, allow only the exact local origin(s) it uses:
```sh ```dotenv
CONTEXT_KIT_DOCS_ALLOW_ORIGIN="http://127.0.0.1:3000 http://localhost:3000" \ CONTEXT_KIT_DOCS_ALLOW_ORIGIN="http://127.0.0.1:3000 http://localhost:3000"
bin/context-kit restart
``` ```
Avoid `*`; the docs MCP is a local unauthenticated endpoint. Avoid `*`; the docs MCP is a local unauthenticated endpoint. Like other
container-environment changes, this takes effect only on explicit provisioning
of a new docs container, not a same-ID `restart`.
## Source Profiles ## Source Profiles

View File

@@ -5,8 +5,11 @@ Context Kit is designed to be safe by default for local development.
## Defaults ## Defaults
- SearXNG is bound to `127.0.0.1` only. - SearXNG is bound to `127.0.0.1` only.
- Web-search and docs MCP HTTP endpoints are bound to `127.0.0.1` only.
- No hosted API keys are required. - No hosted API keys are required.
- The web-search MCP image runs as the non-root `node` user. - The web-search MCP image runs as the non-root `node` user.
- Web-search MCP sessions are stateless. Its HTTP front end permits only
loopback/internal Host values and rejects every supplied Origin with 403.
- Repomix mounts only the current project read-only. - Repomix mounts only the current project read-only.
- Docs indexing stores data under `$HOME/.local/share/context-kit` unless you - Docs indexing stores data under `$HOME/.local/share/context-kit` unless you
override it. override it.
@@ -36,10 +39,14 @@ Do not expose SearXNG or MCP servers to the public internet without a separate
review. The default setup is for localhost development. review. The default setup is for localhost development.
The containers may bind to `0.0.0.0` internally, but the Compose file publishes The containers may bind to `0.0.0.0` internally, but the Compose file publishes
SearXNG and docs-mcp only on `127.0.0.1`. If you run the images outside the SearXNG, web-search-mcp, and docs-mcp only on `127.0.0.1`. If you run the images
provided Compose file, review port publishing, SearXNG's limiter/secret, and MCP outside the provided Compose file, review port publishing, SearXNG's
authentication separately. limiter/secret, and MCP authentication separately.
Browser CORS for `context-docs` is disabled by default. Only set Browser CORS for `context-docs` is disabled by default. Only set
`CONTEXT_KIT_DOCS_ALLOW_ORIGIN` for exact local origins that need direct browser `CONTEXT_KIT_DOCS_ALLOW_ORIGIN` for exact local origins that need direct browser
access; avoid wildcard origins for unauthenticated local MCP endpoints. access; avoid wildcard origins for unauthenticated local MCP endpoints.
`context-web-search` does not expose browser CORS configuration. Browser requests
carry Origin and are rejected; CLI/server-side MCP clients omit Origin. A local
reverse proxy must preserve this policy and present an allowed loopback Host.

View File

@@ -6,8 +6,9 @@
bin/context-kit doctor bin/context-kit doctor
``` ```
This checks Docker, Compose, images, the Docker network, SearXNG health, docs This checks Docker, Compose, images, the Docker network, SearXNG health, a real
HTTP readiness, and docs source configuration. web-search MCP initialize/tools-list exchange, docs HTTP readiness, and docs
source configuration.
For release-grade MCP protocol checks, run: For release-grade MCP protocol checks, run:
@@ -46,6 +47,65 @@ Build default images:
bin/context-kit build bin/context-kit build
``` ```
## Repeated Per-Project Containers
Current OpenCode and Claude snippets connect web search and docs directly to the
shared HTTP services. If every project still starts a web-search container,
regenerate the snippet, update the assistant configuration, and restart the
assistant:
```sh
bin/context-kit install opencode
bin/context-kit install claude
```
For the upgrade from `origin/main`, build and start once. `start` creates only
the missing web-search service and refuses to recreate existing services:
```sh
bin/context-kit build
bin/context-kit start
```
Context Kit never performs a global Docker prune. Inspect labeled resources and
their owners explicitly:
```sh
docker ps -a --filter label=dev.context-kit=true \
--format 'table {{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Label "dev.context-kit.lifecycle"}}\t{{.Label "dev.context-kit.owner"}}\t{{.Label "com.docker.compose.service"}}'
```
Containers from the old per-call web-search launcher have an empty Compose
service and no lifecycle/owner labels. After all old assistant processes are
stopped, remove only the exact legacy container IDs you verified; do not use a
name-pattern or global prune.
Lifecycle commands for one Compose project use a canonical, uid-owned lock and
reject cross-user control. Failed startup removes only newly-created service
containers, restores existing container states by ID, and never removes the
network or cache volume. `start` uses Compose `--no-recreate`, leaving an
existing container unchanged when its image or environment differs from the
current Compose model; it never silently replaces that container. Inspect the
shared services without deleting them:
```sh
bin/context-kit status
docker compose -p "${CONTEXT_KIT_COMPOSE_PROJECT:-context-kit}" -f compose.yml logs web-search-mcp docs-mcp searxng
```
Use the protocol-level doctor check. `/healthz` also performs initialize and
tools/list rather than trusting the proxy's static `/status` metadata:
```sh
bin/context-kit doctor
curl http://127.0.0.1:8777/healthz
```
`bin/context-kit status` lists legacy labeled containers that have neither a
Compose service nor lifecycle label. This is diagnostic only; Context Kit never
auto-removes them. Stop their old assistant owners before removing individually
verified container IDs.
## Fetch URL Says Max Download Bytes Is Too Big ## Fetch URL Says Max Download Bytes Is Too Big
If `fetch_url` fails before making a network request with an MCP validation error If `fetch_url` fails before making a network request with an MCP validation error

View File

@@ -19,8 +19,10 @@ release_id="release-$$"
export CONTEXT_KIT_COMPOSE_PROJECT="context-kit-${release_id}" export CONTEXT_KIT_COMPOSE_PROJECT="context-kit-${release_id}"
export CONTEXT_KIT_DATA_DIR="${tmp_dir}/data" export CONTEXT_KIT_DATA_DIR="${tmp_dir}/data"
export CONTEXT_KIT_PROJECT_DIR="${ROOT}" export CONTEXT_KIT_PROJECT_DIR="${ROOT}"
export CONTEXT_KIT_SEARXNG_PORT="$(pick_port)" CONTEXT_KIT_SEARXNG_PORT="$(pick_port)"
export CONTEXT_KIT_DOCS_PORT="$(pick_port)" CONTEXT_KIT_WEB_SEARCH_PORT="$(pick_port)"
CONTEXT_KIT_DOCS_PORT="$(pick_port)"
export CONTEXT_KIT_SEARXNG_PORT CONTEXT_KIT_WEB_SEARCH_PORT CONTEXT_KIT_DOCS_PORT
export CONTEXT_KIT_DOCS_LOCAL_SOURCES_DIR="${tmp_dir}/local-sources" export CONTEXT_KIT_DOCS_LOCAL_SOURCES_DIR="${tmp_dir}/local-sources"
export CONTEXT_KIT_DOCS_LOCAL_SOURCES_PORT="8769" export CONTEXT_KIT_DOCS_LOCAL_SOURCES_PORT="8769"
local_sources_profile="${tmp_dir}/sources.local.txt" local_sources_profile="${tmp_dir}/sources.local.txt"
@@ -95,6 +97,69 @@ if (!bing.includes("decodeBingRedirect")) process.exit(1);
docker run --rm --entrypoint /usr/bin/test \ docker run --rm --entrypoint /usr/bin/test \
"${CONTEXT_KIT_WEB_SEARCH_IMAGE}" \ "${CONTEXT_KIT_WEB_SEARCH_IMAGE}" \
-x "${CONTEXT_KIT_WEB_SEARCH_CHROME_PATH:-/usr/bin/chromium}" -x "${CONTEXT_KIT_WEB_SEARCH_CHROME_PATH:-/usr/bin/chromium}"
docker run --rm --entrypoint /usr/bin/test \
"${CONTEXT_KIT_WEB_SEARCH_IMAGE}" \
-x /opt/mcp-proxy/bin/mcp-proxy
docker run --rm --entrypoint /usr/bin/test \
"${CONTEXT_KIT_WEB_SEARCH_IMAGE}" \
-r /usr/local/lib/context-kit/http-entrypoint.mjs
docker run --rm --entrypoint /usr/bin/test \
"${CONTEXT_KIT_WEB_SEARCH_IMAGE}" \
-r /usr/local/lib/context-kit/mcp-probe.mjs
}
assert_hostile_requests_rejected() {
local status
status="$(curl -sS -o /dev/null -w '%{http_code}' \
-H 'Host: attacker.example' \
-H 'Content-Type: application/json' \
--data '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' \
"http://127.0.0.1:${CONTEXT_KIT_WEB_SEARCH_PORT}/mcp")"
[[ "${status}" == 421 ]] || {
printf 'hostile Host returned HTTP %s instead of 421\n' "${status}" >&2
return 1
}
status="$(curl -sS -o /dev/null -w '%{http_code}' \
-H 'Origin: https://attacker.example' \
-H 'Content-Type: application/json' \
--data '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' \
"http://127.0.0.1:${CONTEXT_KIT_WEB_SEARCH_PORT}/mcp")"
[[ "${status}" == 403 ]] || {
printf 'hostile Origin returned HTTP %s instead of 403\n' "${status}" >&2
return 1
}
}
assert_web_search_backend_supervision() {
local container_id before after attempt
container_id="$(docker compose -p "${CONTEXT_KIT_COMPOSE_PROJECT}" -f compose.yml ps -q web-search-mcp)"
before="$(docker inspect -f '{{.RestartCount}}' "${container_id}")"
docker exec "${container_id}" sh -eu -c '
for command_path in /proc/[0-9]*/cmdline; do
command="$(tr "\000" " " < "${command_path}")"
case "${command}" in
*node*mcp-web-search*)
pid="${command_path#/proc/}"
pid="${pid%/cmdline}"
kill -KILL "${pid}"
exit 0
;;
esac
done
exit 1
'
for ((attempt=1; attempt <= 60; attempt++)); do
after="$(docker inspect -f '{{.RestartCount}}' "${container_id}")"
if [[ "${after}" -gt "${before}" ]] && node docker/web-search/mcp-probe.mjs "http://127.0.0.1:${CONTEXT_KIT_WEB_SEARCH_PORT}/mcp" >/dev/null 2>&1; then
return 0
fi
sleep 1
done
printf 'web-search container did not restart after backend death\n' >&2
return 1
} }
git diff --check HEAD git diff --check HEAD
@@ -107,20 +172,27 @@ git ls-files --cached --error-unmatch \
scripts/smoke-web-search.mjs \ scripts/smoke-web-search.mjs \
scripts/smoke-docs.mjs \ scripts/smoke-docs.mjs \
scripts/smoke-repomix.mjs \ scripts/smoke-repomix.mjs \
scripts/test-compose-upgrade.sh \
scripts/test-lifecycle.sh \
scripts/test-web-search-http.mjs \
docker/web-search/mcp-probe.mjs \
docker/web-search/http-entrypoint.mjs \
scripts/release-check >/dev/null scripts/release-check >/dev/null
bash -n bin/context-kit bash -n bin/context-kit
bash -n scripts/release-check bash -n scripts/release-check
bash -n scripts/test-compose-upgrade.sh
bash -n scripts/test-lifecycle.sh
sh -n docker/docs/entrypoint.sh sh -n docker/docs/entrypoint.sh
check_node docker/web-search/patch-mcp-web-search.mjs docker/web-search/overrides/bing.js scripts/mcp-smoke-client.mjs scripts/smoke-web-search.mjs scripts/smoke-docs.mjs scripts/smoke-repomix.mjs check_node docker/web-search/patch-mcp-web-search.mjs docker/web-search/overrides/bing.js docker/web-search/mcp-probe.mjs docker/web-search/http-entrypoint.mjs scripts/mcp-smoke-client.mjs scripts/smoke-web-search.mjs scripts/smoke-docs.mjs scripts/smoke-repomix.mjs scripts/test-web-search-http.mjs
node -e 'const fs=require("node:fs"); JSON.parse(fs.readFileSync("snippets/opencode.json", "utf8")); JSON.parse(fs.readFileSync("snippets/claude.mcp.json", "utf8"));' node -e 'const fs=require("node:fs"); JSON.parse(fs.readFileSync("snippets/opencode.json", "utf8")); JSON.parse(fs.readFileSync("snippets/claude.mcp.json", "utf8"));'
CONTEXT_KIT_DOCS_HTTP_URL="http://127.0.0.1:8776/mcp" bin/context-kit install opencode > "${tmp_dir}/opencode-default.json" CONTEXT_KIT_WEB_SEARCH_HTTP_URL="http://127.0.0.1:8777/mcp" CONTEXT_KIT_DOCS_HTTP_URL="http://127.0.0.1:8776/mcp" bin/context-kit install opencode > "${tmp_dir}/opencode-default.json"
cmp -s snippets/opencode.json "${tmp_dir}/opencode-default.json" || { cmp -s snippets/opencode.json "${tmp_dir}/opencode-default.json" || {
printf 'snippets/opencode.json differs from bin/context-kit install opencode output\n' >&2 printf 'snippets/opencode.json differs from bin/context-kit install opencode output\n' >&2
diff -u snippets/opencode.json "${tmp_dir}/opencode-default.json" >&2 || true diff -u snippets/opencode.json "${tmp_dir}/opencode-default.json" >&2 || true
exit 1 exit 1
} }
CONTEXT_KIT_DOCS_HTTP_URL="http://127.0.0.1:8776/mcp" bin/context-kit install claude > "${tmp_dir}/claude-default.json" CONTEXT_KIT_WEB_SEARCH_HTTP_URL="http://127.0.0.1:8777/mcp" CONTEXT_KIT_DOCS_HTTP_URL="http://127.0.0.1:8776/mcp" bin/context-kit install claude > "${tmp_dir}/claude-default.json"
cmp -s snippets/claude.mcp.json "${tmp_dir}/claude-default.json" || { cmp -s snippets/claude.mcp.json "${tmp_dir}/claude-default.json" || {
printf 'snippets/claude.mcp.json differs from bin/context-kit install claude output\n' >&2 printf 'snippets/claude.mcp.json differs from bin/context-kit install claude output\n' >&2
diff -u snippets/claude.mcp.json "${tmp_dir}/claude-default.json" >&2 || true diff -u snippets/claude.mcp.json "${tmp_dir}/claude-default.json" >&2 || true
@@ -137,6 +209,9 @@ node -e 'const fs=require("node:fs"); for (const file of process.argv.slice(1))
"${tmp_dir}/claude-absolute.json" "${tmp_dir}/claude-absolute.json"
bin/context-kit redaction-check "${tmp_dir}/opencode.json" "${tmp_dir}/claude.json" bin/context-kit redaction-check "${tmp_dir}/opencode.json" "${tmp_dir}/claude.json"
assert_redaction_check_does_not_disclose_matches assert_redaction_check_does_not_disclose_matches
bash scripts/test-compose-upgrade.sh
bash scripts/test-lifecycle.sh
node scripts/test-web-search-http.mjs
bin/context-kit redaction-check bin/context-kit redaction-check
docker compose -p "${CONTEXT_KIT_COMPOSE_PROJECT}" -f compose.yml config >/dev/null docker compose -p "${CONTEXT_KIT_COMPOSE_PROJECT}" -f compose.yml config >/dev/null
@@ -147,10 +222,13 @@ fi
CONTEXT_KIT_DATA_DIR="${tmp_dir}/compose-data" env -u HOME docker compose --env-file /dev/null -p context-kit-release-home-check -f compose.yml config >/dev/null CONTEXT_KIT_DATA_DIR="${tmp_dir}/compose-data" env -u HOME docker compose --env-file /dev/null -p context-kit-release-home-check -f compose.yml config >/dev/null
bin/context-kit build bin/context-kit build
assert_web_search_image assert_web_search_image
bin/context-kit restart bin/context-kit start
bin/context-kit doctor bin/context-kit doctor
node docker/web-search/mcp-probe.mjs "http://127.0.0.1:${CONTEXT_KIT_WEB_SEARCH_PORT}/mcp"
assert_hostile_requests_rejected
node scripts/smoke-web-search.mjs bin/context-kit web-search node scripts/smoke-web-search.mjs bin/context-kit web-search
node scripts/smoke-docs.mjs bin/context-kit docs node scripts/smoke-docs.mjs bin/context-kit docs
node scripts/smoke-repomix.mjs bin/context-kit repomix node scripts/smoke-repomix.mjs bin/context-kit repomix
assert_web_search_backend_supervision
printf 'pass release-check\n' printf 'pass release-check\n'

View File

@@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
tmp_dir="$(mktemp -d)"
trap 'rm -rf "${tmp_dir}"' EXIT
git -C "${ROOT}" show origin/main:compose.yml > "${tmp_dir}/origin-compose.yml"
CONTEXT_KIT_DATA_DIR="${tmp_dir}/data" docker compose \
--project-directory "${ROOT}" \
--env-file /dev/null \
-p "context-kit-upgrade-check-$$" \
-f "${tmp_dir}/origin-compose.yml" \
config --format json > "${tmp_dir}/origin.json"
CONTEXT_KIT_DATA_DIR="${tmp_dir}/data" CONTEXT_KIT_HOST_UID="$(id -u)" docker compose \
--project-directory "${ROOT}" \
--env-file /dev/null \
-p "context-kit-upgrade-check-$$" \
-f "${ROOT}/compose.yml" \
config --format json > "${tmp_dir}/current.json"
node - "${tmp_dir}/origin.json" "${tmp_dir}/current.json" <<'NODE'
const fs = require("node:fs");
const [beforePath, afterPath] = process.argv.slice(2);
const before = JSON.parse(fs.readFileSync(beforePath, "utf8"));
const after = JSON.parse(fs.readFileSync(afterPath, "utf8"));
for (const service of ["searxng", "docs-mcp"]) {
if (JSON.stringify(before.services[service]) !== JSON.stringify(after.services[service])) {
throw new Error(`${service} changed from origin/main and could be destructively recreated`);
}
}
for (const key of ["networks", "volumes"]) {
if (JSON.stringify(before[key]) !== JSON.stringify(after[key])) {
throw new Error(`${key} changed from origin/main`);
}
}
NODE
printf 'pass origin/main Compose upgrade contract\n'

380
scripts/test-lifecycle.sh Normal file
View File

@@ -0,0 +1,380 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CONTEXT_KIT="${ROOT}/bin/context-kit"
TEST_ROOT="$(mktemp -d)"
TEST_PROJECT="context-kit-lifecycle-$$"
LOCK_DIR="/tmp/context-kit-${TEST_PROJECT}.lock"
cleanup() {
rm -rf "${TEST_ROOT}"
if [[ -d "${LOCK_DIR}" && "$(stat -c %u "${LOCK_DIR}")" == "$(id -u)" ]]; then
rm -rf "${LOCK_DIR}"
fi
}
trap cleanup EXIT
fail_test() {
printf 'lifecycle test: %s\n' "$*" >&2
exit 1
}
fake_log() {
printf '%s\n' "$*" >> "${FAKE_DOCKER_LOG}"
}
fake_service_for_container() {
local container_id="$1" file
for file in "${FAKE_DOCKER_STATE}"/service.*.id; do
[[ -f "${file}" ]] || continue
if [[ "$(<"${file}")" == "${container_id}" ]]; then
file="${file##*/service.}"
printf '%s' "${file%.id}"
return 0
fi
done
return 1
}
fake_compose() {
while [[ "$#" -gt 0 ]]; do
case "$1" in
-p|-f) shift 2 ;;
*) break ;;
esac
done
local command="${1:-}"
shift || true
case "${command}" in
ps)
local service="${!#}"
if [[ -f "${FAKE_DOCKER_STATE}/service.${service}.id" ]]; then
printf '%s\n' "$(<"${FAKE_DOCKER_STATE}/service.${service}.id")"
fi
;;
up)
local no_recreate=0 service
local services=()
for service in "$@"; do
case "${service}" in
-d) ;;
--no-recreate) no_recreate=1 ;;
*) services+=("${service}") ;;
esac
done
if [[ -n "${FAKE_REPLACEMENT_REQUIRED:-}" && "${no_recreate}" -eq 1 && -f "${FAKE_DOCKER_STATE}/service.${FAKE_REPLACEMENT_REQUIRED}.id" ]]; then
return 17
fi
if ! mkdir "${FAKE_DOCKER_STATE}/up.guard" 2>/dev/null; then
fake_log RACE
return 75
fi
/bin/sleep 0.2
touch "${FAKE_DOCKER_STATE}/network" "${FAKE_DOCKER_STATE}/volume"
for service in "${services[@]}"; do
if [[ ! -f "${FAKE_DOCKER_STATE}/service.${service}.id" ]]; then
printf 'cid-%s\n' "${service}" > "${FAKE_DOCKER_STATE}/service.${service}.id"
fi
touch "${FAKE_DOCKER_STATE}/service.${service}.running"
done
if [[ -n "${FAKE_DROP_RUNNING:-}" ]]; then
rm -f "${FAKE_DOCKER_STATE}/service.${FAKE_DROP_RUNNING}.running"
fi
rmdir "${FAKE_DOCKER_STATE}/up.guard"
;;
restart)
local service
for service in "$@"; do
if [[ -f "${FAKE_DOCKER_STATE}/service.${service}.id" ]]; then
touch "${FAKE_DOCKER_STATE}/service.${service}.running"
fi
done
if [[ -n "${FAKE_DROP_RUNNING:-}" ]]; then
rm -f "${FAKE_DOCKER_STATE}/service.${FAKE_DROP_RUNNING}.running"
fi
[[ "${FAKE_RESTART_FAIL:-0}" -eq 0 ]]
;;
stop)
local service
for service in "$@"; do
rm -f "${FAKE_DOCKER_STATE}/service.${service}.running"
done
;;
build|version) ;;
*) fail_test "unsupported fake compose command: ${command}" ;;
esac
}
docker() {
fake_log "docker $*"
local object="${1:-}"
shift || true
case "${object}" in
info|pull) ;;
image) ;;
compose) fake_compose "$@" ;;
network|volume)
local action="${1:-}"
[[ "${action}" == inspect && -f "${FAKE_DOCKER_STATE}/${object}" ]]
;;
inspect)
local container_id="${!#}" service
service="$(fake_service_for_container "${container_id}" 2>/dev/null)" || {
[[ -f "${FAKE_DOCKER_STATE}/owner.${container_id}" ]] || return 1
if [[ "$*" == *"dev.context-kit.owner"* ]]; then
printf '%s\n' "$(<"${FAKE_DOCKER_STATE}/owner.${container_id}")"
fi
return
}
if [[ "$*" == *".State.Running"* ]]; then
[[ -f "${FAKE_DOCKER_STATE}/service.${service}.running" ]] && printf 'true\n' || printf 'false\n'
elif [[ "$*" == *"com.docker.compose.project"* ]]; then
printf '%s:%s\n' "${CONTEXT_KIT_COMPOSE_PROJECT}" "${service}"
elif [[ "$*" == *".Config.User"* ]]; then
printf '%s:1000\n' "${FAKE_DOCS_UID:-$(id -u)}"
elif [[ "$*" == *"dev.context-kit.uid"* ]]; then
printf '%s\n' "${FAKE_WEB_UID:-$(id -u)}"
fi
;;
create)
local name='' owner='' argument container_id
while [[ "$#" -gt 0 ]]; do
argument="$1"
case "${argument}" in
--name) name="$2"; shift 2 ;;
--label)
[[ "$2" == dev.context-kit.owner=* ]] && owner="${2#*=}"
shift 2
;;
--network|-e|-v|--workdir|--entrypoint) shift 2 ;;
-i|--rm) shift ;;
*) shift ;;
esac
done
[[ -n "${name}" && -n "${owner}" ]] || fail_test "client container lacks a deterministic name or owner"
container_id="cid-${name}"
if [[ "${FAKE_CLIENT_OWNER_MISMATCH:-0}" -eq 1 ]]; then
printf 'unrelated-owner\n' > "${FAKE_DOCKER_STATE}/owner.${container_id}"
else
printf '%s\n' "${owner}" > "${FAKE_DOCKER_STATE}/owner.${container_id}"
fi
printf '%s\n' "${container_id}"
;;
start)
local container_id="${!#}" service
if service="$(fake_service_for_container "${container_id}" 2>/dev/null)"; then
touch "${FAKE_DOCKER_STATE}/service.${service}.running"
fi
;;
rm)
local container_id="${!#}" service
if service="$(fake_service_for_container "${container_id}" 2>/dev/null)"; then
rm -f "${FAKE_DOCKER_STATE}/service.${service}.id" "${FAKE_DOCKER_STATE}/service.${service}.running"
fi
rm -f "${FAKE_DOCKER_STATE}/owner.${container_id}"
;;
stop)
local container_id="${!#}" service
service="$(fake_service_for_container "${container_id}")" || return 1
rm -f "${FAKE_DOCKER_STATE}/service.${service}.running"
;;
ps)
if [[ "$*" == *"label=dev.context-kit=true"* && "${FAKE_LEGACY_CONTAINER:-0}" -eq 1 ]]; then
printf 'legacy-web-search\tCreated\t\t\n'
fi
;;
*) fail_test "unsupported fake docker command: ${object}" ;;
esac
}
curl() {
local argument url='' data=''
while [[ "$#" -gt 0 ]]; do
argument="$1"
case "${argument}" in
--data) data="$2"; shift 2 ;;
http://*|https://*) url="${argument}"; shift ;;
*) shift ;;
esac
done
case "${url}" in
*:8099/healthz) [[ "${FAKE_SEARXNG_FAIL:-0}" -eq 0 ]] ;;
*:8777/mcp)
[[ "${FAKE_WEB_SEARCH_FAIL:-0}" -eq 0 ]] || return 1
if [[ "${data}" == *'"method":"initialize"'* ]]; then
printf '{"jsonrpc":"2.0","id":1,"result":{"serverInfo":{"name":"web"}}}\n'
else
printf '{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"search_web"},{"name":"fetch_url"}]}}\n'
fi
;;
*:8776/status) [[ "${FAKE_DOCS_FAIL:-0}" -eq 0 ]] ;;
*) fail_test "unexpected fake curl URL: ${url}" ;;
esac
}
sleep() { return 0; }
export -f fail_test fake_log fake_service_for_container fake_compose docker curl sleep
new_case() {
local name="$1"
export CASE_ROOT="${TEST_ROOT}/${name}"
export FAKE_DOCKER_STATE="${CASE_ROOT}/docker"
export FAKE_DOCKER_LOG="${CASE_ROOT}/docker.log"
export HOME="${CASE_ROOT}/home"
export CONTEXT_KIT_DATA_DIR="${CASE_ROOT}/data"
export CONTEXT_KIT_COMPOSE_PROJECT="${TEST_PROJECT}"
export CONTEXT_KIT_SEARXNG_PORT=8099
export CONTEXT_KIT_WEB_SEARCH_PORT=8777
export CONTEXT_KIT_WEB_SEARCH_HTTP_URL=http://127.0.0.1:8777/mcp
export CONTEXT_KIT_DOCS_PORT=8776
export CONTEXT_KIT_DOCS_HTTP_URL=http://127.0.0.1:8776/mcp
export CONTEXT_KIT_DOCS_SOURCES=config/sources.default.txt
unset CONTEXT_KIT_DOCKER_CIDFILE CONTEXT_KIT_RUNTIME_DIR FAKE_DOCS_UID FAKE_WEB_UID \
FAKE_REPLACEMENT_REQUIRED FAKE_RESTART_FAIL FAKE_DROP_RUNNING FAKE_SEARXNG_FAIL \
FAKE_WEB_SEARCH_FAIL FAKE_DOCS_FAIL FAKE_LEGACY_CONTAINER FAKE_CLIENT_OWNER_MISMATCH
mkdir -p "${FAKE_DOCKER_STATE}" "${HOME}"
: > "${FAKE_DOCKER_LOG}"
}
seed_service() {
local service="$1" state="${2:-running}"
printf 'cid-%s\n' "${service}" > "${FAKE_DOCKER_STATE}/service.${service}.id"
if [[ "${state}" == running ]]; then
touch "${FAKE_DOCKER_STATE}/service.${service}.running"
fi
}
new_case snippets
"${CONTEXT_KIT}" install opencode > "${CASE_ROOT}/opencode.json"
"${CONTEXT_KIT}" install claude > "${CASE_ROOT}/claude.json"
grep -F '"url": "http://127.0.0.1:8777/mcp"' "${CASE_ROOT}/opencode.json" >/dev/null || fail_test "OpenCode web search is not remote HTTP"
grep -F '"url": "http://127.0.0.1:8777/mcp"' "${CASE_ROOT}/claude.json" >/dev/null || fail_test "Claude web search is not HTTP"
new_case stdio-bridge
touch "${FAKE_DOCKER_STATE}/network"
seed_service web-search-mcp
"${CONTEXT_KIT}" web-search </dev/null
grep -E 'docker create .*dev.context-kit.lifecycle=client .*--entrypoint mcp-proxy .*http://web-search-mcp:8000/mcp' "${FAKE_DOCKER_LOG}" >/dev/null || fail_test "stdio bridge does not reuse the shared service"
[[ -f "${FAKE_DOCKER_STATE}/service.web-search-mcp.running" ]] || fail_test "stdio bridge stopped the shared service"
if compgen -G "${FAKE_DOCKER_STATE}/owner.*" >/dev/null; then
fail_test "stdio bridge did not clean up its own container"
fi
new_case client-owner-isolation
touch "${FAKE_DOCKER_STATE}/network"
seed_service web-search-mcp
export FAKE_CLIENT_OWNER_MISMATCH=1
"${CONTEXT_KIT}" web-search </dev/null
compgen -G "${FAKE_DOCKER_STATE}/owner.*" >/dev/null \
|| fail_test "stdio bridge removed a container whose owner label did not match"
grep -F 'docker rm' "${FAKE_DOCKER_LOG}" >/dev/null \
&& fail_test "stdio bridge attempted to remove a container whose owner label did not match"
new_case differing-environments
mkdir -p "${CASE_ROOT}/runtime-a" "${CASE_ROOT}/runtime-b" "${CASE_ROOT}/tmp-a" "${CASE_ROOT}/tmp-b"
XDG_RUNTIME_DIR="${CASE_ROOT}/runtime-a" TMPDIR="${CASE_ROOT}/tmp-a" "${CONTEXT_KIT}" start >"${CASE_ROOT}/start-a.out" 2>&1 &
first_pid=$!
XDG_RUNTIME_DIR="${CASE_ROOT}/runtime-b" TMPDIR="${CASE_ROOT}/tmp-b" "${CONTEXT_KIT}" start >"${CASE_ROOT}/start-b.out" 2>&1 &
second_pid=$!
wait "${first_pid}" || fail_test "first concurrent start failed"
wait "${second_pid}" || fail_test "second concurrent start failed"
grep -F RACE "${FAKE_DOCKER_LOG}" >/dev/null && fail_test "environment-specific locks allowed a startup race"
[[ -f "${LOCK_DIR}/lifecycle" ]] || fail_test "canonical project lock was not used"
new_case unsafe-lock-mode
chmod 755 "${LOCK_DIR}"
if "${CONTEXT_KIT}" start >"${CASE_ROOT}/start.out" 2>&1; then
fail_test "unsafe lock mode unexpectedly succeeded"
fi
grep -F 'expected uid' "${CASE_ROOT}/start.out" >/dev/null || fail_test "unsafe lock rejection was not explicit"
grep -F ' up ' "${FAKE_DOCKER_LOG}" >/dev/null && fail_test "unsafe lock rejection happened after Compose startup"
chmod 700 "${LOCK_DIR}"
new_case origin-upgrade
touch "${FAKE_DOCKER_STATE}/network" "${FAKE_DOCKER_STATE}/volume"
seed_service searxng
seed_service docs-mcp
"${CONTEXT_KIT}" start
[[ "$(<"${FAKE_DOCKER_STATE}/service.searxng.id")" == cid-searxng ]] || fail_test "origin searxng was replaced"
[[ "$(<"${FAKE_DOCKER_STATE}/service.docs-mcp.id")" == cid-docs-mcp ]] || fail_test "origin docs-mcp was replaced"
[[ -f "${FAKE_DOCKER_STATE}/service.web-search-mcp.running" ]] || fail_test "upgrade did not create shared web search"
grep -F 'up -d --no-recreate searxng web-search-mcp docs-mcp' "${FAKE_DOCKER_LOG}" >/dev/null || fail_test "upgrade omitted --no-recreate"
grep -E 'docker (network|volume) rm' "${FAKE_DOCKER_LOG}" >/dev/null && fail_test "upgrade removed an origin resource"
new_case replacement-required
seed_service searxng
seed_service web-search-mcp
seed_service docs-mcp
export FAKE_REPLACEMENT_REQUIRED=docs-mcp
if "${CONTEXT_KIT}" start >"${CASE_ROOT}/start.out" 2>&1; then
fail_test "replacement-required start unexpectedly succeeded"
fi
for service in searxng web-search-mcp docs-mcp; do
[[ -f "${FAKE_DOCKER_STATE}/service.${service}.running" ]] || fail_test "replacement failure left ${service} down"
[[ "$(<"${FAKE_DOCKER_STATE}/service.${service}.id")" == "cid-${service}" ]] || fail_test "replacement failure changed ${service}"
done
new_case readiness-failure
touch "${FAKE_DOCKER_STATE}/network" "${FAKE_DOCKER_STATE}/volume"
seed_service searxng
seed_service docs-mcp stopped
export FAKE_DROP_RUNNING=searxng
export FAKE_WEB_SEARCH_FAIL=1
if "${CONTEXT_KIT}" start >"${CASE_ROOT}/start.out" 2>&1; then
fail_test "readiness failure unexpectedly succeeded"
fi
[[ -f "${FAKE_DOCKER_STATE}/service.searxng.running" ]] || fail_test "readiness rollback did not restart prior searxng"
[[ ! -f "${FAKE_DOCKER_STATE}/service.docs-mcp.running" ]] || fail_test "readiness rollback did not restore prior stopped docs state"
[[ ! -f "${FAKE_DOCKER_STATE}/service.web-search-mcp.id" ]] || fail_test "readiness rollback left its new web container"
[[ -f "${FAKE_DOCKER_STATE}/network" && -f "${FAKE_DOCKER_STATE}/volume" ]] || fail_test "readiness rollback removed origin resources"
new_case restart-sources
seed_service searxng
seed_service web-search-mcp
seed_service docs-mcp
printf 'https://example.test/llms.txt\n' > "${CASE_ROOT}/sources.txt"
export CONTEXT_KIT_DOCS_SOURCES="${CASE_ROOT}/sources.txt"
"${CONTEXT_KIT}" restart
grep -F 'https://example.test/llms.txt' "${CONTEXT_KIT_DATA_DIR}/docs-sources.txt" >/dev/null \
|| fail_test "restart did not regenerate the bind-mounted docs source list"
new_case restart-failure
seed_service searxng stopped
seed_service web-search-mcp
seed_service docs-mcp
export FAKE_DROP_RUNNING=docs-mcp
export FAKE_RESTART_FAIL=1
if "${CONTEXT_KIT}" restart >"${CASE_ROOT}/restart.out" 2>&1; then
fail_test "restart failure unexpectedly succeeded"
fi
[[ -f "${FAKE_DOCKER_STATE}/service.docs-mcp.running" ]] || fail_test "restart rollback left prior docs down"
[[ ! -f "${FAKE_DOCKER_STATE}/service.searxng.running" ]] || fail_test "restart rollback did not restore prior stopped SearXNG state"
grep -F 'docker rm' "${FAKE_DOCKER_LOG}" >/dev/null && fail_test "restart rollback removed a shared container"
new_case cross-user
seed_service docs-mcp
export FAKE_DOCS_UID="$(( $(id -u) + 1 ))"
if "${CONTEXT_KIT}" start >"${CASE_ROOT}/start.out" 2>&1; then
fail_test "cross-user ownership unexpectedly succeeded"
fi
grep -F 'cross-user ownership is unsupported' "${CASE_ROOT}/start.out" >/dev/null || fail_test "cross-user rejection was not explicit"
grep -F ' up ' "${FAKE_DOCKER_LOG}" >/dev/null && fail_test "cross-user rejection happened after Compose startup"
new_case bounded-failure
export FAKE_WEB_SEARCH_FAIL=1
if "${CONTEXT_KIT}" start >"${CASE_ROOT}/start.out" 2>&1; then
fail_test "fresh readiness failure unexpectedly succeeded"
fi
for service in searxng web-search-mcp docs-mcp; do
[[ ! -f "${FAKE_DOCKER_STATE}/service.${service}.id" ]] || fail_test "fresh failure left ${service}"
done
[[ -f "${FAKE_DOCKER_STATE}/network" && -f "${FAKE_DOCKER_STATE}/volume" ]] || fail_test "bounded shared resources were destructively removed"
new_case legacy-status
export FAKE_LEGACY_CONTAINER=1
"${CONTEXT_KIT}" status >"${CASE_ROOT}/status.out"
grep -F 'Legacy unlabeled Context Kit containers' "${CASE_ROOT}/status.out" >/dev/null || fail_test "status omitted legacy diagnostics"
grep -F 'legacy-web-search' "${CASE_ROOT}/status.out" >/dev/null || fail_test "status omitted the legacy container"
printf 'pass lifecycle and origin-upgrade tests\n'

View File

@@ -0,0 +1,113 @@
import assert from "node:assert/strict";
import http from "node:http";
import { once } from "node:events";
import {
createSecureMcpServer,
mcpProxyArguments,
superviseBackend
} from "../docker/web-search/http-entrypoint.mjs";
import { probeMcp } from "../docker/web-search/mcp-probe.mjs";
let backendAlive = true;
assert(mcpProxyArguments.includes("--stateless"));
const backend = http.createServer(async (request, response) => {
if (request.url === "/status") {
response.writeHead(200, { "Content-Type": "application/json" });
response.end('{"server_instances":{"default":"configured"}}');
return;
}
if (request.method !== "POST" || request.url !== "/mcp") {
response.writeHead(404).end();
return;
}
let body = "";
for await (const chunk of request) body += chunk;
const message = JSON.parse(body);
if (message.method === "initialize") {
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({
jsonrpc: "2.0",
id: message.id,
result: {
protocolVersion: "2024-11-05",
capabilities: { tools: {} },
serverInfo: { name: "mock-web-search", version: "1" }
}
}));
return;
}
if (message.method === "tools/list" && backendAlive) {
response.writeHead(200, { "Content-Type": "application/json" });
response.end(JSON.stringify({
jsonrpc: "2.0",
id: message.id,
result: { tools: [{ name: "search_web" }, { name: "fetch_url" }] }
}));
return;
}
response.writeHead(500, { "Content-Type": "text/plain" });
response.end("backend dead");
});
backend.listen(0, "127.0.0.1");
await once(backend, "listening");
const backendPort = backend.address().port;
const upstream = `http://127.0.0.1:${backendPort}`;
const front = createSecureMcpServer({ upstream });
front.listen(0, "127.0.0.1");
await once(front, "listening");
const frontPort = front.address().port;
function rawRequest({ host, origin }) {
return new Promise((resolve, reject) => {
const headers = {
Accept: "application/json, text/event-stream",
"Content-Type": "application/json",
Host: host
};
if (origin !== undefined) headers.Origin = origin;
const request = http.request({
hostname: "127.0.0.1",
port: frontPort,
path: "/mcp",
method: "POST",
headers
}, response => {
response.resume();
response.once("end", () => resolve(response.statusCode));
});
request.once("error", reject);
request.end('{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}');
});
}
const tools = await probeMcp(`http://127.0.0.1:${frontPort}/mcp`);
assert.deepEqual(tools, ["fetch_url", "search_web"]);
assert.equal(await rawRequest({ host: "attacker.example" }), 421);
assert.equal(await rawRequest({ host: `127.0.0.1:${frontPort}`, origin: "https://attacker.example" }), 403);
assert.equal(await rawRequest({ host: `127.0.0.1:${frontPort}`, origin: `http://127.0.0.1:${frontPort}` }), 403);
backendAlive = false;
assert.equal((await fetch(`${upstream}/status`)).status, 200);
assert.equal((await fetch(`http://127.0.0.1:${frontPort}/healthz`)).status, 503);
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error("backend supervisor did not detect death")), 1000);
const stop = superviseBackend({
probe: () => probeMcp(`${upstream}/mcp`, { timeoutMs: 100 }),
intervalMs: 10,
onFailure: () => {
clearTimeout(timeout);
stop();
resolve();
}
});
});
front.close();
front.closeAllConnections();
backend.close();
backend.closeAllConnections();
console.log("pass web-search HTTP security and supervision tests");

View File

@@ -1,8 +1,8 @@
{ {
"mcpServers": { "mcpServers": {
"context-web-search": { "context-web-search": {
"command": "context-kit", "type": "http",
"args": ["web-search"] "url": "http://127.0.0.1:8777/mcp"
}, },
"context-docs": { "context-docs": {
"type": "http", "type": "http",

View File

@@ -2,8 +2,8 @@
"$schema": "https://opencode.ai/config.json", "$schema": "https://opencode.ai/config.json",
"mcp": { "mcp": {
"context-web-search": { "context-web-search": {
"type": "local", "type": "remote",
"command": ["context-kit", "web-search"], "url": "http://127.0.0.1:8777/mcp",
"enabled": true, "enabled": true,
"timeout": 150000 "timeout": 150000
}, },