diff --git a/.env.example b/.env.example index 31cfbf5..3612281 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,9 @@ CONTEXT_KIT_WEB_SEARCH_MAX_BYTES=52428800 # Web-search defaults. Search uses SearXNG first, then falls back to # 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_HTTP_TIMEOUT=15000 CONTEXT_KIT_WEB_SEARCH_MAX_RESULTS=10 diff --git a/README.md b/README.md index 5cdc6c3..9088e49 100644 --- a/README.md +++ b/README.md @@ -56,9 +56,17 @@ config that will not be committed. ## How It Runs - SearXNG binds to `127.0.0.1:8099` only. -- `context-web-search` and `context-repomix` run as local stdio MCP commands. -- `context-docs` runs as a local HTTP MCP service. `bin/context-kit docs` is a - stdio fallback for clients that cannot use HTTP MCP. +- `context-web-search` and `context-docs` are long-lived HTTP MCP services on + `127.0.0.1:8777` and `127.0.0.1:8776`. All assistant projects reuse them. +- `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 only when a browser-based client needs direct access. - Docs and model caches live in `$HOME/.local/share/context-kit`. @@ -137,13 +145,24 @@ bin/context-kit docs 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 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 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 - Bash - `curl` for health checks +- `flock` from util-linux for serialized service lifecycle operations No hosted API keys are required for the default stack. diff --git a/bin/context-kit b/bin/context-kit index d5b74c3..185c92e 100755 --- a/bin/context-kit +++ b/bin/context-kit @@ -48,20 +48,21 @@ fi DEFAULT_DATA_DIR="${HOME:-}/.local/share/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" DATA_DIR="${CONTEXT_KIT_DATA_DIR:-${DEFAULT_DATA_DIR}}" NETWORK="${PROJECT}_default" 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_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_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:-}" +WEB_SEARCH_SERVICE_NAME="web-search-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_DATA_DIR="${DATA_DIR}/docs" MODELS_DATA_DIR="${DATA_DIR}/models" @@ -77,16 +78,16 @@ usage() { context-kit: local context tools for coding agents Usage: - context-kit start Start SearXNG + the long-lived docs-mcp service - context-kit stop Stop SearXNG + docs-mcp - context-kit restart Restart SearXNG + docs-mcp + context-kit start Start the shared SearXNG and HTTP MCP services + context-kit stop Stop the shared services without removing them + context-kit restart Restart the shared services 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 redaction-check Scan this repo for local paths and secret patterns 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 (clients that speak HTTP MCP should connect directly to the URL printed by `status`) @@ -102,7 +103,10 @@ USAGE compose() { 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_WEB_SEARCH_PORT="${WEB_SEARCH_PORT}" \ CONTEXT_KIT_DOCS_PORT="${DOCS_PORT}" \ CONTEXT_KIT_DOCS_UID="$(id -u)" \ CONTEXT_KIT_DOCS_GID="$(id -g)" \ @@ -128,7 +132,7 @@ write_docs_sources_file() { mkdir -p "$(dirname "${DOCS_SOURCES_FILE}")" 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 } > "${tmp}" 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" } +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() { command -v curl >/dev/null 2>&1 || return 0 @@ -219,11 +331,39 @@ wait_for_searxng() { return 1 } -docs_service_running() { - local container_id - container_id="$(compose ps -q "${DOCS_SERVICE_NAME}" 2>/dev/null || true)" - [[ -n "${container_id}" ]] || return 1 - docker inspect -f '{{.State.Running}}' "${container_id}" 2>/dev/null | grep -qx true +wait_for_web_search_mcp() { + command -v curl >/dev/null 2>&1 || return 1 + + local attempt + 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() { @@ -231,7 +371,7 @@ wait_for_docs_mcp() { # First run can take a while: model download plus optional eager preindexing. 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 http_ready=1 break @@ -286,36 +426,130 @@ resolved_sources() { cmd_build() { require_no_args "usage: context-kit build" "$@" require_docker - # web-search-mcp is still profile-gated (built but not auto-started); - # 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 + compose build web-search-mcp docs-mcp docker pull "${REPOMIX_IMAGE}" } -cmd_start() { - require_no_args "usage: context-kit start" "$@" - require_docker +snapshot_shared_services() { + local service container_id + 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 if ! docker image inspect "${WEB_SEARCH_IMAGE}" >/dev/null 2>&1 || ! docker image inspect "${DOCS_IMAGE}" >/dev/null 2>&1; then cmd_build fi write_docs_sources_file - compose up -d searxng docs-mcp - wait_for_searxng - wait_for_docs_mcp + + local result=0 + 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() { require_no_args "usage: context-kit stop" "$@" require_docker - compose stop searxng docs-mcp + with_lifecycle_lock stop_locked } cmd_restart() { require_no_args "usage: context-kit restart" "$@" - cmd_stop - cmd_start + require_docker + 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() { @@ -326,10 +560,17 @@ cmd_status() { printf '\nImages\n' docker image ls --format '{{.Repository}}:{{.Tag}}\t{{.Size}}' \ | grep -E '^(context-kit/|ghcr.io/yamadashy/repomix:)' || true - printf '\nActive per-call 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"}}' \ - | awk -F '\t' 'BEGIN { print "NAMES\tSTATUS\tIMAGE\tCOMMAND" } $5 !~ /^(searxng|docs-mcp)$/ { print $1 "\t" $2 "\t" $3 "\t" $4 }' - printf '\nDocs MCP endpoint\n- %s (service: %s)\n' "${DOCS_HTTP_URL}" "${DOCS_SERVICE_NAME}" + printf '\nClient-owned stdio MCP containers\n' + 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"}}' + printf '\nLegacy unlabeled Context Kit containers (diagnostic only; never auto-removed)\n' + 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' 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}" @@ -384,6 +625,13 @@ cmd_doctor() { ok=1 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 printf 'pass docs-mcp HTTP responds on 127.0.0.1:%s\n' "${DOCS_PORT}" else @@ -405,23 +653,16 @@ cmd_web_search() { require_docker require_network require_image "${WEB_SEARCH_IMAGE}" "context-kit build" - local cidfile_args=() - if [[ -n "${CONTEXT_KIT_DOCKER_CIDFILE:-}" ]]; then - cidfile_args=(--cidfile "${CONTEXT_KIT_DOCKER_CIDFILE}") + if ! shared_service_running "${WEB_SEARCH_SERVICE_NAME}"; then + fail "long-lived web-search-mcp not running; start it with: context-kit start" fi - exec docker run --rm -i \ - --label dev.context-kit=true \ - "${cidfile_args[@]}" \ + + run_owned_stdio_container web-search-bridge \ --network "${NETWORK}" \ - -e DEFAULT_SEARCH_PROVIDER="${WEB_SEARCH_PROVIDER}" \ - -e SEARXNG_URL="http://searxng:8080" \ - -e CHROME_PATH="${WEB_SEARCH_CHROME_PATH}" \ - -e HTTP_TIMEOUT="${WEB_SEARCH_HTTP_TIMEOUT}" \ - -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}" + --entrypoint mcp-proxy \ + "${WEB_SEARCH_IMAGE}" \ + --transport streamablehttp \ + "http://${WEB_SEARCH_SERVICE_NAME}:8000/mcp" } cmd_docs() { @@ -435,18 +676,12 @@ cmd_docs() { require_network 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" fi local bridge_url="http://${DOCS_SERVICE_NAME}:8000/mcp" - local cidfile_args=() - 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[@]}" \ + run_owned_stdio_container docs-bridge \ --network "${NETWORK}" \ --entrypoint mcp-proxy \ "${DOCS_IMAGE}" \ @@ -462,13 +697,7 @@ cmd_repomix() { dir="$(project_dir)" mount_dir="${CONTEXT_KIT_REPOMIX_MOUNT_DIR:-${dir}}" mount_dir="$(cd "${mount_dir}" && pwd -P)" - local cidfile_args=() - 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[@]}" \ + run_owned_stdio_container repomix \ -v "${mount_dir}:${mount_dir}:ro" \ --workdir "${dir}" \ "${REPOMIX_IMAGE}" --mcp @@ -483,22 +712,23 @@ snippet_command() { } print_opencode() { - local bin url + local bin docs_url web_search_url 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 < { + 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(); diff --git a/docker/web-search/mcp-probe.mjs b/docker/web-search/mcp-probe.mjs new file mode 100644 index 0000000..86dcb15 --- /dev/null +++ b/docker/web-search/mcp-probe.mjs @@ -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 "); + const tools = await probeMcp(url); + console.log(JSON.stringify({ tools })); +} diff --git a/docs/assistants.md b/docs/assistants.md index af4bfa1..06eae14 100644 --- a/docs/assistants.md +++ b/docs/assistants.md @@ -3,12 +3,14 @@ Context Kit supports assistants that can run local stdio MCP servers, HTTP MCP 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-repomix`: local stdio command. -`bin/context-kit docs` is a stdio fallback for clients that cannot use HTTP MCP. -The included snippets cover Claude Code and OpenCode. +`bin/context-kit web-search` and `bin/context-kit docs` are stdio fallbacks for +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 diff --git a/docs/configuration.md b/docs/configuration.md index 22c7eab..8002085 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -56,8 +56,10 @@ Only the variables below are part of the public configuration surface. Other | Variable | Default | Purpose | |---|---|---| | `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_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_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 | @@ -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_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 `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. -Use shorter TTLs for fast-moving APIs: +Set a shorter TTL in `.env` for fast-moving APIs: -```sh -CONTEXT_KIT_DOCS_TTL=6h bin/context-kit restart +```dotenv +CONTEXT_KIT_DOCS_TTL=6h ``` -Use longer TTLs for stable specs: +Set a longer TTL for stable specs: -```sh -CONTEXT_KIT_DOCS_TTL=30d bin/context-kit restart +```dotenv +CONTEXT_KIT_DOCS_TTL=30d ``` -The docs-mcp container reads `CONTEXT_KIT_DOCS_TTL` at startup, so changes -require `bin/context-kit restart`. When freshness matters for one task, prefer -calling the `docs_refresh` MCP tool instead of lowering the global TTL. +The docs-mcp container environment is fixed when Compose creates it. A safe +same-ID `restart` does not apply a changed TTL; it takes effect only when a new +container is explicitly provisioned. When freshness matters for one task, +prefer `docs_refresh` instead of replacing the shared container. ## 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 endpoint directly, allow only the exact local origin(s) it uses: -```sh -CONTEXT_KIT_DOCS_ALLOW_ORIGIN="http://127.0.0.1:3000 http://localhost:3000" \ - bin/context-kit restart +```dotenv +CONTEXT_KIT_DOCS_ALLOW_ORIGIN="http://127.0.0.1:3000 http://localhost:3000" ``` -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 diff --git a/docs/security.md b/docs/security.md index b99efb6..273a673 100644 --- a/docs/security.md +++ b/docs/security.md @@ -5,8 +5,11 @@ Context Kit is designed to be safe by default for local development. ## Defaults - 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. - 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. - Docs indexing stores data under `$HOME/.local/share/context-kit` unless you 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. 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 -provided Compose file, review port publishing, SearXNG's limiter/secret, and MCP -authentication separately. +SearXNG, web-search-mcp, and docs-mcp only on `127.0.0.1`. If you run the images +outside the provided Compose file, review port publishing, SearXNG's +limiter/secret, and MCP authentication separately. Browser CORS for `context-docs` is disabled by default. Only set `CONTEXT_KIT_DOCS_ALLOW_ORIGIN` for exact local origins that need direct browser 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. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 5930556..73c520d 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -6,8 +6,9 @@ bin/context-kit doctor ``` -This checks Docker, Compose, images, the Docker network, SearXNG health, docs -HTTP readiness, and docs source configuration. +This checks Docker, Compose, images, the Docker network, SearXNG health, a real +web-search MCP initialize/tools-list exchange, docs HTTP readiness, and docs +source configuration. For release-grade MCP protocol checks, run: @@ -46,6 +47,65 @@ Build default images: 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 If `fetch_url` fails before making a network request with an MCP validation error diff --git a/scripts/release-check b/scripts/release-check index 2aaf9f1..dc1b6be 100755 --- a/scripts/release-check +++ b/scripts/release-check @@ -19,8 +19,10 @@ release_id="release-$$" export CONTEXT_KIT_COMPOSE_PROJECT="context-kit-${release_id}" export CONTEXT_KIT_DATA_DIR="${tmp_dir}/data" export CONTEXT_KIT_PROJECT_DIR="${ROOT}" -export CONTEXT_KIT_SEARXNG_PORT="$(pick_port)" -export CONTEXT_KIT_DOCS_PORT="$(pick_port)" +CONTEXT_KIT_SEARXNG_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_PORT="8769" 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 \ "${CONTEXT_KIT_WEB_SEARCH_IMAGE}" \ -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 @@ -107,20 +172,27 @@ git ls-files --cached --error-unmatch \ scripts/smoke-web-search.mjs \ scripts/smoke-docs.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 bash -n bin/context-kit bash -n scripts/release-check +bash -n scripts/test-compose-upgrade.sh +bash -n scripts/test-lifecycle.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"));' -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" || { 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 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" || { 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 @@ -137,6 +209,9 @@ node -e 'const fs=require("node:fs"); for (const file of process.argv.slice(1)) "${tmp_dir}/claude-absolute.json" bin/context-kit redaction-check "${tmp_dir}/opencode.json" "${tmp_dir}/claude.json" 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 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 bin/context-kit build assert_web_search_image -bin/context-kit restart +bin/context-kit start 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-docs.mjs bin/context-kit docs node scripts/smoke-repomix.mjs bin/context-kit repomix +assert_web_search_backend_supervision printf 'pass release-check\n' diff --git a/scripts/test-compose-upgrade.sh b/scripts/test-compose-upgrade.sh new file mode 100644 index 0000000..0f39e59 --- /dev/null +++ b/scripts/test-compose-upgrade.sh @@ -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' diff --git a/scripts/test-lifecycle.sh b/scripts/test-lifecycle.sh new file mode 100644 index 0000000..69a75ce --- /dev/null +++ b/scripts/test-lifecycle.sh @@ -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 || 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 \ + || 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' diff --git a/scripts/test-web-search-http.mjs b/scripts/test-web-search-http.mjs new file mode 100644 index 0000000..aa73c24 --- /dev/null +++ b/scripts/test-web-search-http.mjs @@ -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"); diff --git a/snippets/claude.mcp.json b/snippets/claude.mcp.json index 34241d0..2dd1e63 100644 --- a/snippets/claude.mcp.json +++ b/snippets/claude.mcp.json @@ -1,8 +1,8 @@ { "mcpServers": { "context-web-search": { - "command": "context-kit", - "args": ["web-search"] + "type": "http", + "url": "http://127.0.0.1:8777/mcp" }, "context-docs": { "type": "http", diff --git a/snippets/opencode.json b/snippets/opencode.json index 1646f34..c450bb2 100644 --- a/snippets/opencode.json +++ b/snippets/opencode.json @@ -2,8 +2,8 @@ "$schema": "https://opencode.ai/config.json", "mcp": { "context-web-search": { - "type": "local", - "command": ["context-kit", "web-search"], + "type": "remote", + "url": "http://127.0.0.1:8777/mcp", "enabled": true, "timeout": 150000 },