Bound shared MCP container lifecycle
This commit is contained in:
@@ -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
|
||||
|
||||
30
README.md
30
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.
|
||||
|
||||
|
||||
384
bin/context-kit
384
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 <<JSON
|
||||
{
|
||||
"\$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
"context-web-search": {
|
||||
"type": "local",
|
||||
"command": ["${bin}", "web-search"],
|
||||
"type": "remote",
|
||||
"url": "${web_search_url}",
|
||||
"enabled": true,
|
||||
"timeout": 150000
|
||||
},
|
||||
"context-docs": {
|
||||
"type": "remote",
|
||||
"url": "${url}",
|
||||
"url": "${docs_url}",
|
||||
"enabled": true,
|
||||
"timeout": 150000
|
||||
},
|
||||
@@ -514,19 +744,20 @@ JSON
|
||||
}
|
||||
|
||||
print_claude() {
|
||||
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 <<JSON
|
||||
{
|
||||
"mcpServers": {
|
||||
"context-web-search": {
|
||||
"command": "${bin}",
|
||||
"args": ["web-search"]
|
||||
"type": "http",
|
||||
"url": "${web_search_url}"
|
||||
},
|
||||
"context-docs": {
|
||||
"type": "http",
|
||||
"url": "${url}"
|
||||
"url": "${docs_url}"
|
||||
},
|
||||
"context-repomix": {
|
||||
"command": "${bin}",
|
||||
@@ -564,6 +795,7 @@ cmd_redaction_check() {
|
||||
local grep_opts=(
|
||||
-RInE
|
||||
--exclude-dir=.git
|
||||
--exclude=.git
|
||||
--exclude-dir=.cache
|
||||
--exclude-dir=tmp
|
||||
--exclude=.env
|
||||
|
||||
20
compose.yml
20
compose.yml
@@ -21,9 +21,14 @@ services:
|
||||
args:
|
||||
MCP_WEB_SEARCH_MAX_BYTES: "${CONTEXT_KIT_WEB_SEARCH_MAX_BYTES:-52428800}"
|
||||
image: ${CONTEXT_KIT_WEB_SEARCH_IMAGE:-context-kit/web-search-mcp:latest}
|
||||
profiles: ["mcp"]
|
||||
stdin_open: true
|
||||
tty: false
|
||||
# Long-lived shared web-search MCP. Clients connect directly over
|
||||
# Streamable HTTP; stdio callers use a disposable proxy bridge.
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
depends_on:
|
||||
- searxng
|
||||
ports:
|
||||
- "127.0.0.1:${CONTEXT_KIT_WEB_SEARCH_PORT:-8777}:8000"
|
||||
environment:
|
||||
DEFAULT_SEARCH_PROVIDER: "${CONTEXT_KIT_WEB_SEARCH_PROVIDER:-searxng}"
|
||||
SEARXNG_URL: "http://searxng:8080"
|
||||
@@ -33,8 +38,17 @@ services:
|
||||
MAX_RESULTS: "${CONTEXT_KIT_WEB_SEARCH_MAX_RESULTS:-10}"
|
||||
BROWSER_SEARCH_USER_AGENT: "${CONTEXT_KIT_WEB_SEARCH_BROWSER_USER_AGENT:-}"
|
||||
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:
|
||||
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:
|
||||
build:
|
||||
|
||||
@@ -2,9 +2,11 @@ FROM node:22-bookworm-slim@sha256:813a7480f28fdadac1f7f5c824bcdad435b5bc1322a596
|
||||
|
||||
ARG MCP_WEB_SEARCH_VERSION=1.3.0
|
||||
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 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
|
||||
# image family; Bing's browser path is more likely to break with stale Chromium
|
||||
@@ -14,8 +16,13 @@ RUN apt-get update \
|
||||
ca-certificates \
|
||||
chromium \
|
||||
fonts-liberation \
|
||||
python3-venv \
|
||||
&& 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}" \
|
||||
&& 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 \
|
||||
@@ -28,9 +35,12 @@ ENV CHROME_PATH=/usr/bin/chromium \
|
||||
HTTP_TIMEOUT=15000 \
|
||||
MAX_BYTES=${MCP_WEB_SEARCH_MAX_BYTES} \
|
||||
MAX_RESULTS=10 \
|
||||
PATH=/opt/mcp-proxy/bin:$PATH \
|
||||
SEARXNG_URL=http://searxng:8080 \
|
||||
XDG_CACHE_HOME=/tmp/.cache
|
||||
|
||||
USER node
|
||||
|
||||
ENTRYPOINT ["mcp-web-search"]
|
||||
EXPOSE 8000
|
||||
|
||||
ENTRYPOINT ["node", "/usr/local/lib/context-kit/http-entrypoint.mjs"]
|
||||
|
||||
183
docker/web-search/http-entrypoint.mjs
Normal file
183
docker/web-search/http-entrypoint.mjs
Normal 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();
|
||||
53
docker/web-search/mcp-probe.mjs
Normal file
53
docker/web-search/mcp-probe.mjs
Normal 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 }));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
39
scripts/test-compose-upgrade.sh
Normal file
39
scripts/test-compose-upgrade.sh
Normal 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
380
scripts/test-lifecycle.sh
Normal 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'
|
||||
113
scripts/test-web-search-http.mjs
Normal file
113
scripts/test-web-search-http.mjs
Normal 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");
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user