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
|
# Web-search defaults. Search uses SearXNG first, then falls back to
|
||||||
# DuckDuckGo and Bing. Bing requires Chromium inside the web-search image.
|
# DuckDuckGo and Bing. Bing requires Chromium inside the web-search image.
|
||||||
|
CONTEXT_KIT_WEB_SEARCH_PORT=8777
|
||||||
|
# Override only for a loopback proxy that preserves the Host/Origin policy.
|
||||||
|
# CONTEXT_KIT_WEB_SEARCH_HTTP_URL=http://127.0.0.1:8777/mcp
|
||||||
CONTEXT_KIT_WEB_SEARCH_PROVIDER=searxng
|
CONTEXT_KIT_WEB_SEARCH_PROVIDER=searxng
|
||||||
CONTEXT_KIT_WEB_SEARCH_HTTP_TIMEOUT=15000
|
CONTEXT_KIT_WEB_SEARCH_HTTP_TIMEOUT=15000
|
||||||
CONTEXT_KIT_WEB_SEARCH_MAX_RESULTS=10
|
CONTEXT_KIT_WEB_SEARCH_MAX_RESULTS=10
|
||||||
|
|||||||
30
README.md
30
README.md
@@ -56,9 +56,17 @@ config that will not be committed.
|
|||||||
## How It Runs
|
## How It Runs
|
||||||
|
|
||||||
- SearXNG binds to `127.0.0.1:8099` only.
|
- SearXNG binds to `127.0.0.1:8099` only.
|
||||||
- `context-web-search` and `context-repomix` run as local stdio MCP commands.
|
- `context-web-search` and `context-docs` are long-lived HTTP MCP services on
|
||||||
- `context-docs` runs as a local HTTP MCP service. `bin/context-kit docs` is a
|
`127.0.0.1:8777` and `127.0.0.1:8776`. All assistant projects reuse them.
|
||||||
stdio fallback for clients that cannot use HTTP MCP.
|
- `bin/context-kit web-search` and `bin/context-kit docs` are stdio bridges for
|
||||||
|
local clients that cannot use HTTP MCP directly.
|
||||||
|
- `context-repomix` remains a per-call stdio container because its read-only
|
||||||
|
project mount is caller-specific.
|
||||||
|
- Shared containers and the network have Compose-derived deterministic names.
|
||||||
|
Web search records the owning host uid; client containers are named and
|
||||||
|
labeled per launcher process and remove only themselves.
|
||||||
|
- Web search uses stateless MCP HTTP sessions, validates Host, rejects every
|
||||||
|
supplied Origin, and exits for Docker restart if its stdio backend dies.
|
||||||
- `context-docs` browser CORS is disabled by default; set exact local origins
|
- `context-docs` browser CORS is disabled by default; set exact local origins
|
||||||
only when a browser-based client needs direct access.
|
only when a browser-based client needs direct access.
|
||||||
- Docs and model caches live in `$HOME/.local/share/context-kit`.
|
- Docs and model caches live in `$HOME/.local/share/context-kit`.
|
||||||
@@ -137,13 +145,24 @@ bin/context-kit docs
|
|||||||
bin/context-kit repomix
|
bin/context-kit repomix
|
||||||
```
|
```
|
||||||
|
|
||||||
After pulling Context Kit updates, rebuild local images and restart services:
|
For this upgrade from `origin/main`, build images and safely add the missing
|
||||||
|
shared web-search service without recreating the existing SearXNG or docs
|
||||||
|
containers:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
bin/context-kit build
|
bin/context-kit build
|
||||||
bin/context-kit restart
|
bin/context-kit start
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`start` always uses Compose `--no-recreate`. `restart` restarts the same
|
||||||
|
container IDs and does not apply a rebuilt image or changed container
|
||||||
|
environment. Context Kit intentionally has no implicit destructive replacement
|
||||||
|
command.
|
||||||
|
|
||||||
|
When an update changes an MCP transport, regenerate the assistant snippet and
|
||||||
|
replace the corresponding configuration before restarting the assistant. The
|
||||||
|
current snippets connect both web search and docs directly over HTTP.
|
||||||
|
|
||||||
## Security Model
|
## Security Model
|
||||||
|
|
||||||
Context Kit is local-first, but MCP tools still extend what your agent can do.
|
Context Kit is local-first, but MCP tools still extend what your agent can do.
|
||||||
@@ -163,6 +182,7 @@ See `docs/security.md` for details.
|
|||||||
- Docker with Compose v2
|
- Docker with Compose v2
|
||||||
- Bash
|
- Bash
|
||||||
- `curl` for health checks
|
- `curl` for health checks
|
||||||
|
- `flock` from util-linux for serialized service lifecycle operations
|
||||||
|
|
||||||
No hosted API keys are required for the default stack.
|
No hosted API keys are required for the default stack.
|
||||||
|
|
||||||
|
|||||||
384
bin/context-kit
384
bin/context-kit
@@ -48,20 +48,21 @@ fi
|
|||||||
|
|
||||||
DEFAULT_DATA_DIR="${HOME:-}/.local/share/context-kit"
|
DEFAULT_DATA_DIR="${HOME:-}/.local/share/context-kit"
|
||||||
PROJECT="${CONTEXT_KIT_COMPOSE_PROJECT:-context-kit}"
|
PROJECT="${CONTEXT_KIT_COMPOSE_PROJECT:-context-kit}"
|
||||||
|
[[ "${PROJECT}" =~ ^[a-z0-9][a-z0-9_-]*$ ]] || fail "invalid CONTEXT_KIT_COMPOSE_PROJECT: ${PROJECT}"
|
||||||
|
HOST_UID="$(id -u)"
|
||||||
COMPOSE_FILE="${ROOT}/compose.yml"
|
COMPOSE_FILE="${ROOT}/compose.yml"
|
||||||
DATA_DIR="${CONTEXT_KIT_DATA_DIR:-${DEFAULT_DATA_DIR}}"
|
DATA_DIR="${CONTEXT_KIT_DATA_DIR:-${DEFAULT_DATA_DIR}}"
|
||||||
NETWORK="${PROJECT}_default"
|
NETWORK="${PROJECT}_default"
|
||||||
SEARXNG_PORT="${CONTEXT_KIT_SEARXNG_PORT:-8099}"
|
SEARXNG_PORT="${CONTEXT_KIT_SEARXNG_PORT:-8099}"
|
||||||
|
WEB_SEARCH_PORT="${CONTEXT_KIT_WEB_SEARCH_PORT:-8777}"
|
||||||
|
WEB_SEARCH_HTTP_URL="${CONTEXT_KIT_WEB_SEARCH_HTTP_URL:-http://127.0.0.1:${WEB_SEARCH_PORT}/mcp}"
|
||||||
DOCS_PORT="${CONTEXT_KIT_DOCS_PORT:-8776}"
|
DOCS_PORT="${CONTEXT_KIT_DOCS_PORT:-8776}"
|
||||||
DOCS_HTTP_URL="${CONTEXT_KIT_DOCS_HTTP_URL:-http://127.0.0.1:${DOCS_PORT}/mcp}"
|
DOCS_HTTP_URL="${CONTEXT_KIT_DOCS_HTTP_URL:-http://127.0.0.1:${DOCS_PORT}/mcp}"
|
||||||
WEB_SEARCH_MAX_BYTES="${CONTEXT_KIT_WEB_SEARCH_MAX_BYTES:-52428800}"
|
WEB_SEARCH_SERVICE_NAME="web-search-mcp"
|
||||||
WEB_SEARCH_PROVIDER="${CONTEXT_KIT_WEB_SEARCH_PROVIDER:-searxng}"
|
|
||||||
WEB_SEARCH_HTTP_TIMEOUT="${CONTEXT_KIT_WEB_SEARCH_HTTP_TIMEOUT:-15000}"
|
|
||||||
WEB_SEARCH_MAX_RESULTS="${CONTEXT_KIT_WEB_SEARCH_MAX_RESULTS:-10}"
|
|
||||||
WEB_SEARCH_CHROME_PATH="${CONTEXT_KIT_WEB_SEARCH_CHROME_PATH:-/usr/bin/chromium}"
|
|
||||||
WEB_SEARCH_BROWSER_USER_AGENT="${CONTEXT_KIT_WEB_SEARCH_BROWSER_USER_AGENT:-}"
|
|
||||||
WEB_SEARCH_MCP_COMPAT_MODE="${CONTEXT_KIT_WEB_SEARCH_MCP_COMPAT_MODE:-}"
|
|
||||||
DOCS_SERVICE_NAME="docs-mcp"
|
DOCS_SERVICE_NAME="docs-mcp"
|
||||||
|
SHARED_SERVICES=(searxng "${WEB_SEARCH_SERVICE_NAME}" "${DOCS_SERVICE_NAME}")
|
||||||
|
SNAPSHOT_IDS=()
|
||||||
|
SNAPSHOT_RUNNING=()
|
||||||
DOCS_SOURCES_FILE="${DATA_DIR}/docs-sources.txt"
|
DOCS_SOURCES_FILE="${DATA_DIR}/docs-sources.txt"
|
||||||
DOCS_DATA_DIR="${DATA_DIR}/docs"
|
DOCS_DATA_DIR="${DATA_DIR}/docs"
|
||||||
MODELS_DATA_DIR="${DATA_DIR}/models"
|
MODELS_DATA_DIR="${DATA_DIR}/models"
|
||||||
@@ -77,16 +78,16 @@ usage() {
|
|||||||
context-kit: local context tools for coding agents
|
context-kit: local context tools for coding agents
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
context-kit start Start SearXNG + the long-lived docs-mcp service
|
context-kit start Start the shared SearXNG and HTTP MCP services
|
||||||
context-kit stop Stop SearXNG + docs-mcp
|
context-kit stop Stop the shared services without removing them
|
||||||
context-kit restart Restart SearXNG + docs-mcp
|
context-kit restart Restart the shared services
|
||||||
context-kit build Build MCP images
|
context-kit build Build MCP images
|
||||||
context-kit status Show services, images, sources, and the docs HTTP endpoint
|
context-kit status Show services, images, sources, and shared HTTP endpoints
|
||||||
context-kit doctor Check Docker, services, images, sources, and HTTP endpoints
|
context-kit doctor Check Docker, services, images, sources, and HTTP endpoints
|
||||||
context-kit redaction-check Scan this repo for local paths and secret patterns
|
context-kit redaction-check Scan this repo for local paths and secret patterns
|
||||||
|
|
||||||
MCP server commands:
|
MCP server commands:
|
||||||
context-kit web-search Per-call SearXNG-backed web-search MCP (stdio)
|
context-kit web-search Stdio bridge to the shared web-search service
|
||||||
context-kit docs Stdio bridge to the long-lived docs-mcp service
|
context-kit docs Stdio bridge to the long-lived docs-mcp service
|
||||||
(clients that speak HTTP MCP should connect
|
(clients that speak HTTP MCP should connect
|
||||||
directly to the URL printed by `status`)
|
directly to the URL printed by `status`)
|
||||||
@@ -102,7 +103,10 @@ USAGE
|
|||||||
|
|
||||||
compose() {
|
compose() {
|
||||||
CONTEXT_KIT_DATA_DIR="${DATA_DIR}" \
|
CONTEXT_KIT_DATA_DIR="${DATA_DIR}" \
|
||||||
|
CONTEXT_KIT_COMPOSE_PROJECT="${PROJECT}" \
|
||||||
|
CONTEXT_KIT_HOST_UID="${HOST_UID}" \
|
||||||
CONTEXT_KIT_SEARXNG_PORT="${SEARXNG_PORT}" \
|
CONTEXT_KIT_SEARXNG_PORT="${SEARXNG_PORT}" \
|
||||||
|
CONTEXT_KIT_WEB_SEARCH_PORT="${WEB_SEARCH_PORT}" \
|
||||||
CONTEXT_KIT_DOCS_PORT="${DOCS_PORT}" \
|
CONTEXT_KIT_DOCS_PORT="${DOCS_PORT}" \
|
||||||
CONTEXT_KIT_DOCS_UID="$(id -u)" \
|
CONTEXT_KIT_DOCS_UID="$(id -u)" \
|
||||||
CONTEXT_KIT_DOCS_GID="$(id -g)" \
|
CONTEXT_KIT_DOCS_GID="$(id -g)" \
|
||||||
@@ -128,7 +132,7 @@ write_docs_sources_file() {
|
|||||||
mkdir -p "$(dirname "${DOCS_SOURCES_FILE}")"
|
mkdir -p "$(dirname "${DOCS_SOURCES_FILE}")"
|
||||||
local tmp="${DOCS_SOURCES_FILE}.tmp.$$"
|
local tmp="${DOCS_SOURCES_FILE}.tmp.$$"
|
||||||
{
|
{
|
||||||
printf '# generated by context-kit start; edit your CONTEXT_KIT_DOCS_SOURCES file(s) instead\n'
|
printf '# generated by context-kit lifecycle commands; edit your CONTEXT_KIT_DOCS_SOURCES file(s) instead\n'
|
||||||
resolved_sources
|
resolved_sources
|
||||||
} > "${tmp}"
|
} > "${tmp}"
|
||||||
mv "${tmp}" "${DOCS_SOURCES_FILE}"
|
mv "${tmp}" "${DOCS_SOURCES_FILE}"
|
||||||
@@ -204,6 +208,114 @@ require_network() {
|
|||||||
docker network inspect "${NETWORK}" >/dev/null 2>&1 || fail "missing Docker network ${NETWORK}; run: context-kit start"
|
docker network inspect "${NETWORK}" >/dev/null 2>&1 || fail "missing Docker network ${NETWORK}; run: context-kit start"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
with_lifecycle_lock() {
|
||||||
|
command -v flock >/dev/null 2>&1 || fail "flock is required for shared service lifecycle operations"
|
||||||
|
command -v stat >/dev/null 2>&1 || fail "stat is required for shared service lifecycle operations"
|
||||||
|
local lock_dir="/tmp/context-kit-${PROJECT}.lock" owner mode
|
||||||
|
[[ -d /tmp && ! -L /tmp ]] || fail "canonical lifecycle lock parent /tmp is unsafe"
|
||||||
|
if mkdir -m 700 "${lock_dir}" 2>/dev/null; then
|
||||||
|
chmod 700 "${lock_dir}"
|
||||||
|
fi
|
||||||
|
[[ -d "${lock_dir}" && ! -L "${lock_dir}" ]] || fail "unsafe lifecycle lock path: ${lock_dir}"
|
||||||
|
owner="$(stat -c %u "${lock_dir}")"
|
||||||
|
mode="$(stat -c %a "${lock_dir}")"
|
||||||
|
[[ "${owner}" == "${HOST_UID}" && "${mode}" == "700" ]] \
|
||||||
|
|| fail "shared Compose project ${PROJECT} lock has uid ${owner} and mode ${mode}; expected uid ${HOST_UID} and mode 700"
|
||||||
|
(
|
||||||
|
flock -x 9
|
||||||
|
assert_project_owner
|
||||||
|
"$@"
|
||||||
|
) 9>"${lock_dir}/lifecycle"
|
||||||
|
}
|
||||||
|
|
||||||
|
service_container_id() {
|
||||||
|
local service="$1" ids
|
||||||
|
ids="$(compose ps --all --quiet "${service}")"
|
||||||
|
[[ "${ids}" != *$'\n'* ]] || fail "multiple containers found for shared service ${service}"
|
||||||
|
printf '%s' "${ids}"
|
||||||
|
}
|
||||||
|
|
||||||
|
container_running() {
|
||||||
|
docker inspect -f '{{.State.Running}}' "$1" 2>/dev/null | grep -qx true
|
||||||
|
}
|
||||||
|
|
||||||
|
container_exists() {
|
||||||
|
docker inspect "$1" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
shared_service_running() {
|
||||||
|
local container_id
|
||||||
|
container_id="$(service_container_id "$1" 2>/dev/null || true)"
|
||||||
|
[[ -n "${container_id}" ]] && container_running "${container_id}"
|
||||||
|
}
|
||||||
|
|
||||||
|
container_matches_shared_service() {
|
||||||
|
local container_id="$1" service="$2" labels
|
||||||
|
labels="$(docker inspect -f '{{ index .Config.Labels "com.docker.compose.project" }}:{{ index .Config.Labels "com.docker.compose.service" }}' "${container_id}" 2>/dev/null || true)"
|
||||||
|
[[ "${labels}" == "${PROJECT}:${service}" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_project_owner() {
|
||||||
|
local container_id configured_uid
|
||||||
|
container_id="$(service_container_id "${DOCS_SERVICE_NAME}" 2>/dev/null || true)"
|
||||||
|
if [[ -n "${container_id}" ]]; then
|
||||||
|
configured_uid="$(docker inspect -f '{{.Config.User}}' "${container_id}" 2>/dev/null || true)"
|
||||||
|
configured_uid="${configured_uid%%:*}"
|
||||||
|
[[ "${configured_uid}" == "${HOST_UID}" ]] \
|
||||||
|
|| fail "shared Compose project ${PROJECT} docs service belongs to uid ${configured_uid:-unknown}; cross-user ownership is unsupported"
|
||||||
|
fi
|
||||||
|
|
||||||
|
container_id="$(service_container_id "${WEB_SEARCH_SERVICE_NAME}" 2>/dev/null || true)"
|
||||||
|
if [[ -n "${container_id}" ]]; then
|
||||||
|
configured_uid="$(docker inspect -f '{{ index .Config.Labels "dev.context-kit.uid" }}' "${container_id}" 2>/dev/null || true)"
|
||||||
|
[[ "${configured_uid}" == "${HOST_UID}" ]] \
|
||||||
|
|| fail "shared Compose project ${PROJECT} web-search service belongs to uid ${configured_uid:-unknown}; cross-user ownership is unsupported"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_owned_container() {
|
||||||
|
local container_id="$1" owner="$2" actual_owner
|
||||||
|
[[ -n "${container_id}" ]] || return 0
|
||||||
|
actual_owner="$(docker inspect -f '{{ index .Config.Labels "dev.context-kit.owner" }}' "${container_id}" 2>/dev/null || true)"
|
||||||
|
[[ "${actual_owner}" == "${owner}" ]] || return 0
|
||||||
|
docker rm -f "${container_id}" >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
run_owned_stdio_container() {
|
||||||
|
local role="$1"
|
||||||
|
shift
|
||||||
|
local uid owner name container_id='' status=0
|
||||||
|
uid="$(id -u)"
|
||||||
|
owner="${PROJECT}:${role}:${uid}:$$"
|
||||||
|
name="${PROJECT}-${role}-${uid}-$$"
|
||||||
|
|
||||||
|
trap 'cleanup_owned_container "${container_id}" "${owner}"' EXIT
|
||||||
|
trap 'exit 129' HUP
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
container_id="$(docker create -i --rm \
|
||||||
|
--name "${name}" \
|
||||||
|
--label dev.context-kit=true \
|
||||||
|
--label dev.context-kit.lifecycle=client \
|
||||||
|
--label "dev.context-kit.owner=${owner}" \
|
||||||
|
--label "dev.context-kit.role=${role}" \
|
||||||
|
"$@")" || status=$?
|
||||||
|
if [[ "${status}" -ne 0 ]]; then
|
||||||
|
cleanup_owned_container "${container_id}" "${owner}"
|
||||||
|
trap - EXIT HUP INT TERM
|
||||||
|
return "${status}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "${CONTEXT_KIT_DOCKER_CIDFILE:-}" ]]; then
|
||||||
|
printf '%s\n' "${container_id}" > "${CONTEXT_KIT_DOCKER_CIDFILE}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker start -ai "${container_id}" <&0 || status=$?
|
||||||
|
cleanup_owned_container "${container_id}" "${owner}"
|
||||||
|
trap - EXIT HUP INT TERM
|
||||||
|
return "${status}"
|
||||||
|
}
|
||||||
|
|
||||||
wait_for_searxng() {
|
wait_for_searxng() {
|
||||||
command -v curl >/dev/null 2>&1 || return 0
|
command -v curl >/dev/null 2>&1 || return 0
|
||||||
|
|
||||||
@@ -219,11 +331,39 @@ wait_for_searxng() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
docs_service_running() {
|
wait_for_web_search_mcp() {
|
||||||
local container_id
|
command -v curl >/dev/null 2>&1 || return 1
|
||||||
container_id="$(compose ps -q "${DOCS_SERVICE_NAME}" 2>/dev/null || true)"
|
|
||||||
[[ -n "${container_id}" ]] || return 1
|
local attempt
|
||||||
docker inspect -f '{{.State.Running}}' "${container_id}" 2>/dev/null | grep -qx true
|
for attempt in {1..60}; do
|
||||||
|
if probe_web_search_mcp; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
warn "web-search-mcp did not become ready on 127.0.0.1:${WEB_SEARCH_PORT} after 60s (check: docker compose logs ${WEB_SEARCH_SERVICE_NAME})"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
probe_web_search_mcp() {
|
||||||
|
local url="http://127.0.0.1:${WEB_SEARCH_PORT}/mcp" initialized tools
|
||||||
|
initialized="$(curl -fsS --max-time 10 \
|
||||||
|
-H 'Accept: application/json, text/event-stream' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H 'MCP-Protocol-Version: 2024-11-05' \
|
||||||
|
--data '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"context-kit-doctor","version":"1"}}}' \
|
||||||
|
"${url}" 2>/dev/null)" || return 1
|
||||||
|
printf '%s' "${initialized}" | grep -Eq '"serverInfo"[[:space:]]*:' || return 1
|
||||||
|
|
||||||
|
tools="$(curl -fsS --max-time 10 \
|
||||||
|
-H 'Accept: application/json, text/event-stream' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-H 'MCP-Protocol-Version: 2024-11-05' \
|
||||||
|
--data '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \
|
||||||
|
"${url}" 2>/dev/null)" || return 1
|
||||||
|
printf '%s' "${tools}" | grep -Eq '"name"[[:space:]]*:[[:space:]]*"search_web"' || return 1
|
||||||
|
printf '%s' "${tools}" | grep -Eq '"name"[[:space:]]*:[[:space:]]*"fetch_url"'
|
||||||
}
|
}
|
||||||
|
|
||||||
wait_for_docs_mcp() {
|
wait_for_docs_mcp() {
|
||||||
@@ -231,7 +371,7 @@ wait_for_docs_mcp() {
|
|||||||
|
|
||||||
# First run can take a while: model download plus optional eager preindexing.
|
# First run can take a while: model download plus optional eager preindexing.
|
||||||
local attempt http_ready=0
|
local attempt http_ready=0
|
||||||
for attempt in {1..180}; do
|
for ((attempt=1; attempt <= 180; attempt++)); do
|
||||||
if curl -fsS -o /dev/null "http://127.0.0.1:${DOCS_PORT}/status" 2>/dev/null; then
|
if curl -fsS -o /dev/null "http://127.0.0.1:${DOCS_PORT}/status" 2>/dev/null; then
|
||||||
http_ready=1
|
http_ready=1
|
||||||
break
|
break
|
||||||
@@ -286,36 +426,130 @@ resolved_sources() {
|
|||||||
cmd_build() {
|
cmd_build() {
|
||||||
require_no_args "usage: context-kit build" "$@"
|
require_no_args "usage: context-kit build" "$@"
|
||||||
require_docker
|
require_docker
|
||||||
# web-search-mcp is still profile-gated (built but not auto-started);
|
compose build web-search-mcp docs-mcp
|
||||||
# docs-mcp is a regular long-lived service so it builds without a profile.
|
|
||||||
compose --profile mcp build web-search-mcp
|
|
||||||
compose build docs-mcp
|
|
||||||
docker pull "${REPOMIX_IMAGE}"
|
docker pull "${REPOMIX_IMAGE}"
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd_start() {
|
snapshot_shared_services() {
|
||||||
require_no_args "usage: context-kit start" "$@"
|
local service container_id
|
||||||
require_docker
|
SNAPSHOT_IDS=()
|
||||||
|
SNAPSHOT_RUNNING=()
|
||||||
|
for service in "${SHARED_SERVICES[@]}"; do
|
||||||
|
container_id="$(service_container_id "${service}")"
|
||||||
|
SNAPSHOT_IDS+=("${container_id}")
|
||||||
|
if [[ -n "${container_id}" ]] && container_running "${container_id}"; then
|
||||||
|
SNAPSHOT_RUNNING+=(1)
|
||||||
|
else
|
||||||
|
SNAPSHOT_RUNNING+=(0)
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_shared_service_states() {
|
||||||
|
local remove_new="$1" index service current_id prior_id failed=0
|
||||||
|
for ((index=0; index < ${#SHARED_SERVICES[@]}; index++)); do
|
||||||
|
service="${SHARED_SERVICES[index]}"
|
||||||
|
prior_id="${SNAPSHOT_IDS[index]}"
|
||||||
|
current_id="$(service_container_id "${service}" 2>/dev/null || true)"
|
||||||
|
|
||||||
|
if [[ -z "${prior_id}" && "${remove_new}" == true && -n "${current_id}" ]]; then
|
||||||
|
if container_matches_shared_service "${current_id}" "${service}"; then
|
||||||
|
if ! docker rm -f "${current_id}" >/dev/null 2>&1; then
|
||||||
|
warn "failed to remove newly-created ${service} container ${current_id} during rollback"
|
||||||
|
failed=1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "new ${service} container ${current_id} lacks the expected Compose ownership; leaving it untouched"
|
||||||
|
failed=1
|
||||||
|
fi
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
[[ -n "${prior_id}" ]] || continue
|
||||||
|
|
||||||
|
if [[ -n "${current_id}" && "${current_id}" != "${prior_id}" ]]; then
|
||||||
|
warn "${service} unexpectedly changed from ${prior_id} to ${current_id}; leaving the replacement untouched"
|
||||||
|
failed=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if ! container_exists "${prior_id}"; then
|
||||||
|
warn "cannot restore missing pre-start ${service} container ${prior_id}"
|
||||||
|
failed=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
if [[ "${SNAPSHOT_RUNNING[index]}" -eq 1 ]] && ! container_running "${prior_id}"; then
|
||||||
|
if ! docker start "${prior_id}" >/dev/null 2>&1; then
|
||||||
|
warn "failed to restart prior ${service} container ${prior_id} during rollback"
|
||||||
|
failed=1
|
||||||
|
fi
|
||||||
|
elif [[ "${SNAPSHOT_RUNNING[index]}" -eq 0 ]] && container_running "${prior_id}"; then
|
||||||
|
if ! docker stop "${prior_id}" >/dev/null 2>&1; then
|
||||||
|
warn "failed to stop prior ${service} container ${prior_id} during rollback"
|
||||||
|
failed=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return "${failed}"
|
||||||
|
}
|
||||||
|
|
||||||
|
shared_services_ready() {
|
||||||
|
wait_for_searxng && wait_for_web_search_mcp && wait_for_docs_mcp
|
||||||
|
}
|
||||||
|
|
||||||
|
start_locked() {
|
||||||
prepare_data_dirs
|
prepare_data_dirs
|
||||||
if ! docker image inspect "${WEB_SEARCH_IMAGE}" >/dev/null 2>&1 || ! docker image inspect "${DOCS_IMAGE}" >/dev/null 2>&1; then
|
if ! docker image inspect "${WEB_SEARCH_IMAGE}" >/dev/null 2>&1 || ! docker image inspect "${DOCS_IMAGE}" >/dev/null 2>&1; then
|
||||||
cmd_build
|
cmd_build
|
||||||
fi
|
fi
|
||||||
write_docs_sources_file
|
write_docs_sources_file
|
||||||
compose up -d searxng docs-mcp
|
|
||||||
wait_for_searxng
|
local result=0
|
||||||
wait_for_docs_mcp
|
snapshot_shared_services
|
||||||
|
compose up -d --no-recreate "${SHARED_SERVICES[@]}" || result=$?
|
||||||
|
if [[ "${result}" -eq 0 ]] && ! shared_services_ready; then
|
||||||
|
result=1
|
||||||
|
fi
|
||||||
|
[[ "${result}" -ne 0 ]] || return 0
|
||||||
|
|
||||||
|
warn "shared service startup failed; restoring prior container states"
|
||||||
|
restore_shared_service_states true || true
|
||||||
|
return "${result}"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_start() {
|
||||||
|
require_no_args "usage: context-kit start" "$@"
|
||||||
|
require_docker
|
||||||
|
with_lifecycle_lock start_locked
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_locked() {
|
||||||
|
compose stop "${SHARED_SERVICES[@]}"
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd_stop() {
|
cmd_stop() {
|
||||||
require_no_args "usage: context-kit stop" "$@"
|
require_no_args "usage: context-kit stop" "$@"
|
||||||
require_docker
|
require_docker
|
||||||
compose stop searxng docs-mcp
|
with_lifecycle_lock stop_locked
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd_restart() {
|
cmd_restart() {
|
||||||
require_no_args "usage: context-kit restart" "$@"
|
require_no_args "usage: context-kit restart" "$@"
|
||||||
cmd_stop
|
require_docker
|
||||||
cmd_start
|
with_lifecycle_lock restart_locked
|
||||||
|
}
|
||||||
|
|
||||||
|
restart_locked() {
|
||||||
|
local result=0
|
||||||
|
write_docs_sources_file
|
||||||
|
snapshot_shared_services
|
||||||
|
compose restart "${SHARED_SERVICES[@]}" || result=$?
|
||||||
|
if [[ "${result}" -eq 0 ]] && ! shared_services_ready; then
|
||||||
|
result=1
|
||||||
|
fi
|
||||||
|
[[ "${result}" -ne 0 ]] || return 0
|
||||||
|
|
||||||
|
warn "shared service restart failed; restoring prior container states"
|
||||||
|
restore_shared_service_states false || true
|
||||||
|
return "${result}"
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd_status() {
|
cmd_status() {
|
||||||
@@ -326,10 +560,17 @@ cmd_status() {
|
|||||||
printf '\nImages\n'
|
printf '\nImages\n'
|
||||||
docker image ls --format '{{.Repository}}:{{.Tag}}\t{{.Size}}' \
|
docker image ls --format '{{.Repository}}:{{.Tag}}\t{{.Size}}' \
|
||||||
| grep -E '^(context-kit/|ghcr.io/yamadashy/repomix:)' || true
|
| grep -E '^(context-kit/|ghcr.io/yamadashy/repomix:)' || true
|
||||||
printf '\nActive per-call MCP containers\n'
|
printf '\nClient-owned stdio MCP containers\n'
|
||||||
docker ps -a --filter label=dev.context-kit=true --format '{{.Names}}\t{{.Status}}\t{{.Image}}\t{{.Command}}\t{{.Label "com.docker.compose.service"}}' \
|
docker ps -a --filter label=dev.context-kit.lifecycle=client --format 'table {{.Names}}\t{{.Status}}\t{{.Label "dev.context-kit.role"}}\t{{.Label "dev.context-kit.owner"}}'
|
||||||
| awk -F '\t' 'BEGIN { print "NAMES\tSTATUS\tIMAGE\tCOMMAND" } $5 !~ /^(searxng|docs-mcp)$/ { print $1 "\t" $2 "\t" $3 "\t" $4 }'
|
printf '\nLegacy unlabeled Context Kit containers (diagnostic only; never auto-removed)\n'
|
||||||
printf '\nDocs MCP endpoint\n- %s (service: %s)\n' "${DOCS_HTTP_URL}" "${DOCS_SERVICE_NAME}"
|
docker ps -a --filter label=dev.context-kit=true \
|
||||||
|
--format '{{.Names}}\t{{.Status}}\t{{.Label "dev.context-kit.lifecycle"}}\t{{.Label "com.docker.compose.service"}}' \
|
||||||
|
| awk -F '\t' 'BEGIN { print "NAMES\tSTATUS" } $3 == "" && $4 == "" { print $1 "\t" $2 }'
|
||||||
|
printf '\nShared MCP endpoints\n- %s (service: %s)\n- %s (service: %s)\n' \
|
||||||
|
"${WEB_SEARCH_HTTP_URL}" "${WEB_SEARCH_SERVICE_NAME}" \
|
||||||
|
"${DOCS_HTTP_URL}" "${DOCS_SERVICE_NAME}"
|
||||||
|
printf '\nShared Docker ownership\n- project: %s\n- uid: %s\n- network: %s\n- cache volume: %s_searxng-cache\n' \
|
||||||
|
"${PROJECT}" "${HOST_UID}" "${NETWORK}" "${PROJECT}"
|
||||||
printf '\nDocs sources\n'
|
printf '\nDocs sources\n'
|
||||||
resolved_sources | sed 's/^/- /'
|
resolved_sources | sed 's/^/- /'
|
||||||
printf '\nLocal docs source directory\n- %s (served inside docs-mcp at http://127.0.0.1:%s/)\n' "${DOCS_LOCAL_SOURCES_DIR}" "${DOCS_LOCAL_SOURCES_PORT}"
|
printf '\nLocal docs source directory\n- %s (served inside docs-mcp at http://127.0.0.1:%s/)\n' "${DOCS_LOCAL_SOURCES_DIR}" "${DOCS_LOCAL_SOURCES_PORT}"
|
||||||
@@ -384,6 +625,13 @@ cmd_doctor() {
|
|||||||
ok=1
|
ok=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
if command -v curl >/dev/null 2>&1 && probe_web_search_mcp; then
|
||||||
|
printf 'pass web-search-mcp initialize and tools/list on 127.0.0.1:%s\n' "${WEB_SEARCH_PORT}"
|
||||||
|
else
|
||||||
|
printf 'fail web-search-mcp MCP protocol probe failed on 127.0.0.1:%s (run context-kit start)\n' "${WEB_SEARCH_PORT}"
|
||||||
|
ok=1
|
||||||
|
fi
|
||||||
|
|
||||||
if command -v curl >/dev/null 2>&1 && curl -fsS -o /dev/null "http://127.0.0.1:${DOCS_PORT}/status" 2>/dev/null; then
|
if command -v curl >/dev/null 2>&1 && curl -fsS -o /dev/null "http://127.0.0.1:${DOCS_PORT}/status" 2>/dev/null; then
|
||||||
printf 'pass docs-mcp HTTP responds on 127.0.0.1:%s\n' "${DOCS_PORT}"
|
printf 'pass docs-mcp HTTP responds on 127.0.0.1:%s\n' "${DOCS_PORT}"
|
||||||
else
|
else
|
||||||
@@ -405,23 +653,16 @@ cmd_web_search() {
|
|||||||
require_docker
|
require_docker
|
||||||
require_network
|
require_network
|
||||||
require_image "${WEB_SEARCH_IMAGE}" "context-kit build"
|
require_image "${WEB_SEARCH_IMAGE}" "context-kit build"
|
||||||
local cidfile_args=()
|
if ! shared_service_running "${WEB_SEARCH_SERVICE_NAME}"; then
|
||||||
if [[ -n "${CONTEXT_KIT_DOCKER_CIDFILE:-}" ]]; then
|
fail "long-lived web-search-mcp not running; start it with: context-kit start"
|
||||||
cidfile_args=(--cidfile "${CONTEXT_KIT_DOCKER_CIDFILE}")
|
|
||||||
fi
|
fi
|
||||||
exec docker run --rm -i \
|
|
||||||
--label dev.context-kit=true \
|
run_owned_stdio_container web-search-bridge \
|
||||||
"${cidfile_args[@]}" \
|
|
||||||
--network "${NETWORK}" \
|
--network "${NETWORK}" \
|
||||||
-e DEFAULT_SEARCH_PROVIDER="${WEB_SEARCH_PROVIDER}" \
|
--entrypoint mcp-proxy \
|
||||||
-e SEARXNG_URL="http://searxng:8080" \
|
"${WEB_SEARCH_IMAGE}" \
|
||||||
-e CHROME_PATH="${WEB_SEARCH_CHROME_PATH}" \
|
--transport streamablehttp \
|
||||||
-e HTTP_TIMEOUT="${WEB_SEARCH_HTTP_TIMEOUT}" \
|
"http://${WEB_SEARCH_SERVICE_NAME}:8000/mcp"
|
||||||
-e MAX_BYTES="${WEB_SEARCH_MAX_BYTES}" \
|
|
||||||
-e MAX_RESULTS="${WEB_SEARCH_MAX_RESULTS}" \
|
|
||||||
-e BROWSER_SEARCH_USER_AGENT="${WEB_SEARCH_BROWSER_USER_AGENT}" \
|
|
||||||
-e MCP_COMPAT_MODE="${WEB_SEARCH_MCP_COMPAT_MODE}" \
|
|
||||||
"${WEB_SEARCH_IMAGE}"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd_docs() {
|
cmd_docs() {
|
||||||
@@ -435,18 +676,12 @@ cmd_docs() {
|
|||||||
require_network
|
require_network
|
||||||
require_image "${DOCS_IMAGE}" "context-kit build"
|
require_image "${DOCS_IMAGE}" "context-kit build"
|
||||||
|
|
||||||
if ! docs_service_running; then
|
if ! shared_service_running "${DOCS_SERVICE_NAME}"; then
|
||||||
fail "long-lived docs-mcp not running; start it with: context-kit start"
|
fail "long-lived docs-mcp not running; start it with: context-kit start"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local bridge_url="http://${DOCS_SERVICE_NAME}:8000/mcp"
|
local bridge_url="http://${DOCS_SERVICE_NAME}:8000/mcp"
|
||||||
local cidfile_args=()
|
run_owned_stdio_container docs-bridge \
|
||||||
if [[ -n "${CONTEXT_KIT_DOCKER_CIDFILE:-}" ]]; then
|
|
||||||
cidfile_args=(--cidfile "${CONTEXT_KIT_DOCKER_CIDFILE}")
|
|
||||||
fi
|
|
||||||
exec docker run --rm -i \
|
|
||||||
--label dev.context-kit=true \
|
|
||||||
"${cidfile_args[@]}" \
|
|
||||||
--network "${NETWORK}" \
|
--network "${NETWORK}" \
|
||||||
--entrypoint mcp-proxy \
|
--entrypoint mcp-proxy \
|
||||||
"${DOCS_IMAGE}" \
|
"${DOCS_IMAGE}" \
|
||||||
@@ -462,13 +697,7 @@ cmd_repomix() {
|
|||||||
dir="$(project_dir)"
|
dir="$(project_dir)"
|
||||||
mount_dir="${CONTEXT_KIT_REPOMIX_MOUNT_DIR:-${dir}}"
|
mount_dir="${CONTEXT_KIT_REPOMIX_MOUNT_DIR:-${dir}}"
|
||||||
mount_dir="$(cd "${mount_dir}" && pwd -P)"
|
mount_dir="$(cd "${mount_dir}" && pwd -P)"
|
||||||
local cidfile_args=()
|
run_owned_stdio_container repomix \
|
||||||
if [[ -n "${CONTEXT_KIT_DOCKER_CIDFILE:-}" ]]; then
|
|
||||||
cidfile_args=(--cidfile "${CONTEXT_KIT_DOCKER_CIDFILE}")
|
|
||||||
fi
|
|
||||||
exec docker run --rm -i \
|
|
||||||
--label dev.context-kit=true \
|
|
||||||
"${cidfile_args[@]}" \
|
|
||||||
-v "${mount_dir}:${mount_dir}:ro" \
|
-v "${mount_dir}:${mount_dir}:ro" \
|
||||||
--workdir "${dir}" \
|
--workdir "${dir}" \
|
||||||
"${REPOMIX_IMAGE}" --mcp
|
"${REPOMIX_IMAGE}" --mcp
|
||||||
@@ -483,22 +712,23 @@ snippet_command() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
print_opencode() {
|
print_opencode() {
|
||||||
local bin url
|
local bin docs_url web_search_url
|
||||||
bin="$(json_escape "$(snippet_command "${1:-}")")"
|
bin="$(json_escape "$(snippet_command "${1:-}")")"
|
||||||
url="$(json_escape "${DOCS_HTTP_URL}")"
|
web_search_url="$(json_escape "${WEB_SEARCH_HTTP_URL}")"
|
||||||
|
docs_url="$(json_escape "${DOCS_HTTP_URL}")"
|
||||||
cat <<JSON
|
cat <<JSON
|
||||||
{
|
{
|
||||||
"\$schema": "https://opencode.ai/config.json",
|
"\$schema": "https://opencode.ai/config.json",
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"context-web-search": {
|
"context-web-search": {
|
||||||
"type": "local",
|
"type": "remote",
|
||||||
"command": ["${bin}", "web-search"],
|
"url": "${web_search_url}",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"timeout": 150000
|
"timeout": 150000
|
||||||
},
|
},
|
||||||
"context-docs": {
|
"context-docs": {
|
||||||
"type": "remote",
|
"type": "remote",
|
||||||
"url": "${url}",
|
"url": "${docs_url}",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"timeout": 150000
|
"timeout": 150000
|
||||||
},
|
},
|
||||||
@@ -514,19 +744,20 @@ JSON
|
|||||||
}
|
}
|
||||||
|
|
||||||
print_claude() {
|
print_claude() {
|
||||||
local bin url
|
local bin docs_url web_search_url
|
||||||
bin="$(json_escape "$(snippet_command "${1:-}")")"
|
bin="$(json_escape "$(snippet_command "${1:-}")")"
|
||||||
url="$(json_escape "${DOCS_HTTP_URL}")"
|
web_search_url="$(json_escape "${WEB_SEARCH_HTTP_URL}")"
|
||||||
|
docs_url="$(json_escape "${DOCS_HTTP_URL}")"
|
||||||
cat <<JSON
|
cat <<JSON
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"context-web-search": {
|
"context-web-search": {
|
||||||
"command": "${bin}",
|
"type": "http",
|
||||||
"args": ["web-search"]
|
"url": "${web_search_url}"
|
||||||
},
|
},
|
||||||
"context-docs": {
|
"context-docs": {
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"url": "${url}"
|
"url": "${docs_url}"
|
||||||
},
|
},
|
||||||
"context-repomix": {
|
"context-repomix": {
|
||||||
"command": "${bin}",
|
"command": "${bin}",
|
||||||
@@ -564,6 +795,7 @@ cmd_redaction_check() {
|
|||||||
local grep_opts=(
|
local grep_opts=(
|
||||||
-RInE
|
-RInE
|
||||||
--exclude-dir=.git
|
--exclude-dir=.git
|
||||||
|
--exclude=.git
|
||||||
--exclude-dir=.cache
|
--exclude-dir=.cache
|
||||||
--exclude-dir=tmp
|
--exclude-dir=tmp
|
||||||
--exclude=.env
|
--exclude=.env
|
||||||
|
|||||||
20
compose.yml
20
compose.yml
@@ -21,9 +21,14 @@ services:
|
|||||||
args:
|
args:
|
||||||
MCP_WEB_SEARCH_MAX_BYTES: "${CONTEXT_KIT_WEB_SEARCH_MAX_BYTES:-52428800}"
|
MCP_WEB_SEARCH_MAX_BYTES: "${CONTEXT_KIT_WEB_SEARCH_MAX_BYTES:-52428800}"
|
||||||
image: ${CONTEXT_KIT_WEB_SEARCH_IMAGE:-context-kit/web-search-mcp:latest}
|
image: ${CONTEXT_KIT_WEB_SEARCH_IMAGE:-context-kit/web-search-mcp:latest}
|
||||||
profiles: ["mcp"]
|
# Long-lived shared web-search MCP. Clients connect directly over
|
||||||
stdin_open: true
|
# Streamable HTTP; stdio callers use a disposable proxy bridge.
|
||||||
tty: false
|
restart: unless-stopped
|
||||||
|
init: true
|
||||||
|
depends_on:
|
||||||
|
- searxng
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${CONTEXT_KIT_WEB_SEARCH_PORT:-8777}:8000"
|
||||||
environment:
|
environment:
|
||||||
DEFAULT_SEARCH_PROVIDER: "${CONTEXT_KIT_WEB_SEARCH_PROVIDER:-searxng}"
|
DEFAULT_SEARCH_PROVIDER: "${CONTEXT_KIT_WEB_SEARCH_PROVIDER:-searxng}"
|
||||||
SEARXNG_URL: "http://searxng:8080"
|
SEARXNG_URL: "http://searxng:8080"
|
||||||
@@ -33,8 +38,17 @@ services:
|
|||||||
MAX_RESULTS: "${CONTEXT_KIT_WEB_SEARCH_MAX_RESULTS:-10}"
|
MAX_RESULTS: "${CONTEXT_KIT_WEB_SEARCH_MAX_RESULTS:-10}"
|
||||||
BROWSER_SEARCH_USER_AGENT: "${CONTEXT_KIT_WEB_SEARCH_BROWSER_USER_AGENT:-}"
|
BROWSER_SEARCH_USER_AGENT: "${CONTEXT_KIT_WEB_SEARCH_BROWSER_USER_AGENT:-}"
|
||||||
MCP_COMPAT_MODE: "${CONTEXT_KIT_WEB_SEARCH_MCP_COMPAT_MODE:-}"
|
MCP_COMPAT_MODE: "${CONTEXT_KIT_WEB_SEARCH_MCP_COMPAT_MODE:-}"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "/usr/local/lib/context-kit/mcp-probe.mjs", "http://127.0.0.1:8000/mcp"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
labels:
|
labels:
|
||||||
dev.context-kit: "true"
|
dev.context-kit: "true"
|
||||||
|
dev.context-kit.lifecycle: "shared"
|
||||||
|
dev.context-kit.owner: "host"
|
||||||
|
dev.context-kit.uid: "${CONTEXT_KIT_HOST_UID:-1000}"
|
||||||
|
|
||||||
docs-mcp:
|
docs-mcp:
|
||||||
build:
|
build:
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ FROM node:22-bookworm-slim@sha256:813a7480f28fdadac1f7f5c824bcdad435b5bc1322a596
|
|||||||
|
|
||||||
ARG MCP_WEB_SEARCH_VERSION=1.3.0
|
ARG MCP_WEB_SEARCH_VERSION=1.3.0
|
||||||
ARG MCP_WEB_SEARCH_MAX_BYTES=52428800
|
ARG MCP_WEB_SEARCH_MAX_BYTES=52428800
|
||||||
|
ARG MCP_PROXY_VERSION=0.12.0
|
||||||
|
|
||||||
COPY patch-mcp-web-search.mjs /tmp/patch-mcp-web-search.mjs
|
COPY patch-mcp-web-search.mjs /tmp/patch-mcp-web-search.mjs
|
||||||
COPY overrides/bing.js /tmp/context-kit-bing-provider.js
|
COPY overrides/bing.js /tmp/context-kit-bing-provider.js
|
||||||
|
COPY --chmod=0444 mcp-probe.mjs http-entrypoint.mjs /usr/local/lib/context-kit/
|
||||||
|
|
||||||
# Chromium intentionally tracks Debian security updates inside the pinned base
|
# Chromium intentionally tracks Debian security updates inside the pinned base
|
||||||
# image family; Bing's browser path is more likely to break with stale Chromium
|
# image family; Bing's browser path is more likely to break with stale Chromium
|
||||||
@@ -14,8 +16,13 @@ RUN apt-get update \
|
|||||||
ca-certificates \
|
ca-certificates \
|
||||||
chromium \
|
chromium \
|
||||||
fonts-liberation \
|
fonts-liberation \
|
||||||
|
python3-venv \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN python3 -m venv /opt/mcp-proxy \
|
||||||
|
&& /opt/mcp-proxy/bin/pip install --no-cache-dir "mcp-proxy==${MCP_PROXY_VERSION}" \
|
||||||
|
&& /opt/mcp-proxy/bin/mcp-proxy --version
|
||||||
|
|
||||||
RUN npm install -g "@zhafron/mcp-web-search@${MCP_WEB_SEARCH_VERSION}" \
|
RUN npm install -g "@zhafron/mcp-web-search@${MCP_WEB_SEARCH_VERSION}" \
|
||||||
&& cp /tmp/context-kit-bing-provider.js /usr/local/lib/node_modules/@zhafron/mcp-web-search/dist/src/providers/bing.js \
|
&& cp /tmp/context-kit-bing-provider.js /usr/local/lib/node_modules/@zhafron/mcp-web-search/dist/src/providers/bing.js \
|
||||||
&& node /tmp/patch-mcp-web-search.mjs \
|
&& node /tmp/patch-mcp-web-search.mjs \
|
||||||
@@ -28,9 +35,12 @@ ENV CHROME_PATH=/usr/bin/chromium \
|
|||||||
HTTP_TIMEOUT=15000 \
|
HTTP_TIMEOUT=15000 \
|
||||||
MAX_BYTES=${MCP_WEB_SEARCH_MAX_BYTES} \
|
MAX_BYTES=${MCP_WEB_SEARCH_MAX_BYTES} \
|
||||||
MAX_RESULTS=10 \
|
MAX_RESULTS=10 \
|
||||||
|
PATH=/opt/mcp-proxy/bin:$PATH \
|
||||||
SEARXNG_URL=http://searxng:8080 \
|
SEARXNG_URL=http://searxng:8080 \
|
||||||
XDG_CACHE_HOME=/tmp/.cache
|
XDG_CACHE_HOME=/tmp/.cache
|
||||||
|
|
||||||
USER node
|
USER node
|
||||||
|
|
||||||
ENTRYPOINT ["mcp-web-search"]
|
EXPOSE 8000
|
||||||
|
|
||||||
|
ENTRYPOINT ["node", "/usr/local/lib/context-kit/http-entrypoint.mjs"]
|
||||||
|
|||||||
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
|
Context Kit supports assistants that can run local stdio MCP servers, HTTP MCP
|
||||||
servers, or both. The default transport split is simple:
|
servers, or both. The default transport split is simple:
|
||||||
|
|
||||||
- `context-web-search`: local stdio command.
|
- `context-web-search`: local HTTP MCP service.
|
||||||
- `context-docs`: local HTTP MCP service.
|
- `context-docs`: local HTTP MCP service.
|
||||||
- `context-repomix`: local stdio command.
|
- `context-repomix`: local stdio command.
|
||||||
|
|
||||||
`bin/context-kit docs` is a stdio fallback for clients that cannot use HTTP MCP.
|
`bin/context-kit web-search` and `bin/context-kit docs` are stdio fallbacks for
|
||||||
The included snippets cover Claude Code and OpenCode.
|
clients that cannot use HTTP MCP. They bridge to the shared services and do not
|
||||||
|
stop those services when the client exits. The included snippets cover Claude
|
||||||
|
Code and OpenCode.
|
||||||
|
|
||||||
## Claude Code
|
## Claude Code
|
||||||
|
|
||||||
|
|||||||
@@ -56,8 +56,10 @@ Only the variables below are part of the public configuration surface. Other
|
|||||||
| Variable | Default | Purpose |
|
| Variable | Default | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `CONTEXT_KIT_DATA_DIR` | `$HOME/.local/share/context-kit` | Persistent docs indexes and model cache |
|
| `CONTEXT_KIT_DATA_DIR` | `$HOME/.local/share/context-kit` | Persistent docs indexes and model cache |
|
||||||
| `CONTEXT_KIT_COMPOSE_PROJECT` | `context-kit` | Docker Compose project and network prefix |
|
| `CONTEXT_KIT_COMPOSE_PROJECT` | `context-kit` | Shared-service ownership boundary and Compose name prefix |
|
||||||
| `CONTEXT_KIT_SEARXNG_PORT` | `8099` | Localhost SearXNG port |
|
| `CONTEXT_KIT_SEARXNG_PORT` | `8099` | Localhost SearXNG port |
|
||||||
|
| `CONTEXT_KIT_WEB_SEARCH_PORT` | `8777` | Localhost port for the long-lived web-search HTTP service |
|
||||||
|
| `CONTEXT_KIT_WEB_SEARCH_HTTP_URL` | `http://127.0.0.1:${CONTEXT_KIT_WEB_SEARCH_PORT}/mcp` | URL emitted into HTTP MCP install snippets |
|
||||||
| `CONTEXT_KIT_WEB_SEARCH_MAX_BYTES` | `52428800` | Max bytes `context-web-search` accepts and downloads per fetch |
|
| `CONTEXT_KIT_WEB_SEARCH_MAX_BYTES` | `52428800` | Max bytes `context-web-search` accepts and downloads per fetch |
|
||||||
| `CONTEXT_KIT_WEB_SEARCH_PROVIDER` | `searxng` | Default `search_web` provider; fallback order depends on this provider |
|
| `CONTEXT_KIT_WEB_SEARCH_PROVIDER` | `searxng` | Default `search_web` provider; fallback order depends on this provider |
|
||||||
| `CONTEXT_KIT_WEB_SEARCH_HTTP_TIMEOUT` | `15000` | HTTP timeout in milliseconds for search providers |
|
| `CONTEXT_KIT_WEB_SEARCH_HTTP_TIMEOUT` | `15000` | HTTP timeout in milliseconds for search providers |
|
||||||
@@ -76,26 +78,63 @@ Only the variables below are part of the public configuration surface. Other
|
|||||||
| `CONTEXT_KIT_DOCS_LOCAL_SOURCES_DIR` | `${CONTEXT_KIT_DATA_DIR}/local-sources` | Machine-local llms.txt tree mounted read-only into docs-mcp |
|
| `CONTEXT_KIT_DOCS_LOCAL_SOURCES_DIR` | `${CONTEXT_KIT_DATA_DIR}/local-sources` | Machine-local llms.txt tree mounted read-only into docs-mcp |
|
||||||
| `CONTEXT_KIT_DOCS_LOCAL_SOURCES_PORT` | `8769` | Loopback port inside docs-mcp for serving local source files |
|
| `CONTEXT_KIT_DOCS_LOCAL_SOURCES_PORT` | `8769` | Loopback port inside docs-mcp for serving local source files |
|
||||||
|
|
||||||
|
## Docker Ownership
|
||||||
|
|
||||||
|
One Compose project owns the shared `searxng`, `web-search-mcp`, and `docs-mcp`
|
||||||
|
services. Compose derives stable container and network names from
|
||||||
|
`CONTEXT_KIT_COMPOSE_PROJECT`; the default network is `context-kit_default`.
|
||||||
|
Compose's existing labels remain the ownership markers for SearXNG, docs, the
|
||||||
|
network, and `searxng-cache`. Their definitions are unchanged from
|
||||||
|
`origin/main`, avoiding a resource-recreation prompt. The new web-search service
|
||||||
|
also records `dev.context-kit.uid`.
|
||||||
|
|
||||||
|
`start`, `stop`, and `restart` use one canonical
|
||||||
|
`/tmp/context-kit-PROJECT.lock` directory, which must be a non-symlink directory
|
||||||
|
owned by the current uid with mode `0700`. Existing docs and web-search
|
||||||
|
containers must have the same uid; cross-user lifecycle control is rejected.
|
||||||
|
|
||||||
|
`start` passes `--no-recreate` to Compose. On failure it removes only service
|
||||||
|
containers that did not exist before the attempt, restores prior running/stopped
|
||||||
|
states by exact container ID, and leaves the deterministic network and cache
|
||||||
|
volume intact for reuse. `restart` operates on the same container IDs and uses
|
||||||
|
the same state restoration. Neither command replaces an existing container.
|
||||||
|
`stop` stops containers without removing them or their network.
|
||||||
|
|
||||||
|
The stdio bridge commands and Repomix create uniquely named client containers
|
||||||
|
with `dev.context-kit.lifecycle=client` and an invocation-specific owner label.
|
||||||
|
Their cleanup verifies that owner label before removing the exact container ID.
|
||||||
|
|
||||||
|
Web search runs stateless MCP sessions. Its front end accepts only loopback Host
|
||||||
|
values or the internal `web-search-mcp:8000` service name and returns 403 for any
|
||||||
|
request carrying Origin. It probes initialize and tools/list periodically; a
|
||||||
|
dead stdio backend terminates the container so Docker can restart it.
|
||||||
|
|
||||||
|
`restart` restarts existing container IDs, so it reloads bind-mounted docs source
|
||||||
|
files but does not apply rebuilt images or changed container environment. There
|
||||||
|
is intentionally no automatic replacement path while the old container cannot
|
||||||
|
be restored transactionally.
|
||||||
|
|
||||||
## TTL Guidance
|
## TTL Guidance
|
||||||
|
|
||||||
`24h` is the default. Most reference docs do not need re-embedding more often,
|
`24h` is the default. Most reference docs do not need re-embedding more often,
|
||||||
and the shared service does not re-fetch sources until the TTL elapses.
|
and the shared service does not re-fetch sources until the TTL elapses.
|
||||||
|
|
||||||
Use shorter TTLs for fast-moving APIs:
|
Set a shorter TTL in `.env` for fast-moving APIs:
|
||||||
|
|
||||||
```sh
|
```dotenv
|
||||||
CONTEXT_KIT_DOCS_TTL=6h bin/context-kit restart
|
CONTEXT_KIT_DOCS_TTL=6h
|
||||||
```
|
```
|
||||||
|
|
||||||
Use longer TTLs for stable specs:
|
Set a longer TTL for stable specs:
|
||||||
|
|
||||||
```sh
|
```dotenv
|
||||||
CONTEXT_KIT_DOCS_TTL=30d bin/context-kit restart
|
CONTEXT_KIT_DOCS_TTL=30d
|
||||||
```
|
```
|
||||||
|
|
||||||
The docs-mcp container reads `CONTEXT_KIT_DOCS_TTL` at startup, so changes
|
The docs-mcp container environment is fixed when Compose creates it. A safe
|
||||||
require `bin/context-kit restart`. When freshness matters for one task, prefer
|
same-ID `restart` does not apply a changed TTL; it takes effect only when a new
|
||||||
calling the `docs_refresh` MCP tool instead of lowering the global TTL.
|
container is explicitly provisioned. When freshness matters for one task,
|
||||||
|
prefer `docs_refresh` instead of replacing the shared container.
|
||||||
|
|
||||||
## Browser CORS
|
## Browser CORS
|
||||||
|
|
||||||
@@ -103,12 +142,13 @@ calling the `docs_refresh` MCP tool instead of lowering the global TTL.
|
|||||||
HTTP clients do not need CORS. If a browser-based local client must call the MCP
|
HTTP clients do not need CORS. If a browser-based local client must call the MCP
|
||||||
endpoint directly, allow only the exact local origin(s) it uses:
|
endpoint directly, allow only the exact local origin(s) it uses:
|
||||||
|
|
||||||
```sh
|
```dotenv
|
||||||
CONTEXT_KIT_DOCS_ALLOW_ORIGIN="http://127.0.0.1:3000 http://localhost:3000" \
|
CONTEXT_KIT_DOCS_ALLOW_ORIGIN="http://127.0.0.1:3000 http://localhost:3000"
|
||||||
bin/context-kit restart
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Avoid `*`; the docs MCP is a local unauthenticated endpoint.
|
Avoid `*`; the docs MCP is a local unauthenticated endpoint. Like other
|
||||||
|
container-environment changes, this takes effect only on explicit provisioning
|
||||||
|
of a new docs container, not a same-ID `restart`.
|
||||||
|
|
||||||
## Source Profiles
|
## Source Profiles
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ Context Kit is designed to be safe by default for local development.
|
|||||||
## Defaults
|
## Defaults
|
||||||
|
|
||||||
- SearXNG is bound to `127.0.0.1` only.
|
- SearXNG is bound to `127.0.0.1` only.
|
||||||
|
- Web-search and docs MCP HTTP endpoints are bound to `127.0.0.1` only.
|
||||||
- No hosted API keys are required.
|
- No hosted API keys are required.
|
||||||
- The web-search MCP image runs as the non-root `node` user.
|
- The web-search MCP image runs as the non-root `node` user.
|
||||||
|
- Web-search MCP sessions are stateless. Its HTTP front end permits only
|
||||||
|
loopback/internal Host values and rejects every supplied Origin with 403.
|
||||||
- Repomix mounts only the current project read-only.
|
- Repomix mounts only the current project read-only.
|
||||||
- Docs indexing stores data under `$HOME/.local/share/context-kit` unless you
|
- Docs indexing stores data under `$HOME/.local/share/context-kit` unless you
|
||||||
override it.
|
override it.
|
||||||
@@ -36,10 +39,14 @@ Do not expose SearXNG or MCP servers to the public internet without a separate
|
|||||||
review. The default setup is for localhost development.
|
review. The default setup is for localhost development.
|
||||||
|
|
||||||
The containers may bind to `0.0.0.0` internally, but the Compose file publishes
|
The containers may bind to `0.0.0.0` internally, but the Compose file publishes
|
||||||
SearXNG and docs-mcp only on `127.0.0.1`. If you run the images outside the
|
SearXNG, web-search-mcp, and docs-mcp only on `127.0.0.1`. If you run the images
|
||||||
provided Compose file, review port publishing, SearXNG's limiter/secret, and MCP
|
outside the provided Compose file, review port publishing, SearXNG's
|
||||||
authentication separately.
|
limiter/secret, and MCP authentication separately.
|
||||||
|
|
||||||
Browser CORS for `context-docs` is disabled by default. Only set
|
Browser CORS for `context-docs` is disabled by default. Only set
|
||||||
`CONTEXT_KIT_DOCS_ALLOW_ORIGIN` for exact local origins that need direct browser
|
`CONTEXT_KIT_DOCS_ALLOW_ORIGIN` for exact local origins that need direct browser
|
||||||
access; avoid wildcard origins for unauthenticated local MCP endpoints.
|
access; avoid wildcard origins for unauthenticated local MCP endpoints.
|
||||||
|
|
||||||
|
`context-web-search` does not expose browser CORS configuration. Browser requests
|
||||||
|
carry Origin and are rejected; CLI/server-side MCP clients omit Origin. A local
|
||||||
|
reverse proxy must preserve this policy and present an allowed loopback Host.
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
bin/context-kit doctor
|
bin/context-kit doctor
|
||||||
```
|
```
|
||||||
|
|
||||||
This checks Docker, Compose, images, the Docker network, SearXNG health, docs
|
This checks Docker, Compose, images, the Docker network, SearXNG health, a real
|
||||||
HTTP readiness, and docs source configuration.
|
web-search MCP initialize/tools-list exchange, docs HTTP readiness, and docs
|
||||||
|
source configuration.
|
||||||
|
|
||||||
For release-grade MCP protocol checks, run:
|
For release-grade MCP protocol checks, run:
|
||||||
|
|
||||||
@@ -46,6 +47,65 @@ Build default images:
|
|||||||
bin/context-kit build
|
bin/context-kit build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Repeated Per-Project Containers
|
||||||
|
|
||||||
|
Current OpenCode and Claude snippets connect web search and docs directly to the
|
||||||
|
shared HTTP services. If every project still starts a web-search container,
|
||||||
|
regenerate the snippet, update the assistant configuration, and restart the
|
||||||
|
assistant:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bin/context-kit install opencode
|
||||||
|
bin/context-kit install claude
|
||||||
|
```
|
||||||
|
|
||||||
|
For the upgrade from `origin/main`, build and start once. `start` creates only
|
||||||
|
the missing web-search service and refuses to recreate existing services:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bin/context-kit build
|
||||||
|
bin/context-kit start
|
||||||
|
```
|
||||||
|
|
||||||
|
Context Kit never performs a global Docker prune. Inspect labeled resources and
|
||||||
|
their owners explicitly:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker ps -a --filter label=dev.context-kit=true \
|
||||||
|
--format 'table {{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Label "dev.context-kit.lifecycle"}}\t{{.Label "dev.context-kit.owner"}}\t{{.Label "com.docker.compose.service"}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Containers from the old per-call web-search launcher have an empty Compose
|
||||||
|
service and no lifecycle/owner labels. After all old assistant processes are
|
||||||
|
stopped, remove only the exact legacy container IDs you verified; do not use a
|
||||||
|
name-pattern or global prune.
|
||||||
|
|
||||||
|
Lifecycle commands for one Compose project use a canonical, uid-owned lock and
|
||||||
|
reject cross-user control. Failed startup removes only newly-created service
|
||||||
|
containers, restores existing container states by ID, and never removes the
|
||||||
|
network or cache volume. `start` uses Compose `--no-recreate`, leaving an
|
||||||
|
existing container unchanged when its image or environment differs from the
|
||||||
|
current Compose model; it never silently replaces that container. Inspect the
|
||||||
|
shared services without deleting them:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bin/context-kit status
|
||||||
|
docker compose -p "${CONTEXT_KIT_COMPOSE_PROJECT:-context-kit}" -f compose.yml logs web-search-mcp docs-mcp searxng
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the protocol-level doctor check. `/healthz` also performs initialize and
|
||||||
|
tools/list rather than trusting the proxy's static `/status` metadata:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bin/context-kit doctor
|
||||||
|
curl http://127.0.0.1:8777/healthz
|
||||||
|
```
|
||||||
|
|
||||||
|
`bin/context-kit status` lists legacy labeled containers that have neither a
|
||||||
|
Compose service nor lifecycle label. This is diagnostic only; Context Kit never
|
||||||
|
auto-removes them. Stop their old assistant owners before removing individually
|
||||||
|
verified container IDs.
|
||||||
|
|
||||||
## Fetch URL Says Max Download Bytes Is Too Big
|
## Fetch URL Says Max Download Bytes Is Too Big
|
||||||
|
|
||||||
If `fetch_url` fails before making a network request with an MCP validation error
|
If `fetch_url` fails before making a network request with an MCP validation error
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ release_id="release-$$"
|
|||||||
export CONTEXT_KIT_COMPOSE_PROJECT="context-kit-${release_id}"
|
export CONTEXT_KIT_COMPOSE_PROJECT="context-kit-${release_id}"
|
||||||
export CONTEXT_KIT_DATA_DIR="${tmp_dir}/data"
|
export CONTEXT_KIT_DATA_DIR="${tmp_dir}/data"
|
||||||
export CONTEXT_KIT_PROJECT_DIR="${ROOT}"
|
export CONTEXT_KIT_PROJECT_DIR="${ROOT}"
|
||||||
export CONTEXT_KIT_SEARXNG_PORT="$(pick_port)"
|
CONTEXT_KIT_SEARXNG_PORT="$(pick_port)"
|
||||||
export CONTEXT_KIT_DOCS_PORT="$(pick_port)"
|
CONTEXT_KIT_WEB_SEARCH_PORT="$(pick_port)"
|
||||||
|
CONTEXT_KIT_DOCS_PORT="$(pick_port)"
|
||||||
|
export CONTEXT_KIT_SEARXNG_PORT CONTEXT_KIT_WEB_SEARCH_PORT CONTEXT_KIT_DOCS_PORT
|
||||||
export CONTEXT_KIT_DOCS_LOCAL_SOURCES_DIR="${tmp_dir}/local-sources"
|
export CONTEXT_KIT_DOCS_LOCAL_SOURCES_DIR="${tmp_dir}/local-sources"
|
||||||
export CONTEXT_KIT_DOCS_LOCAL_SOURCES_PORT="8769"
|
export CONTEXT_KIT_DOCS_LOCAL_SOURCES_PORT="8769"
|
||||||
local_sources_profile="${tmp_dir}/sources.local.txt"
|
local_sources_profile="${tmp_dir}/sources.local.txt"
|
||||||
@@ -95,6 +97,69 @@ if (!bing.includes("decodeBingRedirect")) process.exit(1);
|
|||||||
docker run --rm --entrypoint /usr/bin/test \
|
docker run --rm --entrypoint /usr/bin/test \
|
||||||
"${CONTEXT_KIT_WEB_SEARCH_IMAGE}" \
|
"${CONTEXT_KIT_WEB_SEARCH_IMAGE}" \
|
||||||
-x "${CONTEXT_KIT_WEB_SEARCH_CHROME_PATH:-/usr/bin/chromium}"
|
-x "${CONTEXT_KIT_WEB_SEARCH_CHROME_PATH:-/usr/bin/chromium}"
|
||||||
|
|
||||||
|
docker run --rm --entrypoint /usr/bin/test \
|
||||||
|
"${CONTEXT_KIT_WEB_SEARCH_IMAGE}" \
|
||||||
|
-x /opt/mcp-proxy/bin/mcp-proxy
|
||||||
|
|
||||||
|
docker run --rm --entrypoint /usr/bin/test \
|
||||||
|
"${CONTEXT_KIT_WEB_SEARCH_IMAGE}" \
|
||||||
|
-r /usr/local/lib/context-kit/http-entrypoint.mjs
|
||||||
|
|
||||||
|
docker run --rm --entrypoint /usr/bin/test \
|
||||||
|
"${CONTEXT_KIT_WEB_SEARCH_IMAGE}" \
|
||||||
|
-r /usr/local/lib/context-kit/mcp-probe.mjs
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_hostile_requests_rejected() {
|
||||||
|
local status
|
||||||
|
status="$(curl -sS -o /dev/null -w '%{http_code}' \
|
||||||
|
-H 'Host: attacker.example' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
--data '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' \
|
||||||
|
"http://127.0.0.1:${CONTEXT_KIT_WEB_SEARCH_PORT}/mcp")"
|
||||||
|
[[ "${status}" == 421 ]] || {
|
||||||
|
printf 'hostile Host returned HTTP %s instead of 421\n' "${status}" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
status="$(curl -sS -o /dev/null -w '%{http_code}' \
|
||||||
|
-H 'Origin: https://attacker.example' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
--data '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' \
|
||||||
|
"http://127.0.0.1:${CONTEXT_KIT_WEB_SEARCH_PORT}/mcp")"
|
||||||
|
[[ "${status}" == 403 ]] || {
|
||||||
|
printf 'hostile Origin returned HTTP %s instead of 403\n' "${status}" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_web_search_backend_supervision() {
|
||||||
|
local container_id before after attempt
|
||||||
|
container_id="$(docker compose -p "${CONTEXT_KIT_COMPOSE_PROJECT}" -f compose.yml ps -q web-search-mcp)"
|
||||||
|
before="$(docker inspect -f '{{.RestartCount}}' "${container_id}")"
|
||||||
|
docker exec "${container_id}" sh -eu -c '
|
||||||
|
for command_path in /proc/[0-9]*/cmdline; do
|
||||||
|
command="$(tr "\000" " " < "${command_path}")"
|
||||||
|
case "${command}" in
|
||||||
|
*node*mcp-web-search*)
|
||||||
|
pid="${command_path#/proc/}"
|
||||||
|
pid="${pid%/cmdline}"
|
||||||
|
kill -KILL "${pid}"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
exit 1
|
||||||
|
'
|
||||||
|
for ((attempt=1; attempt <= 60; attempt++)); do
|
||||||
|
after="$(docker inspect -f '{{.RestartCount}}' "${container_id}")"
|
||||||
|
if [[ "${after}" -gt "${before}" ]] && node docker/web-search/mcp-probe.mjs "http://127.0.0.1:${CONTEXT_KIT_WEB_SEARCH_PORT}/mcp" >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
printf 'web-search container did not restart after backend death\n' >&2
|
||||||
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
git diff --check HEAD
|
git diff --check HEAD
|
||||||
@@ -107,20 +172,27 @@ git ls-files --cached --error-unmatch \
|
|||||||
scripts/smoke-web-search.mjs \
|
scripts/smoke-web-search.mjs \
|
||||||
scripts/smoke-docs.mjs \
|
scripts/smoke-docs.mjs \
|
||||||
scripts/smoke-repomix.mjs \
|
scripts/smoke-repomix.mjs \
|
||||||
|
scripts/test-compose-upgrade.sh \
|
||||||
|
scripts/test-lifecycle.sh \
|
||||||
|
scripts/test-web-search-http.mjs \
|
||||||
|
docker/web-search/mcp-probe.mjs \
|
||||||
|
docker/web-search/http-entrypoint.mjs \
|
||||||
scripts/release-check >/dev/null
|
scripts/release-check >/dev/null
|
||||||
bash -n bin/context-kit
|
bash -n bin/context-kit
|
||||||
bash -n scripts/release-check
|
bash -n scripts/release-check
|
||||||
|
bash -n scripts/test-compose-upgrade.sh
|
||||||
|
bash -n scripts/test-lifecycle.sh
|
||||||
sh -n docker/docs/entrypoint.sh
|
sh -n docker/docs/entrypoint.sh
|
||||||
check_node docker/web-search/patch-mcp-web-search.mjs docker/web-search/overrides/bing.js scripts/mcp-smoke-client.mjs scripts/smoke-web-search.mjs scripts/smoke-docs.mjs scripts/smoke-repomix.mjs
|
check_node docker/web-search/patch-mcp-web-search.mjs docker/web-search/overrides/bing.js docker/web-search/mcp-probe.mjs docker/web-search/http-entrypoint.mjs scripts/mcp-smoke-client.mjs scripts/smoke-web-search.mjs scripts/smoke-docs.mjs scripts/smoke-repomix.mjs scripts/test-web-search-http.mjs
|
||||||
|
|
||||||
node -e 'const fs=require("node:fs"); JSON.parse(fs.readFileSync("snippets/opencode.json", "utf8")); JSON.parse(fs.readFileSync("snippets/claude.mcp.json", "utf8"));'
|
node -e 'const fs=require("node:fs"); JSON.parse(fs.readFileSync("snippets/opencode.json", "utf8")); JSON.parse(fs.readFileSync("snippets/claude.mcp.json", "utf8"));'
|
||||||
CONTEXT_KIT_DOCS_HTTP_URL="http://127.0.0.1:8776/mcp" bin/context-kit install opencode > "${tmp_dir}/opencode-default.json"
|
CONTEXT_KIT_WEB_SEARCH_HTTP_URL="http://127.0.0.1:8777/mcp" CONTEXT_KIT_DOCS_HTTP_URL="http://127.0.0.1:8776/mcp" bin/context-kit install opencode > "${tmp_dir}/opencode-default.json"
|
||||||
cmp -s snippets/opencode.json "${tmp_dir}/opencode-default.json" || {
|
cmp -s snippets/opencode.json "${tmp_dir}/opencode-default.json" || {
|
||||||
printf 'snippets/opencode.json differs from bin/context-kit install opencode output\n' >&2
|
printf 'snippets/opencode.json differs from bin/context-kit install opencode output\n' >&2
|
||||||
diff -u snippets/opencode.json "${tmp_dir}/opencode-default.json" >&2 || true
|
diff -u snippets/opencode.json "${tmp_dir}/opencode-default.json" >&2 || true
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
CONTEXT_KIT_DOCS_HTTP_URL="http://127.0.0.1:8776/mcp" bin/context-kit install claude > "${tmp_dir}/claude-default.json"
|
CONTEXT_KIT_WEB_SEARCH_HTTP_URL="http://127.0.0.1:8777/mcp" CONTEXT_KIT_DOCS_HTTP_URL="http://127.0.0.1:8776/mcp" bin/context-kit install claude > "${tmp_dir}/claude-default.json"
|
||||||
cmp -s snippets/claude.mcp.json "${tmp_dir}/claude-default.json" || {
|
cmp -s snippets/claude.mcp.json "${tmp_dir}/claude-default.json" || {
|
||||||
printf 'snippets/claude.mcp.json differs from bin/context-kit install claude output\n' >&2
|
printf 'snippets/claude.mcp.json differs from bin/context-kit install claude output\n' >&2
|
||||||
diff -u snippets/claude.mcp.json "${tmp_dir}/claude-default.json" >&2 || true
|
diff -u snippets/claude.mcp.json "${tmp_dir}/claude-default.json" >&2 || true
|
||||||
@@ -137,6 +209,9 @@ node -e 'const fs=require("node:fs"); for (const file of process.argv.slice(1))
|
|||||||
"${tmp_dir}/claude-absolute.json"
|
"${tmp_dir}/claude-absolute.json"
|
||||||
bin/context-kit redaction-check "${tmp_dir}/opencode.json" "${tmp_dir}/claude.json"
|
bin/context-kit redaction-check "${tmp_dir}/opencode.json" "${tmp_dir}/claude.json"
|
||||||
assert_redaction_check_does_not_disclose_matches
|
assert_redaction_check_does_not_disclose_matches
|
||||||
|
bash scripts/test-compose-upgrade.sh
|
||||||
|
bash scripts/test-lifecycle.sh
|
||||||
|
node scripts/test-web-search-http.mjs
|
||||||
|
|
||||||
bin/context-kit redaction-check
|
bin/context-kit redaction-check
|
||||||
docker compose -p "${CONTEXT_KIT_COMPOSE_PROJECT}" -f compose.yml config >/dev/null
|
docker compose -p "${CONTEXT_KIT_COMPOSE_PROJECT}" -f compose.yml config >/dev/null
|
||||||
@@ -147,10 +222,13 @@ fi
|
|||||||
CONTEXT_KIT_DATA_DIR="${tmp_dir}/compose-data" env -u HOME docker compose --env-file /dev/null -p context-kit-release-home-check -f compose.yml config >/dev/null
|
CONTEXT_KIT_DATA_DIR="${tmp_dir}/compose-data" env -u HOME docker compose --env-file /dev/null -p context-kit-release-home-check -f compose.yml config >/dev/null
|
||||||
bin/context-kit build
|
bin/context-kit build
|
||||||
assert_web_search_image
|
assert_web_search_image
|
||||||
bin/context-kit restart
|
bin/context-kit start
|
||||||
bin/context-kit doctor
|
bin/context-kit doctor
|
||||||
|
node docker/web-search/mcp-probe.mjs "http://127.0.0.1:${CONTEXT_KIT_WEB_SEARCH_PORT}/mcp"
|
||||||
|
assert_hostile_requests_rejected
|
||||||
node scripts/smoke-web-search.mjs bin/context-kit web-search
|
node scripts/smoke-web-search.mjs bin/context-kit web-search
|
||||||
node scripts/smoke-docs.mjs bin/context-kit docs
|
node scripts/smoke-docs.mjs bin/context-kit docs
|
||||||
node scripts/smoke-repomix.mjs bin/context-kit repomix
|
node scripts/smoke-repomix.mjs bin/context-kit repomix
|
||||||
|
assert_web_search_backend_supervision
|
||||||
|
|
||||||
printf 'pass release-check\n'
|
printf 'pass release-check\n'
|
||||||
|
|||||||
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": {
|
"mcpServers": {
|
||||||
"context-web-search": {
|
"context-web-search": {
|
||||||
"command": "context-kit",
|
"type": "http",
|
||||||
"args": ["web-search"]
|
"url": "http://127.0.0.1:8777/mcp"
|
||||||
},
|
},
|
||||||
"context-docs": {
|
"context-docs": {
|
||||||
"type": "http",
|
"type": "http",
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"mcp": {
|
"mcp": {
|
||||||
"context-web-search": {
|
"context-web-search": {
|
||||||
"type": "local",
|
"type": "remote",
|
||||||
"command": ["context-kit", "web-search"],
|
"url": "http://127.0.0.1:8777/mcp",
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"timeout": 150000
|
"timeout": 150000
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user