From a91504289a276ecc19af43faef2cb7ba4a3db28e Mon Sep 17 00:00:00 2001 From: m-szymanska Date: Sat, 27 Jun 2026 22:47:54 -0700 Subject: [PATCH 1/7] fix(install): verify aicx + aicx-mcp as a versioned pair Shadow scan and post-install path resolution were aicx-only, so a fresh CLI could ship next to a stale aicx-mcp without warning. Scan and verify both binaries (install.sh + npm install.js + 3 platform postinstalls) and report dry-run intent for shadow cleanup. Co-Authored-By: Claude Opus 4.8 (1M context) --- distribution/npm/aicx/install.js | 14 +++-- .../darwin-arm64/postinstall.js | 16 ++++-- .../linux-x64-gnu/postinstall.js | 16 ++++-- .../win32-x64-gnu/postinstall.js | 16 ++++-- install.sh | 55 ++++++++++++------- 5 files changed, 75 insertions(+), 42 deletions(-) diff --git a/distribution/npm/aicx/install.js b/distribution/npm/aicx/install.js index 0d3feda..4707af5 100644 --- a/distribution/npm/aicx/install.js +++ b/distribution/npm/aicx/install.js @@ -92,11 +92,11 @@ function cleanupShadowDir(scope, targetVersion) { } } -function scanAicxShadows(installedPath, targetVersion) { - const pathBinaries = Array.from(new Set(whichAll("aicx"))); +function scanBinaryShadows(binaryName, installedPath, targetVersion) { + const pathBinaries = Array.from(new Set(whichAll(binaryName))); if (pathBinaries.length === 0) return; - console.warn("[AICX npm] Existing aicx binaries on PATH:"); + console.warn(`[AICX npm] Existing ${binaryName} binaries on PATH:`); for (const path of pathBinaries) { const version = commandOutput(path, ["--version"]) || "unknown"; console.warn(` ${path} -> ${version}`); @@ -104,12 +104,16 @@ function scanAicxShadows(installedPath, targetVersion) { const resolved = pathBinaries[0]; if (resolved && resolved !== installedPath) { - console.warn("[AICX npm] WARNING: PATH may resolve to a different aicx than this npm package."); + console.warn(`[AICX npm] WARNING: PATH may resolve to a different ${binaryName} than this npm package.`); console.warn(` npm package binary: ${installedPath} -> ${targetVersion}`); console.warn(` PATH resolves to: ${resolved}`); console.warn(" Set AICX_NPM_REPLACE_LOCAL=1 to remove older/equal ~/.local/bin or cargo-bin shadows during npm install."); } +} +function scanAicxShadows(installedAicxPath, installedMcpPath, targetVersion) { + scanBinaryShadows("aicx", installedAicxPath, targetVersion); + scanBinaryShadows("aicx-mcp", installedMcpPath, targetVersion); if (envFlag("AICX_NPM_REPLACE_LOCAL")) { cleanupShadowDir("local-bin", targetVersion); cleanupShadowDir("cargo-bin", targetVersion); @@ -155,4 +159,4 @@ if (hasError) { process.exit(1); } -scanAicxShadows(getBinaryPath("aicx"), VERSION); +scanAicxShadows(getBinaryPath("aicx"), getBinaryPath("aicx-mcp"), VERSION); diff --git a/distribution/npm/aicx/platform-packages/darwin-arm64/postinstall.js b/distribution/npm/aicx/platform-packages/darwin-arm64/postinstall.js index 10857ef..6fb70f9 100644 --- a/distribution/npm/aicx/platform-packages/darwin-arm64/postinstall.js +++ b/distribution/npm/aicx/platform-packages/darwin-arm64/postinstall.js @@ -130,11 +130,11 @@ function cleanupShadowDir(scope, targetVersion) { } } -function scanAicxShadows(installedPath, targetVersion) { - const pathBinaries = Array.from(new Set(whichAll("aicx"))); +function scanBinaryShadows(binaryName, installedPath, targetVersion) { + const pathBinaries = Array.from(new Set(whichAll(binaryName))); if (pathBinaries.length === 0) return; - console.warn("[AICX npm] Existing aicx binaries on PATH:"); + console.warn(`[AICX npm] Existing ${binaryName} binaries on PATH:`); for (const path of pathBinaries) { const version = commandOutput(path, ["--version"]) || "unknown"; console.warn(` ${path} -> ${version}`); @@ -142,12 +142,16 @@ function scanAicxShadows(installedPath, targetVersion) { const resolved = pathBinaries[0]; if (resolved && resolved !== installedPath) { - console.warn("[AICX npm] WARNING: PATH may resolve to a different aicx than this npm package."); + console.warn(`[AICX npm] WARNING: PATH may resolve to a different ${binaryName} than this npm package.`); console.warn(` npm package binary: ${installedPath} -> ${targetVersion}`); console.warn(` PATH resolves to: ${resolved}`); console.warn(" Set AICX_NPM_REPLACE_LOCAL=1 to remove older/equal ~/.local/bin or cargo-bin shadows during npm install."); } +} +function scanAicxShadows(installedAicxPath, installedMcpPath, targetVersion) { + scanBinaryShadows("aicx", installedAicxPath, targetVersion); + scanBinaryShadows("aicx-mcp", installedMcpPath, targetVersion); if (envFlag("AICX_NPM_REPLACE_LOCAL")) { cleanupShadowDir("local-bin", targetVersion); cleanupShadowDir("cargo-bin", targetVersion); @@ -232,7 +236,7 @@ async function install() { const targetAicxMcp = join(__dirname, `aicx-mcp${exe}`); if (existsSync(targetAicx) && existsSync(targetAicxMcp)) { console.log(`Binaries already exist at ${__dirname}`); - scanAicxShadows(targetAicx, VERSION); + scanAicxShadows(targetAicx, targetAicxMcp, VERSION); return; } @@ -264,7 +268,7 @@ async function install() { rmSync(tempDir, { recursive: true, force: true }); console.log(`Successfully installed aicx binaries to ${__dirname}`); - scanAicxShadows(targetAicx, VERSION); + scanAicxShadows(targetAicx, targetAicxMcp, VERSION); } catch (error) { rmSync(tempDir, { recursive: true, force: true }); console.error(` diff --git a/distribution/npm/aicx/platform-packages/linux-x64-gnu/postinstall.js b/distribution/npm/aicx/platform-packages/linux-x64-gnu/postinstall.js index 10857ef..6fb70f9 100644 --- a/distribution/npm/aicx/platform-packages/linux-x64-gnu/postinstall.js +++ b/distribution/npm/aicx/platform-packages/linux-x64-gnu/postinstall.js @@ -130,11 +130,11 @@ function cleanupShadowDir(scope, targetVersion) { } } -function scanAicxShadows(installedPath, targetVersion) { - const pathBinaries = Array.from(new Set(whichAll("aicx"))); +function scanBinaryShadows(binaryName, installedPath, targetVersion) { + const pathBinaries = Array.from(new Set(whichAll(binaryName))); if (pathBinaries.length === 0) return; - console.warn("[AICX npm] Existing aicx binaries on PATH:"); + console.warn(`[AICX npm] Existing ${binaryName} binaries on PATH:`); for (const path of pathBinaries) { const version = commandOutput(path, ["--version"]) || "unknown"; console.warn(` ${path} -> ${version}`); @@ -142,12 +142,16 @@ function scanAicxShadows(installedPath, targetVersion) { const resolved = pathBinaries[0]; if (resolved && resolved !== installedPath) { - console.warn("[AICX npm] WARNING: PATH may resolve to a different aicx than this npm package."); + console.warn(`[AICX npm] WARNING: PATH may resolve to a different ${binaryName} than this npm package.`); console.warn(` npm package binary: ${installedPath} -> ${targetVersion}`); console.warn(` PATH resolves to: ${resolved}`); console.warn(" Set AICX_NPM_REPLACE_LOCAL=1 to remove older/equal ~/.local/bin or cargo-bin shadows during npm install."); } +} +function scanAicxShadows(installedAicxPath, installedMcpPath, targetVersion) { + scanBinaryShadows("aicx", installedAicxPath, targetVersion); + scanBinaryShadows("aicx-mcp", installedMcpPath, targetVersion); if (envFlag("AICX_NPM_REPLACE_LOCAL")) { cleanupShadowDir("local-bin", targetVersion); cleanupShadowDir("cargo-bin", targetVersion); @@ -232,7 +236,7 @@ async function install() { const targetAicxMcp = join(__dirname, `aicx-mcp${exe}`); if (existsSync(targetAicx) && existsSync(targetAicxMcp)) { console.log(`Binaries already exist at ${__dirname}`); - scanAicxShadows(targetAicx, VERSION); + scanAicxShadows(targetAicx, targetAicxMcp, VERSION); return; } @@ -264,7 +268,7 @@ async function install() { rmSync(tempDir, { recursive: true, force: true }); console.log(`Successfully installed aicx binaries to ${__dirname}`); - scanAicxShadows(targetAicx, VERSION); + scanAicxShadows(targetAicx, targetAicxMcp, VERSION); } catch (error) { rmSync(tempDir, { recursive: true, force: true }); console.error(` diff --git a/distribution/npm/aicx/platform-packages/win32-x64-gnu/postinstall.js b/distribution/npm/aicx/platform-packages/win32-x64-gnu/postinstall.js index 10857ef..6fb70f9 100644 --- a/distribution/npm/aicx/platform-packages/win32-x64-gnu/postinstall.js +++ b/distribution/npm/aicx/platform-packages/win32-x64-gnu/postinstall.js @@ -130,11 +130,11 @@ function cleanupShadowDir(scope, targetVersion) { } } -function scanAicxShadows(installedPath, targetVersion) { - const pathBinaries = Array.from(new Set(whichAll("aicx"))); +function scanBinaryShadows(binaryName, installedPath, targetVersion) { + const pathBinaries = Array.from(new Set(whichAll(binaryName))); if (pathBinaries.length === 0) return; - console.warn("[AICX npm] Existing aicx binaries on PATH:"); + console.warn(`[AICX npm] Existing ${binaryName} binaries on PATH:`); for (const path of pathBinaries) { const version = commandOutput(path, ["--version"]) || "unknown"; console.warn(` ${path} -> ${version}`); @@ -142,12 +142,16 @@ function scanAicxShadows(installedPath, targetVersion) { const resolved = pathBinaries[0]; if (resolved && resolved !== installedPath) { - console.warn("[AICX npm] WARNING: PATH may resolve to a different aicx than this npm package."); + console.warn(`[AICX npm] WARNING: PATH may resolve to a different ${binaryName} than this npm package.`); console.warn(` npm package binary: ${installedPath} -> ${targetVersion}`); console.warn(` PATH resolves to: ${resolved}`); console.warn(" Set AICX_NPM_REPLACE_LOCAL=1 to remove older/equal ~/.local/bin or cargo-bin shadows during npm install."); } +} +function scanAicxShadows(installedAicxPath, installedMcpPath, targetVersion) { + scanBinaryShadows("aicx", installedAicxPath, targetVersion); + scanBinaryShadows("aicx-mcp", installedMcpPath, targetVersion); if (envFlag("AICX_NPM_REPLACE_LOCAL")) { cleanupShadowDir("local-bin", targetVersion); cleanupShadowDir("cargo-bin", targetVersion); @@ -232,7 +236,7 @@ async function install() { const targetAicxMcp = join(__dirname, `aicx-mcp${exe}`); if (existsSync(targetAicx) && existsSync(targetAicxMcp)) { console.log(`Binaries already exist at ${__dirname}`); - scanAicxShadows(targetAicx, VERSION); + scanAicxShadows(targetAicx, targetAicxMcp, VERSION); return; } @@ -264,7 +268,7 @@ async function install() { rmSync(tempDir, { recursive: true, force: true }); console.log(`Successfully installed aicx binaries to ${__dirname}`); - scanAicxShadows(targetAicx, VERSION); + scanAicxShadows(targetAicx, targetAicxMcp, VERSION); } catch (error) { rmSync(tempDir, { recursive: true, force: true }); console.error(` diff --git a/install.sh b/install.sh index 94ec8b2..e702443 100755 --- a/install.sh +++ b/install.sh @@ -603,18 +603,19 @@ target_install_version() { esac } -scan_aicx_shadows() { - local target_path="$1" +scan_binary_shadows() { + local binary_name="$1" + local target_path="$2" local shadow_paths count path version resolved - echo "Scanning current aicx installation surface..." - shadow_paths=$(which -a aicx 2>/dev/null | sort -u || true) + echo "Scanning current $binary_name installation surface..." + shadow_paths=$(which -a "$binary_name" 2>/dev/null | sort -u || true) if [ -z "$shadow_paths" ]; then - echo " no existing aicx on PATH" + echo " no existing $binary_name on PATH" return 0 fi - echo "Found existing aicx installations:" + echo "Found existing $binary_name installations:" count=0 while IFS= read -r path; do [ -n "$path" ] || continue @@ -623,10 +624,10 @@ scan_aicx_shadows() { echo " $path -> $version" done <<< "$shadow_paths" - resolved=$(command -v aicx 2>/dev/null || true) + resolved=$(command -v "$binary_name" 2>/dev/null || true) if [ "$count" -gt 1 ] || { [ -n "$resolved" ] && ! same_path "$resolved" "$target_path"; }; then echo "" - echo "WARNING: Multiple or shadowing aicx binaries detected." + echo "WARNING: Multiple or shadowing $binary_name binaries detected." echo " target install path: $target_path" if [ -n "$resolved" ]; then echo " PATH currently resolves to: $resolved" @@ -650,6 +651,14 @@ scan_aicx_shadows() { fi } +scan_aicx_shadows() { + local target_aicx="$1" + local target_mcp="$2" + + scan_binary_shadows "aicx" "$target_aicx" + scan_binary_shadows "aicx-mcp" "$target_mcp" +} + cleanup_shadow_pair() { local dir="$1" local target_dir="$2" @@ -671,7 +680,11 @@ cleanup_shadow_pair() { fi if semver_le "$candidate_version" "$target_version"; then - echo " removing shadow aicx $candidate_version from $dir (target $target_version)" + if [ "$AICX_INSTALL_DRY_RUN" = "1" ]; then + echo " would remove shadow aicx $candidate_version from $dir (target $target_version)" + else + echo " removing shadow aicx $candidate_version from $dir (target $target_version)" + fi cleanup_old_binaries "$dir/aicx" cleanup_old_binaries "$dir/aicx-mcp" else @@ -694,6 +707,7 @@ cleanup_shadow_aicx_binaries() { verify_install_path_resolution() { local installed_path="$1" + local binary_name="${2:-aicx}" local installed_version path_resolved path_resolved_version if ! [ -x "$installed_path" ]; then @@ -701,17 +715,17 @@ verify_install_path_resolution() { fi installed_version=$("$installed_path" --version 2>/dev/null || echo "unknown") - path_resolved=$(command -v aicx 2>/dev/null || true) + path_resolved=$(command -v "$binary_name" 2>/dev/null || true) if [ -z "$path_resolved" ]; then echo "" echo "==========================================" - echo "WARNING: installed aicx is not on PATH" + echo "WARNING: installed $binary_name is not on PATH" echo " Installed to: $installed_path -> $installed_version" - echo " Add $(dirname "$installed_path") to PATH before running aicx." + echo " Add $(dirname "$installed_path") to PATH before running $binary_name." echo "==========================================" return 0 fi - path_resolved_version=$(aicx --version 2>/dev/null || echo "unknown") + path_resolved_version=$("$binary_name" --version 2>/dev/null || echo "unknown") if [ -n "$path_resolved" ] && { ! same_path "$path_resolved" "$installed_path" || [ "$installed_version" != "$path_resolved_version" ]; }; then echo "" echo "==========================================" @@ -719,14 +733,14 @@ verify_install_path_resolution() { echo " Installed to: $installed_path -> $installed_version" echo " PATH resolves to: $path_resolved -> $path_resolved_version" echo "" - echo "Other aicx binaries in PATH:" - which -a aicx 2>/dev/null | sort -u | while IFS= read -r path; do + echo "Other $binary_name binaries in PATH:" + which -a "$binary_name" 2>/dev/null | sort -u | while IFS= read -r path; do if [ -n "$path" ] && ! same_path "$path" "$installed_path"; then echo " $path" fi done echo "" - echo "To fix: ensure $(dirname "$installed_path") is before other aicx locations in your PATH," + echo "To fix: ensure $(dirname "$installed_path") is before other $binary_name locations in your PATH," echo "or remove the older binary shown above." echo "==========================================" fi @@ -927,13 +941,15 @@ resolve_install_mode() { INSTALL_MODE=$(resolve_install_mode) INSTALL_TARGET_BIN_DIR=$(install_target_bin_dir "$INSTALL_MODE") INSTALL_TARGET_AICX="$INSTALL_TARGET_BIN_DIR/aicx" +INSTALL_TARGET_AICX_MCP="$INSTALL_TARGET_BIN_DIR/aicx-mcp" INSTALL_TARGET_VERSION=$(target_install_version "$INSTALL_MODE") if [ "$VERIFY_PATH_ONLY" -eq 1 ]; then - verify_install_path_resolution "$INSTALL_TARGET_AICX" + verify_install_path_resolution "$INSTALL_TARGET_AICX" "aicx" + verify_install_path_resolution "$INSTALL_TARGET_AICX_MCP" "aicx-mcp" exit 0 fi if [ "$SKIP_INSTALL" -eq 0 ]; then - scan_aicx_shadows "$INSTALL_TARGET_AICX" + scan_aicx_shadows "$INSTALL_TARGET_AICX" "$INSTALL_TARGET_AICX_MCP" cleanup_shadow_aicx_binaries "$INSTALL_TARGET_BIN_DIR" "$INSTALL_TARGET_VERSION" fi if [ "$SHADOW_CHECK_ONLY" -eq 1 ]; then @@ -998,7 +1014,8 @@ else fi if [ "$SKIP_INSTALL" -eq 0 ]; then - verify_install_path_resolution "$INSTALL_TARGET_AICX" + verify_install_path_resolution "$INSTALL_TARGET_AICX" "aicx" + verify_install_path_resolution "$INSTALL_TARGET_AICX_MCP" "aicx-mcp" fi # --- Step 2: Verify --- From 9a6c7107f1ef939a2e450becfed62fa930382503 Mon Sep 17 00:00:00 2001 From: m-szymanska Date: Sat, 27 Jun 2026 22:47:54 -0700 Subject: [PATCH 2/7] feat(search/mcp/doctor): CLI/MCP search parity, host contract, diagnostics - Shared fuzzy primitives in search_engine (fuzzy_search_with_post_filters, finalize_fuzzy_results, fuzzy_fetch_limit) so the CLI and MCP fuzzy fallback cannot drift; remove the duplicated inline logic in both surfaces. - MCP aicx_search is semantic-first with an explicit semantic_fallback payload and a strict_semantic opt-out for the old fail-fast behavior. - HTTP transport gains --host / --allowed-host / --allow-any-host with a safe loopback default (distinguishing bind host from Host-header validation). - Hybrid results carry an index_snapshot honesty hint (freshness_verified=false) pointing at `aicx index status`; zero search hot-path cost. - doctor gains informational aicx_home / binary_pair / http_auth_token checks (not folded into overall severity); auth::probe_token_source reports the token source without generating or printing it. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/auth.rs | 82 ++++++++- src/bin/aicx_mcp.rs | 64 ++++++- src/doctor/checks.rs | 116 +++++++++++++ src/doctor/report.rs | 4 + src/doctor/tests.rs | 37 ++++ src/doctor/types.rs | 13 ++ src/main.rs | 239 ++++++++++++-------------- src/main/tests.rs | 44 ++++- src/mcp.rs | 398 ++++++++++++++++++++++++++++++++++--------- src/search_engine.rs | 238 ++++++++++++++++++++++++++ 10 files changed, 1025 insertions(+), 210 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index 70b5570..0755095 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,6 +1,7 @@ //! Shared HTTP Bearer-token auth for MCP HTTP transport and dashboard server. //! -//! Single token loaded from CLI override, `AICX_HTTP_AUTH_TOKEN`, `~/.aicx/auth-token`, +//! Single token loaded from CLI override, `AICX_HTTP_AUTH_TOKEN`, +//! `/auth-token` (honors `$AICX_HOME`; defaults to `~/.aicx`), //! or generated and persisted on Unix (mode 0600). Compared in constant time. //! Mismatch and missing produce the same 401 body to defeat oracle probing. //! @@ -82,6 +83,60 @@ fn default_token_path() -> Result { Ok(crate::store::resolve_aicx_home()?.join("auth-token")) } +/// Where the HTTP auth token resolves from, as a non-mutating probe. +/// +/// Distinct from [`AuthSource`] (which is produced by [`load_auth_config`] and +/// may *generate* a token as a side effect). This probe never reads the token +/// value, never generates, and never writes — it only reports the source so +/// `aicx doctor` can show operators where the token comes from without +/// triggering token creation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TokenSourceProbe { + /// `AICX_HTTP_AUTH_TOKEN` is set and non-empty. + Env, + /// A persisted token file exists at this path. + File(PathBuf), + /// No token present yet; one would be generated at this path on first + /// authenticated HTTP serve. + WouldGenerate(PathBuf), + /// The token path could not be resolved (no home directory). + Unresolved, +} + +impl TokenSourceProbe { + /// Operator-facing label. Never includes the token value. + pub fn describe(&self) -> String { + match self { + Self::Env => "env (AICX_HTTP_AUTH_TOKEN)".to_string(), + Self::File(path) => format!("file: {}", path.display()), + Self::WouldGenerate(path) => { + format!( + "none yet — would generate at {} on first HTTP serve", + path.display() + ) + } + Self::Unresolved => "unresolved (no home directory)".to_string(), + } + } +} + +/// Non-mutating probe of the active HTTP auth-token source. Mirrors the +/// resolution order of [`load_auth_config`] (env → file → would-generate) but +/// performs no reads of the token value, no generation, and no writes. +pub fn probe_token_source() -> TokenSourceProbe { + if std::env::var("AICX_HTTP_AUTH_TOKEN") + .map(|value| !value.trim().is_empty()) + .unwrap_or(false) + { + return TokenSourceProbe::Env; + } + match default_token_path() { + Ok(path) if path.exists() => TokenSourceProbe::File(path), + Ok(path) => TokenSourceProbe::WouldGenerate(path), + Err(_) => TokenSourceProbe::Unresolved, + } +} + /// Load auth configuration from (in order): CLI override, env, file, or generate. /// /// `require_auth = false` skips all of the above and returns [`AuthConfig::disabled`]. @@ -696,6 +751,31 @@ mod tests { // Serialise env-var manipulation to avoid cross-test interference. static ENV_MUTEX: Mutex<()> = Mutex::new(()); + #[test] + fn token_source_probe_describe_never_leaks_value() { + assert_eq!( + TokenSourceProbe::Env.describe(), + "env (AICX_HTTP_AUTH_TOKEN)" + ); + let file = TokenSourceProbe::File(std::path::PathBuf::from("/x/auth-token")); + assert!(file.describe().starts_with("file:")); + assert!(file.describe().contains("/x/auth-token")); + let would = TokenSourceProbe::WouldGenerate(std::path::PathBuf::from("/x/auth-token")); + assert!(would.describe().contains("would generate")); + } + + #[test] + fn probe_token_source_detects_env_without_generating() { + let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + clear_env(); + // Safety: env access is serialised by ENV_MUTEX for the test's duration. + unsafe { + std::env::set_var("AICX_HTTP_AUTH_TOKEN", "probe-test-token"); + } + assert_eq!(probe_token_source(), TokenSourceProbe::Env); + clear_env(); + } + fn clear_env() { // Safety: the mutex guards concurrent access to process env across tests. unsafe { diff --git a/src/bin/aicx_mcp.rs b/src/bin/aicx_mcp.rs index 199dbbd..361f977 100644 --- a/src/bin/aicx_mcp.rs +++ b/src/bin/aicx_mcp.rs @@ -7,12 +7,14 @@ //! aicx-mcp # stdio transport //! aicx-mcp --transport http # streamable HTTP on port 8044 //! aicx-mcp --transport http --port 9000 +//! aicx-mcp --transport http --host 0.0.0.0 --port 9000 //! //! Vibecrafted with AI Agents by VetCoders (c)2026 VetCoders use aicx::auth; -use aicx::mcp::{self, McpTransport}; +use aicx::mcp::{self, McpHttpConfig, McpTransport}; use std::io::Write as _; +use std::net::IpAddr; use std::panic; use std::process::ExitCode; @@ -32,6 +34,18 @@ struct Args { #[arg(long, default_value = "8044")] port: u16, + /// Host/IP for streamable HTTP transport (default: 127.0.0.1) + #[arg(long, default_value = "127.0.0.1")] + host: IpAddr, + + /// Allowed HTTP Host header for streamable HTTP clients. Repeat for remote hostnames/IPs. + #[arg(long = "allowed-host", value_name = "HOST")] + allowed_hosts: Vec, + + /// Disable HTTP Host header validation. Not recommended outside trusted networks. + #[arg(long)] + allow_any_host: bool, + /// Optional explicit auth token (overrides env / file / generated). HTTP transport only. #[arg(long, value_name = "TOKEN")] auth_token: Option, @@ -108,7 +122,17 @@ fn main() -> ExitCode { "[aicx-mcp] WARNING: HTTP transport bound without auth (--no-require-auth)", ); } - match rt.block_on(async { mcp::run_transport(args.transport, args.port, auth_config).await }) { + if matches!(args.transport, McpTransport::Http) && args.allow_any_host { + safe_stderr_log("[aicx-mcp] WARNING: HTTP Host validation disabled (--allow-any-host)"); + } + let http_config = McpHttpConfig { + host: args.host, + port: args.port, + allowed_hosts: args.allowed_hosts, + allow_any_host: args.allow_any_host, + }; + match rt.block_on(async { mcp::run_transport(args.transport, http_config, auth_config).await }) + { Ok(()) => ExitCode::SUCCESS, Err(e) => { let err_str = format!("{e:?}"); @@ -122,3 +146,39 @@ fn main() -> ExitCode { } } } + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + use std::net::{IpAddr, Ipv4Addr}; + + #[test] + fn http_host_defaults_to_loopback() { + let args = Args::try_parse_from(["aicx-mcp", "--transport", "http"]) + .expect("http transport should parse"); + + assert_eq!(args.host, IpAddr::V4(Ipv4Addr::LOCALHOST)); + assert_eq!(args.port, 8044); + } + + #[test] + fn http_host_accepts_all_interfaces() { + let args = Args::try_parse_from([ + "aicx-mcp", + "--transport", + "http", + "--host", + "0.0.0.0", + "--allowed-host", + "mcp.example.internal", + "--port", + "9000", + ]) + .expect("explicit http host should parse"); + + assert_eq!(args.host, IpAddr::V4(Ipv4Addr::UNSPECIFIED)); + assert_eq!(args.allowed_hosts, vec!["mcp.example.internal"]); + assert_eq!(args.port, 9000); + } +} diff --git a/src/doctor/checks.rs b/src/doctor/checks.rs index d560f0a..c84d489 100644 --- a/src/doctor/checks.rs +++ b/src/doctor/checks.rs @@ -52,6 +52,14 @@ pub async fn run_at(base: &Path, opts: &DoctorOptions) -> Result { }; let mut context_corpus = check_context_corpus(base); + // Informational diagnostics — deliberately NOT folded into `overall`. + // They report where the runtime resolved its home, whether the CLI and + // aicx-mcp binaries match on PATH, and where the HTTP auth token comes + // from. Health gating stays with the canonical/index checks above. + let aicx_home = check_aicx_home(base); + let binary_pair = check_binary_pair(); + let http_auth_token = check_http_auth_token(); + let mut fixes_applied = Vec::new(); let rebuild_sidecars_script = if opts.rebuild_sidecars { Some(render_rebuild_sidecars_script(base)?) @@ -206,6 +214,9 @@ pub async fn run_at(base: &Path, opts: &DoctorOptions) -> Result { empty_body_chunks, content_dedup, context_corpus, + aicx_home, + binary_pair, + http_auth_token, rebuild_sidecars_script, prune_empty_bodies_script, fixes_applied, @@ -213,6 +224,111 @@ pub async fn run_at(base: &Path, opts: &DoctorOptions) -> Result { }) } +/// Informational: report the resolved AICX_HOME, whether it is pinned via the +/// environment, and whether the canonical store / semantic index live under +/// it. Diagnostic only — `canonical_store` owns the health gate; this exists so +/// an operator can see *where* aicx is looking without spelunking. +pub(crate) fn check_aicx_home(base: &Path) -> CheckResult { + let env_pin = std::env::var_os("AICX_HOME").filter(|value| !value.is_empty()); + let resolved = base.display().to_string(); + let store_present = base.join("store").exists(); + let indexed_present = base.join("indexed").exists(); + let source = match &env_pin { + Some(value) => format!("pinned via AICX_HOME={}", PathBuf::from(value).display()), + None => "default (bootstrap config or ~/.aicx)".to_string(), + }; + let detail = format!( + "resolved home: {resolved} [{source}]; store/ {}, indexed/ {}", + if store_present { "present" } else { "missing" }, + if indexed_present { + "present" + } else { + "missing" + }, + ); + let (severity, recommendation) = if store_present { + (Severity::Green, None) + } else { + ( + Severity::Warning, + Some(format!( + "No canonical store under {resolved}. If your corpus lives elsewhere, set AICX_HOME to that path before running aicx (default is ~/.aicx)." + )), + ) + }; + CheckResult { + name: "aicx_home".to_string(), + severity, + detail, + recommendation, + } +} + +/// Informational: compare the running aicx CLI version against the aicx-mcp +/// companion resolved on PATH. Surfaces the "fresh CLI, stale MCP" drift class +/// where a long-running MCP service answers health checks while serving older +/// search behavior. Diagnostic only — not part of `overall`. +pub(crate) fn check_binary_pair() -> CheckResult { + let cli_version = env!("CARGO_PKG_VERSION"); + let probed = std::process::Command::new("aicx-mcp") + .arg("--version") + .output() + .ok() + .filter(|out| out.status.success()) + .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string()); + match probed { + None => CheckResult { + name: "binary_pair".to_string(), + severity: Severity::Warning, + detail: format!("aicx CLI {cli_version}; aicx-mcp not found on PATH"), + recommendation: Some( + "Install the matching aicx-mcp so `aicx serve` MCP behavior tracks the CLI." + .to_string(), + ), + }, + Some(version_line) => { + let mcp_version = version_line + .rsplit(char::is_whitespace) + .next() + .unwrap_or(version_line.as_str()); + if mcp_version == cli_version { + CheckResult { + name: "binary_pair".to_string(), + severity: Severity::Green, + detail: format!( + "aicx CLI {cli_version} matches aicx-mcp {mcp_version} on PATH" + ), + recommendation: None, + } + } else { + CheckResult { + name: "binary_pair".to_string(), + severity: Severity::Warning, + detail: format!( + "version drift: aicx CLI {cli_version} vs aicx-mcp {mcp_version} on PATH" + ), + recommendation: Some( + "Reinstall so both binaries match; a stale aicx-mcp service can serve old search behavior while looking healthy." + .to_string(), + ), + } + } + } + } +} + +/// Informational: report where the HTTP auth token resolves from, without +/// reading the token value or generating one. Diagnostic only. +pub(crate) fn check_http_auth_token() -> CheckResult { + let probe = crate::auth::probe_token_source(); + CheckResult { + name: "http_auth_token".to_string(), + severity: Severity::Green, + detail: format!("HTTP auth token source: {}", probe.describe()), + recommendation: None, + } +} + pub(crate) fn check_context_corpus(base: &Path) -> CheckResult { let corpus_root = base.join(store::CONTEXT_CORPUS_DIRNAME); if !corpus_root.exists() { diff --git a/src/doctor/report.rs b/src/doctor/report.rs index df0913e..191fac0 100644 --- a/src/doctor/report.rs +++ b/src/doctor/report.rs @@ -41,6 +41,10 @@ pub fn format_report_text(report: &DoctorReport, verbose: bool) -> String { &report.embedder_warmth, &report.empty_body_chunks, &report.content_dedup, + &report.context_corpus, + &report.aicx_home, + &report.binary_pair, + &report.http_auth_token, ]; for check in checks { out.push_str(&format!( diff --git a/src/doctor/tests.rs b/src/doctor/tests.rs index 22adaac..9e66c8b 100644 --- a/src/doctor/tests.rs +++ b/src/doctor/tests.rs @@ -215,6 +215,9 @@ fn oracle_readiness_is_ready_when_semantic_and_freshness_are_green() { detail: "ok".to_string(), recommendation: None, }, + aicx_home: CheckResult::default(), + binary_pair: CheckResult::default(), + http_auth_token: CheckResult::default(), rebuild_sidecars_script: None, prune_empty_bodies_script: None, fixes_applied: Vec::new(), @@ -279,6 +282,40 @@ fn check_canonical_store_warns_when_missing() { let _ = std::fs::remove_dir_all(&tmp); } +#[test] +fn check_aicx_home_is_green_when_store_present() { + let tmp = unique_test_dir("home-present"); + std::fs::create_dir_all(tmp.join("store")).unwrap(); + let result = check_aicx_home(&tmp); + assert_eq!(result.name, "aicx_home"); + assert_eq!(result.severity, Severity::Green); + assert!(result.detail.contains("store/ present")); + assert!(result.detail.contains(&tmp.display().to_string())); + let _ = std::fs::remove_dir_all(&tmp); +} + +#[test] +fn check_aicx_home_warns_and_advises_when_store_missing() { + let tmp = unique_test_dir("home-missing"); + std::fs::create_dir_all(&tmp).unwrap(); + let result = check_aicx_home(&tmp); + assert_eq!(result.name, "aicx_home"); + assert_eq!(result.severity, Severity::Warning); + assert!(result.detail.contains("store/ missing")); + assert!(result.recommendation.unwrap().contains("AICX_HOME")); + let _ = std::fs::remove_dir_all(&tmp); +} + +#[test] +fn check_http_auth_token_is_informational_and_never_leaks_value() { + let result = check_http_auth_token(); + assert_eq!(result.name, "http_auth_token"); + // Always Green: it reports a source, it does not gate health. + assert_eq!(result.severity, Severity::Green); + assert!(result.detail.starts_with("HTTP auth token source:")); + let _ = result.recommendation; +} + #[test] fn check_corpus_buckets_green_when_only_valid_names() { let tmp = unique_test_dir("valid-buckets"); diff --git a/src/doctor/types.rs b/src/doctor/types.rs index fb7df70..86625e7 100644 --- a/src/doctor/types.rs +++ b/src/doctor/types.rs @@ -107,6 +107,19 @@ pub struct DoctorReport { pub content_dedup: CheckResult, #[serde(default)] pub context_corpus: CheckResult, + /// Informational: which AICX_HOME the runtime resolved, whether it is + /// pinned via env, and whether store/indexed live there. Not part of + /// `overall` — diagnostic, not a gate. + #[serde(default)] + pub aicx_home: CheckResult, + /// Informational: aicx CLI vs aicx-mcp version parity on PATH. Catches the + /// "fresh CLI, stale MCP service" drift class. Not part of `overall`. + #[serde(default)] + pub binary_pair: CheckResult, + /// Informational: where the HTTP auth token resolves from (env / file / + /// would-generate). Never exposes the token value. Not part of `overall`. + #[serde(default)] + pub http_auth_token: CheckResult, #[serde(skip_serializing_if = "Option::is_none")] pub rebuild_sidecars_script: Option, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/src/main.rs b/src/main.rs index b809e6a..6061dd2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,6 +27,7 @@ use serde::Serialize; use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::io::{self, BufReader, IsTerminal, Write}; +use std::net::IpAddr; use std::path::{Path, PathBuf}; use std::process::{Command as ProcessCommand, Stdio}; @@ -36,7 +37,7 @@ use aicx::corpus; use aicx::dashboard::{self, DashboardConfig, DashboardScope}; use aicx::dashboard_server::{self, DashboardCorsPolicy, DashboardServerConfig}; use aicx::intents; -use aicx::mcp::{self, McpTransport}; +use aicx::mcp::{self, McpHttpConfig, McpTransport}; use aicx::output::{self, OutputConfig, OutputFormat, OutputMode, ReportMetadata}; use aicx::rank; use aicx::reports_extractor::{self, ReportsExtractorConfig}; @@ -1283,6 +1284,18 @@ enum Commands { #[arg(long, default_value = "8044")] port: u16, + /// Host/IP for streamable HTTP transport (default: 127.0.0.1) + #[arg(long, default_value = "127.0.0.1")] + host: IpAddr, + + /// Allowed HTTP Host header for streamable HTTP clients. Repeat for remote hostnames/IPs. + #[arg(long = "allowed-host", value_name = "HOST")] + allowed_hosts: Vec, + + /// Disable HTTP Host header validation. Not recommended outside trusted networks. + #[arg(long)] + allow_any_host: bool, + /// Optional explicit auth token (overrides env / file / generated). HTTP transport only. #[arg(long, value_name = "TOKEN")] auth_token: Option, @@ -2332,6 +2345,9 @@ fn run_command(command: Option) -> Result<()> { Some(Commands::Serve { transport, port, + host, + allowed_hosts, + allow_any_host, auth_token, require_auth, }) => { @@ -2341,8 +2357,17 @@ fn run_command(command: Option) -> Result<()> { "! Warning: MCP HTTP transport bound without auth — knowing the port is enough to invoke MCP tools." ); } + if matches!(transport, McpTransport::Http) && allow_any_host { + eprintln!("! Warning: MCP HTTP Host validation disabled (--allow-any-host)."); + } + let http_config = McpHttpConfig { + host, + port, + allowed_hosts, + allow_any_host, + }; let rt = tokio::runtime::Runtime::new()?; - rt.block_on(async { mcp::run_transport(transport, port, auth_config).await })?; + rt.block_on(async { mcp::run_transport(transport, http_config, auth_config).await })?; } Some(Commands::Search { query, @@ -7331,16 +7356,6 @@ fn validate_cli_search_limit(limit: usize) -> Result<()> { Ok(()) } -fn search_examined_fetch_limit(user_limit: usize, filters_active: bool) -> usize { - if filters_active { - user_limit - .saturating_mul(aicx::search_engine::FILTER_EXAMINED_CAP_RATIO) - .max(aicx::search_engine::FILTER_EXAMINED_CAP_MIN) - } else { - user_limit - } -} - fn run_search(args: SearchRunArgs<'_>) -> Result<()> { let SearchRunArgs { query, @@ -7397,93 +7412,91 @@ fn run_search(args: SearchRunArgs<'_>) -> Result<()> { let resolved_projects = resolve_project_filters_or_error(projects)?; let scopes = project_scopes(&resolved_projects); - let (mut results, scanned, semantic_status, pushdown_diagnostic, semantic_fallback) = - if no_semantic { - let (results, scanned) = run_fuzzy_search_with_filters( - &root, - &search_query, - limit, - &scopes, - filters.frame_kind.map(Into::into), - &post_filters, - )?; - (results, scanned, None, None, None) - } else { - match aicx::search_engine::try_semantic_search_filtered( - &root, - &search_query, - limit, - &scopes, - filters.frame_kind.map(Into::into), - kind_filter.map(|kind| kind.dir_name()), - &post_filters, - ) { - Ok(filtered) => { - let aicx::search_engine::FilteredSemanticOutcome { - outcome, - diagnostic, - } = filtered; - let status = ( - outcome.backend_label, - outcome.model_id.clone(), - outcome.scanned, - outcome.retrieval_status.clone(), + let (results, scanned, semantic_status, pushdown_diagnostic, semantic_fallback) = if no_semantic + { + let (results, scanned) = aicx::search_engine::fuzzy_search_with_post_filters( + &root, + &search_query, + limit, + &scopes, + filters.frame_kind.map(Into::into), + &post_filters, + )?; + (results, scanned, None, None, None) + } else { + match aicx::search_engine::try_semantic_search_filtered( + &root, + &search_query, + limit, + &scopes, + filters.frame_kind.map(Into::into), + kind_filter.map(|kind| kind.dir_name()), + &post_filters, + ) { + Ok(filtered) => { + let aicx::search_engine::FilteredSemanticOutcome { + outcome, + diagnostic, + } = filtered; + let status = ( + outcome.backend_label, + outcome.model_id.clone(), + outcome.scanned, + outcome.retrieval_status.clone(), + ); + ( + outcome.results, + outcome.scanned, + Some(status), + diagnostic, + None, + ) + } + Err(err) => { + let fallback = SemanticFallbackNotice::from_error(&err); + let (results, scanned) = aicx::search_engine::fuzzy_search_with_post_filters( + &root, + &search_query, + limit, + &scopes, + filters.frame_kind.map(Into::into), + &post_filters, + )?; + if !json { + eprintln!( + "aicx search: semantic search unavailable; falling back to filesystem fuzzy." ); - ( - outcome.results, - outcome.scanned, - Some(status), - diagnostic, - None, - ) - } - Err(err) => { - let fallback = SemanticFallbackNotice::from_error(&err); - let (results, scanned) = run_fuzzy_search_with_filters( - &root, - &search_query, - limit, - &scopes, - filters.frame_kind.map(Into::into), - &post_filters, - )?; - if !json { - eprintln!( - "aicx search: semantic search unavailable; falling back to filesystem fuzzy." - ); - eprintln!(" kind: {}", err.kind()); - eprintln!(" reason: {}", err.reason()); - eprintln!(" recommendation: {}", err.recommendation()); - } - (results, scanned, None, None, Some(fallback)) + eprintln!(" kind: {}", err.kind()); + eprintln!(" reason: {}", err.reason()); + eprintln!(" recommendation: {}", err.recommendation()); } + (results, scanned, None, None, Some(fallback)) } - }; - - // Defensive kind retain: the semantic path pushes `kind_filter` - // into the hybrid query, but we keep the explicit check so a future - // index regression cannot smuggle off-kind hits past the operator. - if let Some(kind_filter) = kind_filter { - results.retain(|r| r.kind == kind_filter.dir_name()); - } + } + }; - if let Some(sort_order) = filters.sort { - results.sort_by(|a, b| { - let t_a = a.timestamp.as_deref().unwrap_or(a.date.as_str()); - let t_b = b.timestamp.as_deref().unwrap_or(b.date.as_str()); - match sort_order { - SortOrder::Newest => t_b.cmp(t_a), - SortOrder::Oldest => t_a.cmp(t_b), - SortOrder::Score => b.score.cmp(&a.score).then(t_b.cmp(t_a)), - } - }); - } else { - // default sort - results.sort_by_key(|b| std::cmp::Reverse(b.score)); - } + // Shared finalize (kind retain + sort + truncate) keeps CLI and MCP search + // ordering/limit semantics identical across surfaces. The defensive kind + // retain also guards against a future index regression smuggling off-kind + // hits past the operator. + let sort_label = filters.sort.map(|order| match order { + SortOrder::Newest => "newest", + SortOrder::Oldest => "oldest", + SortOrder::Score => "score", + }); + let results = aicx::search_engine::finalize_fuzzy_results( + results, + kind_filter.map(|kind| kind.dir_name()), + sort_label, + limit, + ); - // Truncate to requested limit after date filtering - let results: Vec<_> = results.into_iter().take(limit).collect(); + // Source-chunk count from the hybrid manifest (if this was a hybrid run); + // borrow so the by-value `match semantic_status` below still owns it. + let hybrid_source_chunks = match &semantic_status { + Some((_, _, _, Some(retrieval_status))) => Some(retrieval_status.source_chunk_count), + _ => None, + }; if json { let oracle_status = match semantic_status { @@ -7522,6 +7535,11 @@ fn run_search(args: SearchRunArgs<'_>) -> Result<()> { &rendered, pushdown_diagnostic.as_ref(), )?; + // Hybrid results reflect the committed index snapshot, not a live + // freshness check; attach the honesty hint pointing at `index status`. + if let Some(source_chunks) = hybrid_source_chunks { + payload = aicx::search_engine::inject_index_snapshot_hint(&payload, source_chunks)?; + } if let Some(ref fallback) = semantic_fallback { let mut value: serde_json::Value = serde_json::from_str(&payload)?; if let Some(obj) = value.as_object_mut() { @@ -7622,39 +7640,6 @@ impl SemanticFallbackNotice { } } -fn run_fuzzy_search_with_filters( - root: &Path, - search_query: &str, - limit: usize, - scopes: &[Option<&str>], - frame_kind: Option, - post_filters: &aicx::search_engine::SemanticSearchFilters, -) -> Result<(Vec, usize)> { - // Fuzzy path keeps the legacy "fetch then post-filter" shape. It is - // reached either by operator request (`--no-semantic`) or by explicit - // semantic degradation when the committed index cannot be served. - let fuzzy_fetch_limit = search_examined_fetch_limit(limit, post_filters.is_active()); - let (mut results, scanned) = - rank::fuzzy_search_store(root, search_query, fuzzy_fetch_limit, scopes, frame_kind)?; - if let Some(min_score) = post_filters.score_min { - results.retain(|r| r.score >= min_score); - } - if let Some(ref agent_filter) = post_filters.agent { - results.retain(|r| r.agent == *agent_filter); - } - if post_filters.date_lo.is_some() || post_filters.date_hi.is_some() { - let lo = post_filters.date_lo.as_deref(); - let hi = post_filters.date_hi.as_deref(); - results.retain(|r| { - lo.is_none_or(|lo| r.date.as_str() >= lo) && hi.is_none_or(|hi| r.date.as_str() <= hi) - }); - } else if let Some(ref cutoff) = post_filters.hours_cutoff { - let cutoff = cutoff.as_str(); - results.retain(|r| r.date.as_str() >= cutoff); - } - Ok((results, scanned)) -} - /// Build the `IndexEvent` -> sink fanout used by `aicx index`. Always /// includes a tracing adapter (for log capture / non-TTY runs); adds an /// `IndicatifSink` with live ETA + rate when stderr is an interactive diff --git a/src/main/tests.rs b/src/main/tests.rs index f8ba574..e08c171 100644 --- a/src/main/tests.rs +++ b/src/main/tests.rs @@ -509,15 +509,17 @@ fn run_search_rejects_limit_over_hard_cap_before_store_access() { #[test] fn fuzzy_fetch_limit_uses_semantic_filter_cap_constants() { + // The CLI fuzzy fallback now routes through the shared search_engine + // primitive; assert it still honors the bounded over-fetch contract. assert_eq!( - search_examined_fetch_limit(1, true), + aicx::search_engine::fuzzy_fetch_limit(1, true), aicx::search_engine::FILTER_EXAMINED_CAP_MIN ); assert_eq!( - search_examined_fetch_limit(10, true), + aicx::search_engine::fuzzy_fetch_limit(10, true), 10 * aicx::search_engine::FILTER_EXAMINED_CAP_RATIO ); - assert_eq!(search_examined_fetch_limit(1, false), 1); + assert_eq!(aicx::search_engine::fuzzy_fetch_limit(1, false), 1); } #[test] @@ -1721,6 +1723,37 @@ fn serve_accepts_http_and_legacy_sse_transport_names() { } } +#[test] +fn serve_accepts_explicit_http_host() { + let parsed = Cli::try_parse_from([ + "aicx", + "serve", + "--transport", + "http", + "--host", + "0.0.0.0", + "--allowed-host", + "mcp.example.internal", + "--port", + "9000", + ]) + .expect("http host should parse"); + + match parsed.command { + Some(Commands::Serve { + host, + port, + allowed_hosts, + .. + }) => { + assert_eq!(host, std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)); + assert_eq!(allowed_hosts, vec!["mcp.example.internal"]); + assert_eq!(port, 9000); + } + _ => panic!("expected serve command"), + } +} + #[test] fn serve_help_prefers_http_name_and_stays_compact() { let mut cmd = Cli::command(); @@ -1730,10 +1763,13 @@ fn serve_help_prefers_http_name_and_stays_compact() { let rendered = serve.render_long_help().to_string(); assert!(rendered.contains("Transport: stdio (default) or http.")); + assert!(rendered.contains("--host ")); + assert!(rendered.contains("--allowed-host ")); + assert!(rendered.contains("default: 127.0.0.1")); assert!(!rendered.contains("Transport: stdio (default) or sse")); assert!(!rendered.contains("embedding mode")); assert!( - rendered.lines().count() < 30, + rendered.lines().count() < 48, "serve help should stay compact" ); } diff --git a/src/mcp.rs b/src/mcp.rs index 80e53fa..8107260 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -18,6 +18,7 @@ use std::{ collections::HashMap, error::Error, fmt, + net::{IpAddr, Ipv4Addr, SocketAddr}, sync::Arc, time::{Duration, Instant}, }; @@ -283,6 +284,51 @@ pub enum McpTransport { Http, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct McpHttpConfig { + pub host: IpAddr, + pub port: u16, + pub allowed_hosts: Vec, + pub allow_any_host: bool, +} + +impl McpHttpConfig { + pub fn new(host: IpAddr, port: u16) -> Self { + Self { + host, + port, + allowed_hosts: Vec::new(), + allow_any_host: false, + } + } + + pub fn localhost(port: u16) -> Self { + Self::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port) + } + + pub fn allowed_hosts_for_rmcp(&self) -> Vec { + let mut allowed = Vec::new(); + push_allowed_host(&mut allowed, "localhost"); + push_allowed_host(&mut allowed, "127.0.0.1"); + push_allowed_host(&mut allowed, "::1"); + push_allowed_host(&mut allowed, self.host.to_string()); + for host in &self.allowed_hosts { + push_allowed_host(&mut allowed, host.trim()); + } + allowed + } +} + +fn push_allowed_host(allowed: &mut Vec, host: impl AsRef) { + let host = host.as_ref(); + if host.is_empty() { + return; + } + if !allowed.iter().any(|existing| existing == host) { + allowed.push(host.to_string()); + } +} + #[derive(Debug, Deserialize, JsonSchema)] pub struct SearchParams { /// Search query text @@ -319,6 +365,10 @@ pub struct SearchParams { /// Return snippet + full evidence #[serde(default)] pub verbose: bool, + /// Return a structured error instead of filesystem-fuzzy fallback when + /// semantic preconditions are missing. + #[serde(default)] + pub strict_semantic: bool, } #[derive(Debug, Deserialize, JsonSchema)] @@ -627,6 +677,109 @@ fn inject_mcp_filter_pushdown_payload( .map_err(|e| McpError::internal_error(format!("Inject filter_pushdown JSON: {e}"), None)) } +#[derive(Debug, Clone)] +struct McpSemanticFallbackNotice { + kind: String, + reason: String, + recommendation: String, +} + +impl McpSemanticFallbackNotice { + fn from_error(err: &crate::search_engine::SemanticError) -> Self { + Self { + kind: err.kind().to_string(), + reason: err.reason().to_string(), + recommendation: err.recommendation().to_string(), + } + } +} + +fn mcp_semantic_unavailable_error(fallback: &McpSemanticFallbackNotice) -> McpError { + let payload = serde_json::json!({ + "ok": false, + "error": "semantic_search_unavailable", + "kind": fallback.kind, + "reason": fallback.reason, + "recommendation": fallback.recommendation, + }); + McpError::invalid_params( + format!( + "semantic search unavailable [{}]: {} — recommendation: {}", + fallback.kind, fallback.reason, fallback.recommendation + ), + Some(payload), + ) +} + +fn inject_mcp_semantic_fallback_payload( + rendered: &str, + fallback: &McpSemanticFallbackNotice, +) -> Result { + let mut value: serde_json::Value = serde_json::from_str(rendered).map_err(|e| { + McpError::internal_error(format!("Inject semantic_fallback JSON: {e}"), None) + })?; + let obj = value.as_object_mut().ok_or_else(|| { + McpError::internal_error( + "Inject semantic_fallback JSON: rendered search payload is not an object", + None, + ) + })?; + obj.insert( + "semantic_fallback".to_string(), + serde_json::json!({ + "used": true, + "backend": "filesystem_fuzzy", + "kind": fallback.kind, + "reason": fallback.reason, + "recommendation": fallback.recommendation, + }), + ); + serde_json::to_string(&value).map_err(|e| { + McpError::internal_error(format!("Serialize semantic_fallback JSON: {e}"), None) + }) +} + +/// Shared inputs for the MCP filesystem-fuzzy fallback. Groups the search +/// parameters so the renderer keeps a 2-argument shape (request + fallback +/// notice) and both fallback call sites build one value instead of threading +/// eight positional args. +struct McpFuzzyFallbackRequest<'a> { + store_root: &'a std::path::Path, + query: &'a str, + limit: usize, + project_scopes: &'a [Option<&'a str>], + frame_kind: Option, + kind_filter: Option<&'a str>, + post_filters: &'a crate::search_engine::SemanticSearchFilters, + sort: Option<&'a str>, +} + +fn render_mcp_fuzzy_fallback_payload( + req: &McpFuzzyFallbackRequest<'_>, + fallback: &McpSemanticFallbackNotice, +) -> Result { + // Shared retrieval + post-filter + finalize primitives keep this MCP + // fallback byte-identical to the CLI `aicx search` fuzzy path. + let (results, scanned) = crate::search_engine::fuzzy_search_with_post_filters( + req.store_root, + req.query, + req.limit, + req.project_scopes, + req.frame_kind, + req.post_filters, + ) + .map_err(|e| McpError::internal_error(format!("Filesystem fuzzy search: {e}"), None))?; + let results = + crate::search_engine::finalize_fuzzy_results(results, req.kind_filter, req.sort, req.limit); + let oracle_status = rank::search_oracle_status(req.store_root, &results, scanned); + let rendered = + rank::render_search_json_with_oracle(req.store_root, &results, scanned, oracle_status) + .map_err(|e| { + McpError::internal_error(format!("Serialize fallback search JSON: {e}"), None) + })?; + inject_mcp_semantic_fallback_payload(&rendered, fallback) +} + #[derive(Clone)] pub struct AicxMcpServer { #[allow(dead_code)] @@ -679,7 +832,7 @@ impl AicxMcpServer { #[tool( name = "aicx_search", - description = "Semantic search over the canonical corpus. Fails fast with kind/reason/recommendation when the index or embedder is not ready. Filter pushdown (agent/score/date/hours) iterates a bounded retrieval pool (up to 10x the requested limit) so a corpus whose top-N raw hits all sit outside the filter window still surfaces inside-window matches further down the ranking instead of returning silent-empty. When the cap is examined without satisfying the limit, the response carries a `filter_pushdown` payload with `kind=\"filter_yielded_partial\"` so callers can distinguish bounded under-delivery from a genuinely empty corpus." + description = "Semantic-first search over the canonical corpus. When the semantic index or embedder is not ready, returns filesystem-fuzzy results with an explicit `semantic_fallback` payload; pass `strict_semantic=true` to receive the old fail-fast kind/reason/recommendation error. Filter pushdown (agent/score/date/hours) iterates a bounded semantic retrieval pool (up to 10x the requested limit) so a corpus whose top-N raw hits all sit outside the filter window still surfaces inside-window matches further down the ranking instead of returning silent-empty. When the cap is examined without satisfying the limit, the response carries a `filter_pushdown` payload with `kind=\"filter_yielded_partial\"` so callers can distinguish bounded under-delivery from a genuinely empty corpus." )] async fn search( &self, @@ -691,30 +844,6 @@ impl AicxMcpServer { // archive long-term. tracing::info!(target: "mcp.audit", tool_name = "aicx_search", "mcp tool invoked"); - // D-6: short-circuit while the embedder is in the negative-cache - // window. Returns a structured error the MCP caller can act on - // without further round-trips to the embedder. - let now = Instant::now(); - if let Some(remaining) = self.embedder_negative_cache_remaining(now) { - let payload = serde_json::json!({ - "ok": false, - "error": "semantic_search_unavailable", - "kind": "embedder_unavailable", - "reason": format!( - "embedder negative cache active for another {}s — last embed attempt failed", - remaining.as_secs() - ), - "recommendation": "run `aicx doctor` to inspect embedder health; the cache clears automatically after the TTL elapses", - }); - return Err(McpError::invalid_params( - format!( - "semantic search unavailable [embedder_unavailable]: negative cache active for {}s", - remaining.as_secs() - ), - Some(payload), - )); - } - validate_string_len(Some(params.query.as_str()), 4096, "query")?; validate_string_len(params.project.as_deref(), 4096, "project")?; validate_string_len(params.agent.as_deref(), 4096, "agent")?; @@ -751,8 +880,10 @@ impl AicxMcpServer { }, None => None, }; + let kind_filter_dir = kind_filter.map(|kind| kind.dir_name()); let query = params.query; let limit = params.limit.min(50); + let strict_semantic = params.strict_semantic; let project = params.project; let owned_projects = params .projects @@ -770,22 +901,53 @@ impl AicxMcpServer { let post_filters = filter_build.post_filters; - // Semantic-only dispatch with filter pushdown. No fuzzy fallback. + // One request value shared by both fuzzy-fallback exits below. + let fallback_request = McpFuzzyFallbackRequest { + store_root: &store_root, + query: &query, + limit, + project_scopes: &project_scopes, + frame_kind, + kind_filter: kind_filter_dir, + post_filters: &post_filters, + sort: params.sort.as_deref(), + }; + + // D-6: short-circuit semantic work while the embedder is in the + // negative-cache window. Default MCP behavior still serves Layer 1 + // corpus fuzzy search, so local agents remain useful without a + // hydrated embedder; strict clients can opt back into fail-fast. + let now = Instant::now(); + if let Some(remaining) = self.embedder_negative_cache_remaining(now) { + let fallback = McpSemanticFallbackNotice { + kind: "embedder_unavailable".to_string(), + reason: format!( + "embedder negative cache active for another {}s — last embed attempt failed", + remaining.as_secs() + ), + recommendation: "run `aicx doctor` to inspect embedder health; the cache clears automatically after the TTL elapses".to_string(), + }; + if strict_semantic { + return Err(mcp_semantic_unavailable_error(&fallback)); + } + let payload = render_mcp_fuzzy_fallback_payload(&fallback_request, &fallback)?; + return Ok(CallToolResult::success(vec![Content::text(payload)])); + } + + // Semantic-first dispatch with filter pushdown. Missing semantic + // preconditions degrade to filesystem-fuzzy by default, matching the + // CLI contract. `strict_semantic=true` keeps the fail-fast MCP mode. // The wrapper iterates a bounded retrieval pool (`FILTER_EXAMINED_CAP_RATIO` // x `limit`) so the canonical pathology — top-N raw hits sit // outside the filter window while valid hits exist below — does - // not surface as silent-empty. When a precondition is missing - // (embedder unhydrated, index not built, ...) the wrapper still - // returns a structured McpError carrying the same `kind` + - // `reason` + `recommendation` triple the CLI fail-fast surface - // emits. + // not surface as silent-empty. let filtered = match crate::search_engine::try_semantic_search_filtered( &store_root, &query, limit, &project_scopes, frame_kind, - kind_filter.map(|kind| kind.dir_name()), + kind_filter_dir, &post_filters, ) { Ok(filtered) => filtered, @@ -798,22 +960,12 @@ impl AicxMcpServer { if err.kind() == "embedder_unavailable" { self.mark_embedder_unavailable(Instant::now(), MCP_EMBEDDER_NEGATIVE_TTL); } - let payload = serde_json::json!({ - "ok": false, - "error": "semantic_search_unavailable", - "kind": err.kind(), - "reason": err.reason(), - "recommendation": err.recommendation(), - }); - return Err(McpError::invalid_params( - format!( - "semantic search unavailable [{}]: {} — recommendation: {}", - err.kind(), - err.reason(), - err.recommendation() - ), - Some(payload), - )); + let fallback = McpSemanticFallbackNotice::from_error(&err); + if strict_semantic { + return Err(mcp_semantic_unavailable_error(&fallback)); + } + let payload = render_mcp_fuzzy_fallback_payload(&fallback_request, &fallback)?; + return Ok(CallToolResult::success(vec![Content::text(payload)])); } }; let crate::search_engine::FilteredSemanticOutcome { @@ -822,24 +974,15 @@ impl AicxMcpServer { } = filtered; let scanned = outcome.scanned; let retrieval_status = outcome.retrieval_status.clone(); - let mut results = outcome.results; - - if let Some(sort_order) = params.sort.as_deref() { - results.sort_by(|a, b| { - let t_a = a.timestamp.as_deref().unwrap_or(a.date.as_str()); - let t_b = b.timestamp.as_deref().unwrap_or(b.date.as_str()); - match sort_order { - "newest" => t_b.cmp(t_a), - "oldest" => t_a.cmp(t_b), - "score" => b.score.cmp(&a.score).then(t_b.cmp(t_a)), - _ => t_b.cmp(t_a), - } - }); - } else { - results.sort_by_key(|b| std::cmp::Reverse(b.score)); - } - - let results: Vec<_> = results.into_iter().take(limit).collect(); + // Shared finalize keeps MCP success ordering identical to the CLI and + // the fuzzy fallback. Kind is already pushed into the semantic query, + // so no kind retain is needed here. + let results = crate::search_engine::finalize_fuzzy_results( + outcome.results, + None, + params.sort.as_deref(), + limit, + ); let source_paths_verified = crate::oracle::verify_paths( results @@ -867,6 +1010,20 @@ impl AicxMcpServer { McpError::internal_error(format!("Serialize search JSON: {e}"), None) })?; let payload = inject_mcp_filter_pushdown_payload(&rendered, pushdown_diagnostic.as_ref())?; + // Hybrid results reflect the committed index snapshot, not a live + // freshness check; attach the honesty hint so callers verify pending + // chunks via `aicx index status` instead of assuming full freshness. + let payload = if let Some(ref retrieval_status) = retrieval_status { + crate::search_engine::inject_index_snapshot_hint( + &payload, + retrieval_status.source_chunk_count, + ) + .map_err(|e| { + McpError::internal_error(format!("Inject index_snapshot hint: {e}"), None) + })? + } else { + payload + }; Ok(CallToolResult::success(vec![Content::text(payload)])) } @@ -1344,15 +1501,29 @@ pub async fn run_stdio() -> anyhow::Result<()> { Ok(()) } -/// Run MCP server over streamable HTTP transport on given port with the given auth state. +/// Run MCP server over streamable HTTP transport on localhost with the given auth state. pub async fn run_http(port: u16, auth_config: AuthConfig) -> anyhow::Result<()> { + run_http_config(McpHttpConfig::localhost(port), auth_config).await +} + +/// Run MCP server over streamable HTTP transport on the given host/port with the given auth state. +pub async fn run_http_on(host: IpAddr, port: u16, auth_config: AuthConfig) -> anyhow::Result<()> { + run_http_config(McpHttpConfig::new(host, port), auth_config).await +} + +/// Run MCP server over streamable HTTP transport with explicit bind and Host validation config. +pub async fn run_http_config( + http_config: McpHttpConfig, + auth_config: AuthConfig, +) -> anyhow::Result<()> { tracing_subscriber::fmt() .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .with_writer(std::io::stderr) .try_init() .ok(); - let addr = std::net::SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), port); + let addr = SocketAddr::new(http_config.host, http_config.port); + let allowed_hosts = http_config.allowed_hosts_for_rmcp(); let auth_source_label = auth_config.source.describe(); let auth_enforced = auth_config.is_enforced(); @@ -1365,11 +1536,20 @@ pub async fn run_http(port: u16, auth_config: AuthConfig) -> anyhow::Result<()> auth_enabled = auth_enforced, auth_source = %auth_source_label, tools = ?MCP_TOOL_SURFACE, - port = port, + host = %http_config.host, + port = http_config.port, + allow_any_host = http_config.allow_any_host, + allowed_hosts = ?allowed_hosts, "mcp server starting (http)" ); - let config = rmcp::transport::streamable_http_server::StreamableHttpServerConfig::default(); + let config = if http_config.allow_any_host { + rmcp::transport::streamable_http_server::StreamableHttpServerConfig::default() + .disable_allowed_hosts() + } else { + rmcp::transport::streamable_http_server::StreamableHttpServerConfig::default() + .with_allowed_hosts(allowed_hosts.clone()) + }; let session_manager = configured_mcp_session_manager(); spawn_mcp_session_cleanup(session_manager.clone(), MCP_SESSION_SWEEP_INTERVAL); let service = rmcp::transport::streamable_http_server::StreamableHttpService::new( @@ -1395,6 +1575,11 @@ pub async fn run_http(port: u16, auth_config: AuthConfig) -> anyhow::Result<()> eprintln!("aicx MCP server running (streamable HTTP)"); eprintln!(" Endpoint: http://{addr}/mcp"); eprintln!(" Transport: Streamable HTTP (POST + GET /mcp)"); + if http_config.allow_any_host { + eprintln!(" Host validation: DISABLED (--allow-any-host)"); + } else { + eprintln!(" Host validation: enabled (allowed: {allowed_hosts:?})"); + } if auth_enforced { eprintln!(" Auth: enabled (source: {auth_source_label})"); } else { @@ -1419,12 +1604,12 @@ pub async fn run_sse(port: u16, auth_config: AuthConfig) -> anyhow::Result<()> { /// Run the selected MCP transport. Stdio bypasses HTTP auth (no network surface). pub async fn run_transport( transport: McpTransport, - port: u16, + http_config: McpHttpConfig, auth_config: AuthConfig, ) -> anyhow::Result<()> { match transport { McpTransport::Stdio => run_stdio().await, - McpTransport::Http => run_http(port, auth_config).await, + McpTransport::Http => run_http_config(http_config, auth_config).await, } } @@ -1472,15 +1657,18 @@ mod tests { use super::{ AicxMcpServer, AicxSessionManager, AicxSessionManagerError, MAX_SCORE_FILTER, MCP_EMBEDDER_NEGATIVE_TTL, MCP_SESSION_IDLE_TTL, MCP_SESSION_MAX_SESSIONS, - MCP_SESSION_SWEEP_INTERVAL, McpTransport, RankItem, RankResponse, SearchParams, - SteerResponse, background_refresh_args, build_mcp_semantic_filters, - configured_mcp_session_manager, inject_mcp_filter_pushdown_payload, parse_date_filter_mcp, - spawn_mcp_session_cleanup, validate_score_filter, validate_string_len, + MCP_SESSION_SWEEP_INTERVAL, McpHttpConfig, McpSemanticFallbackNotice, McpTransport, + RankItem, RankResponse, SearchParams, SteerResponse, background_refresh_args, + build_mcp_semantic_filters, configured_mcp_session_manager, + inject_mcp_filter_pushdown_payload, inject_mcp_semantic_fallback_payload, + parse_date_filter_mcp, spawn_mcp_session_cleanup, validate_score_filter, + validate_string_len, }; use crate::oracle::OracleStatus; use clap::ValueEnum as _; use rmcp::transport::streamable_http_server::session::SessionManager as _; use std::{ + net::{IpAddr, Ipv4Addr}, sync::Arc, time::{Duration, Instant}, }; @@ -1502,6 +1690,7 @@ mod tests { kind: None, slim: true, verbose: false, + strict_semantic: false, } } @@ -1651,6 +1840,15 @@ mod tests { assert!(params.score.is_none()); assert!(params.hours.is_none()); assert!(params.date.is_none()); + assert!(!params.strict_semantic); + } + + #[test] + fn search_params_can_opt_back_into_strict_semantic() { + let params: SearchParams = + serde_json::from_str(r#"{"query":"dashboard","strict_semantic":true}"#) + .expect("search params should parse"); + assert!(params.strict_semantic); } #[test] @@ -1723,6 +1921,33 @@ mod tests { assert_eq!(json["filter_pushdown"]["examined_cap_ratio"], 10); } + #[test] + fn mcp_search_payload_includes_semantic_fallback_diagnostic() { + let fallback = McpSemanticFallbackNotice { + kind: "index_not_built".to_string(), + reason: "index missing".to_string(), + recommendation: "run `aicx index`".to_string(), + }; + + let payload = inject_mcp_semantic_fallback_payload( + r#"{"oracle_status":{"backend":"filesystem_fuzzy"},"results":0,"items":[]}"#, + &fallback, + ) + .expect("fallback payload should inject"); + let json: serde_json::Value = + serde_json::from_str(&payload).expect("payload should stay valid JSON"); + + assert_eq!(json["results"], 0); + assert_eq!(json["semantic_fallback"]["used"], true); + assert_eq!(json["semantic_fallback"]["backend"], "filesystem_fuzzy"); + assert_eq!(json["semantic_fallback"]["kind"], "index_not_built"); + assert_eq!(json["semantic_fallback"]["reason"], "index missing"); + assert_eq!( + json["semantic_fallback"]["recommendation"], + "run `aicx index`" + ); + } + #[test] fn mcp_search_payload_invalid_json_maps_to_internal_error() { let diagnostic = crate::search_engine::FilterPushdownDiagnostic { @@ -1919,4 +2144,25 @@ mod tests { assert_eq!(McpTransport::from_str("http", true), Ok(McpTransport::Http)); assert_eq!(McpTransport::from_str("sse", true), Ok(McpTransport::Http)); } + + #[test] + fn mcp_http_config_keeps_loopback_and_adds_remote_allowed_hosts() { + let mut config = McpHttpConfig::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 9000); + config.allowed_hosts = vec![ + "mcp.example.internal".to_string(), + "127.0.0.1".to_string(), + "mcp.example.internal".to_string(), + ]; + + assert_eq!( + config.allowed_hosts_for_rmcp(), + vec![ + "localhost".to_string(), + "127.0.0.1".to_string(), + "::1".to_string(), + "0.0.0.0".to_string(), + "mcp.example.internal".to_string(), + ] + ); + } } diff --git a/src/search_engine.rs b/src/search_engine.rs index 040a9a7..ad66c2f 100644 --- a/src/search_engine.rs +++ b/src/search_engine.rs @@ -1351,10 +1351,248 @@ pub fn render_semantic_status_line( ) } +/// Bounded fetch limit for fuzzy retrieval. When post-filters are active we +/// over-fetch up to `FILTER_EXAMINED_CAP_RATIO`× the requested limit (floored +/// at `FILTER_EXAMINED_CAP_MIN`) so inside-window matches that sit below the +/// raw top-N still surface instead of being lost to a silent-empty result. +pub fn fuzzy_fetch_limit(user_limit: usize, filters_active: bool) -> usize { + if filters_active { + user_limit + .saturating_mul(FILTER_EXAMINED_CAP_RATIO) + .max(FILTER_EXAMINED_CAP_MIN) + } else { + user_limit + } +} + +/// Apply the shared score/agent/date/hours post-filters to a fuzzy result set +/// in place. Date bounds win over the `--hours` cutoff to match legacy +/// precedence. +fn apply_fuzzy_post_filters( + results: &mut Vec, + post_filters: &SemanticSearchFilters, +) { + if let Some(min_score) = post_filters.score_min { + results.retain(|r| r.score >= min_score); + } + if let Some(ref agent_filter) = post_filters.agent { + results.retain(|r| r.agent == *agent_filter); + } + if post_filters.date_lo.is_some() || post_filters.date_hi.is_some() { + let lo = post_filters.date_lo.as_deref(); + let hi = post_filters.date_hi.as_deref(); + results.retain(|r| { + lo.is_none_or(|lo| r.date.as_str() >= lo) && hi.is_none_or(|hi| r.date.as_str() <= hi) + }); + } else if let Some(ref cutoff) = post_filters.hours_cutoff { + let cutoff = cutoff.as_str(); + results.retain(|r| r.date.as_str() >= cutoff); + } +} + +/// Shared fuzzy retrieval + post-filter primitive. Both the CLI `aicx search` +/// fallback and the MCP `aicx_search` fallback route through this so the two +/// surfaces cannot drift in *what* they retrieve and filter. Fetches a bounded +/// pool then applies the post-filters; ordering/truncation is the caller's job +/// via [`finalize_fuzzy_results`]. +pub fn fuzzy_search_with_post_filters( + store_root: &Path, + query: &str, + limit: usize, + project_scopes: &[Option<&str>], + frame_kind: Option, + post_filters: &SemanticSearchFilters, +) -> anyhow::Result<(Vec, usize)> { + let fetch_limit = fuzzy_fetch_limit(limit, post_filters.is_active()); + let (mut results, scanned) = crate::rank::fuzzy_search_store( + store_root, + query, + fetch_limit, + project_scopes, + frame_kind, + )?; + apply_fuzzy_post_filters(&mut results, post_filters); + Ok((results, scanned)) +} + +/// Shared "finalize" step for fuzzy/semantic result sets: optional kind retain, +/// then sort, then truncate to `limit`. Both CLI and MCP search call this so +/// ordering and limit semantics stay byte-identical across surfaces. `sort` +/// accepts `"newest"` / `"oldest"` / `"score"`; `None` (and any unknown token) +/// falls back to descending score. +pub fn finalize_fuzzy_results( + mut results: Vec, + kind_filter: Option<&str>, + sort: Option<&str>, + limit: usize, +) -> Vec { + if let Some(kind_filter) = kind_filter { + results.retain(|r| r.kind == kind_filter); + } + match sort { + Some(sort_order) => results.sort_by(|a, b| { + let t_a = a.timestamp.as_deref().unwrap_or(a.date.as_str()); + let t_b = b.timestamp.as_deref().unwrap_or(b.date.as_str()); + match sort_order { + "newest" => t_b.cmp(t_a), + "oldest" => t_a.cmp(t_b), + "score" => b.score.cmp(&a.score).then(t_b.cmp(t_a)), + _ => t_b.cmp(t_a), + } + }), + None => results.sort_by_key(|b| std::cmp::Reverse(b.score)), + } + results.into_iter().take(limit).collect() +} + +/// Append an `index_snapshot` honesty hint to a rendered hybrid search payload. +/// +/// Hybrid results reflect the committed index *manifest* (a snapshot of the +/// corpus at index-build time), not a live freshness check — that check scans +/// canonical chunks and is intentionally kept off the search hot path. Without +/// this hint a `hybrid_rrf` result reads as "fully fresh"; the hint keeps the +/// surface honest by pointing callers at `aicx index status` to confirm there +/// are no pending (un-embedded) chunks. Shared by CLI and MCP so both surfaces +/// emit the identical honesty signal. +pub fn inject_index_snapshot_hint(rendered: &str, source_chunks: usize) -> anyhow::Result { + let mut value: serde_json::Value = serde_json::from_str(rendered).map_err(|e| { + anyhow::anyhow!("parse rendered search payload for index_snapshot hint: {e}") + })?; + if let Some(obj) = value.as_object_mut() { + obj.insert( + "index_snapshot".to_string(), + serde_json::json!({ + "source_chunks": source_chunks, + "freshness_verified": false, + "note": "results reflect the committed index snapshot; run `aicx index status` to confirm there are no pending (un-embedded) chunks", + }), + ); + } + serde_json::to_string(&value) + .map_err(|e| anyhow::anyhow!("serialize search payload with index_snapshot hint: {e}")) +} + #[cfg(test)] mod tests { use super::*; use std::ffi::OsString; + + fn fuzzy(score: u8, kind: &str, date: &str, ts: Option<&str>) -> crate::rank::FuzzyResult { + crate::rank::FuzzyResult { + file: "f".to_string(), + path: "p".to_string(), + project: "proj".to_string(), + kind: kind.to_string(), + frame_kind: None, + agent: "claude".to_string(), + date: date.to_string(), + timestamp: ts.map(|s| s.to_string()), + score, + label: "l".to_string(), + density: 0.0, + matched_lines: Vec::new(), + session_id: None, + cwd: None, + } + } + + #[test] + fn fuzzy_fetch_limit_over_fetches_only_when_filters_active() { + // Inactive filters: fetch exactly the requested limit. + assert_eq!(fuzzy_fetch_limit(5, false), 5); + // Active filters: over-fetch CAP_RATIO× the limit, floored at CAP_MIN. + assert_eq!( + fuzzy_fetch_limit(20, true), + 20 * FILTER_EXAMINED_CAP_RATIO // 200, above the floor + ); + assert_eq!(fuzzy_fetch_limit(1, true), FILTER_EXAMINED_CAP_MIN); + } + + #[test] + fn finalize_fuzzy_results_defaults_to_descending_score() { + let input = vec![ + fuzzy(10, "decision", "2026-01-01", None), + fuzzy(50, "decision", "2026-01-01", None), + fuzzy(30, "decision", "2026-01-01", None), + ]; + let out = finalize_fuzzy_results(input, None, None, 10); + assert_eq!( + out.iter().map(|r| r.score).collect::>(), + vec![50, 30, 10] + ); + } + + #[test] + fn finalize_fuzzy_results_honors_newest_and_oldest() { + let input = vec![ + fuzzy(10, "decision", "2026-01-01", Some("2026-01-01T00:00:00Z")), + fuzzy(10, "decision", "2026-01-03", Some("2026-01-03T00:00:00Z")), + fuzzy(10, "decision", "2026-01-02", Some("2026-01-02T00:00:00Z")), + ]; + let newest = finalize_fuzzy_results(input.clone(), None, Some("newest"), 10); + assert_eq!( + newest.iter().map(|r| r.date.clone()).collect::>(), + vec!["2026-01-03", "2026-01-02", "2026-01-01"] + ); + let oldest = finalize_fuzzy_results(input, None, Some("oldest"), 10); + assert_eq!( + oldest.iter().map(|r| r.date.clone()).collect::>(), + vec!["2026-01-01", "2026-01-02", "2026-01-03"] + ); + } + + #[test] + fn finalize_fuzzy_results_retains_kind_then_truncates() { + let input = vec![ + fuzzy(90, "decision", "2026-01-01", None), + fuzzy(80, "task", "2026-01-01", None), + fuzzy(70, "decision", "2026-01-01", None), + fuzzy(60, "decision", "2026-01-01", None), + ]; + let out = finalize_fuzzy_results(input, Some("decision"), None, 2); + assert_eq!(out.len(), 2, "kind retain then truncate to limit"); + assert!(out.iter().all(|r| r.kind == "decision")); + assert_eq!( + out.iter().map(|r| r.score).collect::>(), + vec![90, 70] + ); + } + + #[test] + fn finalize_fuzzy_results_unknown_sort_token_falls_back_to_newest() { + // The shared primitive treats any unknown sort token as "newest" so a + // caller passing an out-of-contract string degrades gracefully instead + // of panicking or diverging from the MCP surface. + let input = vec![ + fuzzy(10, "decision", "2026-01-01", Some("2026-01-01T00:00:00Z")), + fuzzy(10, "decision", "2026-01-02", Some("2026-01-02T00:00:00Z")), + ]; + let out = finalize_fuzzy_results(input, None, Some("nonsense"), 10); + assert_eq!( + out.iter().map(|r| r.date.clone()).collect::>(), + vec!["2026-01-02", "2026-01-01"] + ); + } + + #[test] + fn inject_index_snapshot_hint_marks_freshness_unverified() { + let payload = inject_index_snapshot_hint( + r#"{"oracle_status":{"backend":"hybrid_rrf"},"results":2,"items":[]}"#, + 11906, + ) + .expect("index_snapshot hint should inject"); + let json: serde_json::Value = + serde_json::from_str(&payload).expect("payload stays valid JSON"); + assert_eq!(json["results"], 2); + assert_eq!(json["index_snapshot"]["source_chunks"], 11906); + assert_eq!(json["index_snapshot"]["freshness_verified"], false); + assert!( + json["index_snapshot"]["note"] + .as_str() + .unwrap() + .contains("aicx index status") + ); + } use std::fs; use std::path::{Path, PathBuf}; use std::sync::{Mutex, MutexGuard, OnceLock}; From 482064fcb56416007d80ac41a3dd670a23d05c62 Mon Sep 17 00:00:00 2001 From: m-szymanska Date: Sat, 27 Jun 2026 22:48:01 -0700 Subject: [PATCH 3/7] docs: MCP search contract, host validation, token cascade, index snapshot Document semantic-first search + fallback, --host/--allowed-host (bind host vs Host header), the single HTTP auth-token resolution cascade, and the index_snapshot freshness signal. Public-safe examples only (mcp.example.internal, localhost). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/ARCHITECTURE.md | 18 +++++++++---- docs/COMMANDS.md | 26 +++++++++++++++++- docs/ORACLE_CORPUS.md | 18 ++++++++++--- docs/install-paths.md | 61 ++++++++++++++++++++++++++++++++++++++----- 4 files changed, 106 insertions(+), 17 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 69f4bd2..bb331ec 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -130,15 +130,23 @@ Frontmatter is not just telemetry — it is part of the steering and selective r ## MCP Surface (`src/mcp.rs`) -The MCP server exposes four tools via stdio and streamable HTTP transports: +The MCP server exposes six tools via stdio and streamable HTTP transports: -- `aicx_search` — canonical-store filesystem fuzzy search with quality scoring and `oracle_status`; this is not semantic retrieval and is not safe for Loctree scope narrowing until callers read the canonical chunks -- `aicx_read` — read one canonical chunk by path, file name, or compact reference; this is the direct re-entry step after search, refs, steer, or dashboard discovery -- `aicx_rank` — rank chunks by signal density for a project as compact JSON -- `aicx_steer` — retrieve chunks by steering metadata (run_id, prompt_id, agent, kind, project, date) using sidecar data; returns `oracle_status` for the rebuildable metadata index and is safe for Loctree metadata narrowing only when source paths verify +- `aicx_search` — semantic-first search over the canonical corpus. Ready indexes return `hybrid_rrf` oracle status and are safe for Loctree scope narrowing. Missing semantic preconditions degrade to filesystem fuzzy with an explicit `semantic_fallback` payload; callers that need fail-fast semantics pass `strict_semantic = true`. +- `aicx_read` — read one canonical chunk by path, file name, or compact reference; this is the direct re-entry step after search, refs, steer, or dashboard discovery. +- `aicx_rank` — rank chunks by signal density for a project as compact JSON. +- `aicx_steer` — retrieve chunks by steering metadata (run_id, prompt_id, agent, kind, project, date) using sidecar data; returns `oracle_status` for the rebuildable metadata index and is safe for Loctree metadata narrowing only when source paths verify. +- `aicx_intents` — extract intent/outcome/decision/task records from canonical chunks. +- `aicx_index_status` — report the sessions -> chunks -> semantic-index pipeline for a project bucket, including readiness, backend, row count, and artifact paths. Recency filtering in `aicx_search` and `aicx_steer` uses canonical chunk dates from the store layout, not filesystem `mtime` accidents. +The streamable HTTP transport binds to `127.0.0.1` by default and keeps rmcp's +loopback-only `Host` validation. Operators can explicitly pass `--host ` to +listen on another interface and `--allowed-host ` for each remote +hostname/IP clients will use. `--allow-any-host` disables that DNS-rebinding +guard and is intended only for trusted networks. + ## Security Model (Pragmatic) Two mechanisms protect your machine and your data: diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 0ab5afe..46e32cf 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -699,7 +699,10 @@ aicx state --info Run `aicx` as an MCP server (stdio or streamable HTTP transport). Exposes search, read, steer, intents, and rank tools over MCP for agent retrieval. -`aicx_search` is semantic and fails fast when the index is not ready. +`aicx_search` is semantic-first and uses the same filtered retrieval primitive +as CLI `aicx search`. When the semantic index or embedder is not ready it +returns filesystem-fuzzy results with an explicit `semantic_fallback` payload; +pass `strict_semantic = true` to get fail-fast semantic errors. `aicx_steer`, `aicx_intents`, and `aicx_rank` query the canonical corpus on disk and return grounded source paths or chunk references. `aicx_read` pulls the actual chunk content by path, file name, or compact @@ -711,14 +714,35 @@ aicx serve [OPTIONS] Options: - `--transport ` transport (default: `stdio`; legacy alias `sse` is still accepted) +- `--host ` streamable HTTP bind host (default: `127.0.0.1`) - `--port ` streamable HTTP port (default: `8044`) +- `--allowed-host ` allowed inbound HTTP `Host` header for streamable HTTP clients; repeat for remote hostnames/IPs +- `--allow-any-host` disable HTTP `Host` validation; only use on trusted networks +- `--auth-token ` explicit Bearer token for HTTP transport +- `--no-require-auth` disable HTTP auth for local debugging only Example: ```bash aicx serve --transport http --port 8044 +aicx serve --transport http --host 0.0.0.0 --allowed-host mcp.example.internal --port 8044 +aicx-mcp --transport http --host 127.0.0.1 --port 8044 ``` +### HTTP auth token resolution + +The HTTP transport resolves a single Bearer token from one canonical cascade +(first match wins): + +1. `--auth-token ` (explicit CLI override) +2. `AICX_HTTP_AUTH_TOKEN` environment variable +3. `/auth-token` file — honors `$AICX_HOME`; defaults to `~/.aicx/auth-token` +4. otherwise a token is generated and persisted to that file (mode `0600` on Unix) + +`--no-require-auth` skips the cascade entirely and serves without auth (local +debugging only). Run `aicx doctor` to see which source is active — it reports +the token *source*, never the token value. + ## `aicx init` (Retired) `aicx init` has been retired. Context initialisation is now handled by `/vc-init` inside Claude Code. diff --git a/docs/ORACLE_CORPUS.md b/docs/ORACLE_CORPUS.md index 1c1e8cf..e80315e 100644 --- a/docs/ORACLE_CORPUS.md +++ b/docs/ORACLE_CORPUS.md @@ -17,10 +17,20 @@ embedding surface is a derived view that must be rebuildable from that corpus. ## Operator Surfaces -- `aicx search --json` and MCP `aicx_search` return `oracle_status.backend = - filesystem_fuzzy`, `index_kind = none`, and a non-null `fallback_reason`. - Treat these results as routing evidence only. Loctree must read the canonical - chunks before trusting scope. +- `aicx search --json` and MCP `aicx_search` are semantic-first. When the + semantic index and embedder are ready they return `oracle_status.backend = + hybrid_rrf`, `index_kind = onion_content`, and `loctree_scope_safe = true`. + This is the preferred retrieval surface for humans and agents. Hybrid results + also carry an `index_snapshot` payload (`freshness_verified = false`, + `source_chunks = N`): the result reflects the committed index manifest, not a + live freshness check. To confirm there are no pending (un-embedded) chunks, + run `aicx index status` (or `aicx doctor`, check `index_freshness`) — the + search hot path deliberately does not pay for that scan. +- If semantic preconditions are missing, `aicx search --json` and MCP + `aicx_search` degrade to canonical-store filesystem fuzzy search and include + an explicit `semantic_fallback` payload. Treat fallback results as routing + evidence only; Loctree must read the canonical chunks before trusting scope. + MCP clients that need fail-fast behavior can pass `strict_semantic = true`. - `aicx intents --emit json` and MCP `aicx_intents` return `backend = canonical_corpus` and `index_kind = canonical_chunks`. This is canonical intent evidence, not semantic similarity. diff --git a/docs/install-paths.md b/docs/install-paths.md index 77ea085..d7bbcc2 100644 --- a/docs/install-paths.md +++ b/docs/install-paths.md @@ -1,7 +1,7 @@ # AICX Install Paths This document maps the supported binary install channels and the shadow checks -that keep `PATH` from resolving an older `aicx`. +that keep `PATH` from resolving older `aicx` or `aicx-mcp` binaries. ## Install Surface @@ -37,15 +37,16 @@ npm @loctree/aicx ## Pre-install Scan -Shell installers run a preflight inventory: +Shell installers run a preflight inventory for both binaries: ```bash which -a aicx +which -a aicx-mcp ``` Each path is probed with `--version` when executable. If multiple binaries are -visible, or the current `PATH` winner is not the target install path, interactive -installs ask for confirmation. Automation can set: +visible, or the current `PATH` winner is not the target install path, +interactive installs ask for confirmation. Automation can set: ```bash AICX_INSTALL_FORCE=1 bash install.sh @@ -72,17 +73,63 @@ because the installer cannot prove that removal is safe. ## Post-install Sanity -After shell installs, `install.sh` compares: +After shell installs, `install.sh` compares both runtime entry points: ```bash /aicx --version command -v aicx aicx --version +/aicx-mcp --version +command -v aicx-mcp +aicx-mcp --version ``` If the versions or paths differ, the installer prints a warning with the target -path, the `PATH`-resolved path, and all other `which -a aicx` entries. The fix is -to move the target directory earlier in `PATH` or remove the older channel. +path, the `PATH`-resolved path, and all other `which -a` entries for that +binary. The fix is to move the target directory earlier in `PATH` or remove the +older channel. + +## MCP Runtime Drift + +The CLI and MCP server are shipped as one versioned pair. Any long-running MCP +service, launchd unit, systemd unit, or hand-written wrapper must point at the +same installed `aicx-mcp` that the installer verifies on `PATH`. A copied +service-local binary can keep answering MCP health checks while running older +search behavior. + +`aicx doctor` reports this drift class directly: the `binary_pair` check +compares the running CLI against the `aicx-mcp` resolved on `PATH`, `aicx_home` +shows which `AICX_HOME` was resolved (and whether `store/` + `indexed/` live +there), and `http_auth_token` shows where the HTTP token resolves from without +printing its value. + +Minimum parity smoke after updating a service: + +```bash +aicx --version +aicx-mcp --version +aicx-mcp --help | grep -- "--host" +aicx doctor --format json # inspect binary_pair / aicx_home / http_auth_token +aicx index status --json +aicx search "operator decision" --json --limit 1 +``` + +For streamable HTTP MCP, also call `aicx_index_status` and `aicx_search` through +the client transport and compare the reported `indexed_count`, `readiness`, and +`oracle_status.backend` with the CLI output. `aicx_search` should report +`hybrid_rrf` when the same CLI search does; if it reports `content_semantic` or +silent zero results while CLI returns `hybrid_rrf`, the service is stale or +running a different binary/config. + +Remote MCP services that bind outside loopback also need HTTP `Host` validation +configured for the client-facing hostname/IP: + +```bash +aicx-mcp --transport http --host 0.0.0.0 --allowed-host mcp.example.internal --port 8044 +``` + +Without a matching `--allowed-host`, rmcp rejects the request before MCP tools +run and the client sees `403 Forbidden: Host header is not allowed`. ## npm Opt-in Replacement From d26452ec93a7d5c0db76492cc709325a1835b976 Mon Sep 17 00:00:00 2001 From: m-szymanska Date: Sat, 27 Jun 2026 23:59:12 -0700 Subject: [PATCH 4/7] test: prove CLI/MCP search parity end-to-end Drive the real aicx binary (fuzzy path) as a subprocess and reproduce the exact public retrieval+render path that MCP aicx_search uses for its fuzzy fallback in-process, then assert the rendered items are identical. Closes the North Star gap: same query + same store => same results on both surfaces. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/cli_mcp_search_parity.rs | 196 +++++++++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 tests/cli_mcp_search_parity.rs diff --git a/tests/cli_mcp_search_parity.rs b/tests/cli_mcp_search_parity.rs new file mode 100644 index 0000000..7bf6abb --- /dev/null +++ b/tests/cli_mcp_search_parity.rs @@ -0,0 +1,196 @@ +//! North Star parity: a human typing `aicx search ` and an agent calling +//! MCP `aicx_search` for the same query must get the same results. +//! +//! Both surfaces share one retrieval+render path. The MCP `aicx_search` fuzzy +//! fallback is literally: +//! search_engine::fuzzy_search_with_post_filters +//! -> search_engine::finalize_fuzzy_results +//! -> rank::search_oracle_status + rank::render_search_json_with_oracle +//! (see `render_mcp_fuzzy_fallback_payload` in src/mcp.rs). The CLI fuzzy path +//! (`aicx search --no-semantic`) routes through the exact same functions. +//! +//! This test drives the real CLI binary as a subprocess and reproduces the MCP +//! render path in-process, then asserts the rendered `items` are byte-identical. +//! `aicx_search`'s `search` method is private to the crate, so the public +//! retrieval/render primitives are the faithful, stable stand-in for the MCP +//! surface here; an in-crate unit test additionally exercises the live tool. + +use serde_json::Value; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::OnceLock; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn unique_test_dir(name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "aicx-parity-{name}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time before unix epoch") + .as_nanos() + )) +} + +fn write_file(path: &Path, content: &str) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create parent directories"); + } + fs::write(path, content).expect("write file"); +} + +fn current_profile_dir() -> PathBuf { + let test_exe = std::env::current_exe().expect("resolve current test executable"); + test_exe + .parent() + .and_then(Path::parent) + .expect("resolve cargo profile dir") + .to_path_buf() +} + +fn ensure_aicx_binary_exists() -> PathBuf { + static BIN_PATH: OnceLock = OnceLock::new(); + BIN_PATH + .get_or_init(|| { + if let Some(env_path) = std::env::var_os("CARGO_BIN_EXE_aicx").map(PathBuf::from) + && env_path.is_file() + { + return env_path; + } + let mut fallback = current_profile_dir().join("aicx"); + if cfg!(windows) { + fallback.set_extension("exe"); + } + if fallback.is_file() { + return fallback; + } + let cargo = std::env::var_os("CARGO") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("cargo")); + let output = Command::new(&cargo) + .args(["build", "--bin", "aicx"]) + .current_dir(env!("CARGO_MANIFEST_DIR")) + .output() + .expect("build fallback aicx binary"); + assert!( + output.status.success(), + "fallback cargo build --bin aicx failed\nstderr:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + fallback + }) + .clone() +} + +/// Build a small canonical store fixture under `/.aicx/store/...`. +fn build_store_fixture(aicx_home: &Path) { + let base = aicx_home + .join("store") + .join("vetcoders") + .join("aicx") + .join("2026_0601") + .join("reports") + .join("codex"); + write_file( + &base.join("2026_0601_codex_sess-p1_001.md"), + "Decision: the remote backend is an optional accelerator for deployment, not a requirement.", + ); + write_file( + &base.join("2026_0601_codex_sess-p2_001.md"), + "Note: local-first deployment search must work even when the backend is unreachable.", + ); + write_file( + &base.join("2026_0601_codex_sess-p3_001.md"), + "Unrelated chunk about installer drift and binary pairs.", + ); + write_file( + &base.join("2026_0601_codex_sess-p4_001.md"), + "Deployment runtime hosts the hybrid index; a local node mirrors a subset.", + ); +} + +#[test] +fn cli_and_mcp_render_identical_search_items() { + const QUERY: &str = "deployment"; + const LIMIT: usize = 10; + + let root = unique_test_dir("identical-items"); + let home = root.join("home"); + let aicx_home = home.join(".aicx"); + build_store_fixture(&aicx_home); + + // --- CLI surface: real binary, fuzzy path (--no-semantic), JSON output. --- + let output = Command::new(ensure_aicx_binary_exists()) + .args([ + "search", + QUERY, + "--json", + "--no-semantic", + "--limit", + &LIMIT.to_string(), + ]) + .env("HOME", &home) + .env("USERPROFILE", &home) + .env("AICX_ALLOW_TMP", "1") + .env_remove("AICX_HOME") + .output() + .expect("run aicx search"); + assert!( + output.status.success(), + "cli search failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let cli_json: Value = + serde_json::from_slice(&output.stdout).expect("cli search must emit valid JSON"); + let cli_items = cli_json + .get("items") + .cloned() + .expect("cli payload has items"); + + // --- MCP surface: the exact public retrieval+render path aicx_search uses + // for its fuzzy fallback, run in-process against the same store. --- + let store_root = aicx_home.clone(); + let scopes: Vec> = vec![None]; + let post_filters = aicx::search_engine::SemanticSearchFilters { + agent: None, + score_min: None, + date_lo: None, + date_hi: None, + hours_cutoff: None, + }; + let (results, scanned) = aicx::search_engine::fuzzy_search_with_post_filters( + &store_root, + QUERY, + LIMIT, + &scopes, + None, + &post_filters, + ) + .expect("mcp-path fuzzy retrieval"); + let results = aicx::search_engine::finalize_fuzzy_results(results, None, None, LIMIT); + let oracle_status = aicx::rank::search_oracle_status(&store_root, &results, scanned); + let rendered = + aicx::rank::render_search_json_with_oracle(&store_root, &results, scanned, oracle_status) + .expect("mcp-path render"); + let mcp_json: Value = + serde_json::from_str(&rendered).expect("mcp-path payload must be valid JSON"); + let mcp_items = mcp_json + .get("items") + .cloned() + .expect("mcp payload has items"); + + // North Star: same query, same store, same items on both surfaces. + assert_eq!( + cli_items, mcp_items, + "CLI and MCP search must return identical items for the same query" + ); + let item_count = cli_items.as_array().map(|a| a.len()).unwrap_or(0); + assert!( + item_count >= 3, + "fixture has 3 chunks mentioning the query; got {item_count} items" + ); + + let _ = fs::remove_dir_all(&root); +} From 81e6346e0af99da6a9801cf1fb45331f0c2c3336 Mon Sep 17 00:00:00 2001 From: m-szymanska Date: Sun, 28 Jun 2026 00:08:37 -0700 Subject: [PATCH 5/7] fix(doctor): parse aicx-mcp version by first numeric token rsplit-on-whitespace took the last token, which a trailing build suffix ('0.9.4 (abc)') would corrupt. Select the first token starting with a digit instead so the CLI/MCP version comparison stays correct. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/doctor/checks.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/doctor/checks.rs b/src/doctor/checks.rs index c84d489..2bb35d0 100644 --- a/src/doctor/checks.rs +++ b/src/doctor/checks.rs @@ -287,9 +287,13 @@ pub(crate) fn check_binary_pair() -> CheckResult { ), }, Some(version_line) => { + // `aicx-mcp --version` prints "aicx-mcp "; pick the first + // token that starts with a digit so a trailing build suffix + // ("0.9.4 (abc)") or a different binary-name prefix cannot corrupt + // the comparison. let mcp_version = version_line - .rsplit(char::is_whitespace) - .next() + .split_whitespace() + .find(|token| token.chars().next().is_some_and(|c| c.is_ascii_digit())) .unwrap_or(version_line.as_str()); if mcp_version == cli_version { CheckResult { From fb66b2bdf11cf7fbc76b372b043e3e8737e09094 Mon Sep 17 00:00:00 2001 From: m-szymanska Date: Sun, 28 Jun 2026 19:45:02 -0700 Subject: [PATCH 6/7] docs(search): correct finalize_fuzzy_results sort fallback docstring Unknown sort tokens fall back to newest (timestamp/date desc), not descending score. Align the doc with the implementation and unit test. PR #24 review (copilot-pull-request-reviewer) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/search_engine.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/search_engine.rs b/src/search_engine.rs index ad66c2f..ff03fce 100644 --- a/src/search_engine.rs +++ b/src/search_engine.rs @@ -1418,8 +1418,9 @@ pub fn fuzzy_search_with_post_filters( /// Shared "finalize" step for fuzzy/semantic result sets: optional kind retain, /// then sort, then truncate to `limit`. Both CLI and MCP search call this so /// ordering and limit semantics stay byte-identical across surfaces. `sort` -/// accepts `"newest"` / `"oldest"` / `"score"`; `None` (and any unknown token) -/// falls back to descending score. +/// accepts `"newest"` / `"oldest"` / `"score"`. `None` falls back to descending +/// score; any unknown token falls back to `"newest"` (timestamp/date +/// descending), matching the unit test below. pub fn finalize_fuzzy_results( mut results: Vec, kind_filter: Option<&str>, From 593625b628ed9554d3b248e611c827c2f6e6bf70 Mon Sep 17 00:00:00 2001 From: m-szymanska Date: Sun, 28 Jun 2026 19:45:44 -0700 Subject: [PATCH 7/7] fix(mcp): defensive kind retain on semantic-success for CLI parity MCP semantic-success now passes kind_filter_dir to finalize_fuzzy_results instead of None, matching the CLI tail. Kind is already pushed into the semantic query; the retain is a defensive guard so an off-kind hit cannot slip through if the semantic backend regresses, keeping CLI/MCP parity. PR #24 review (copilot-pull-request-reviewer) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/mcp.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/mcp.rs b/src/mcp.rs index 8107260..eb51a19 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -976,10 +976,11 @@ impl AicxMcpServer { let retrieval_status = outcome.retrieval_status.clone(); // Shared finalize keeps MCP success ordering identical to the CLI and // the fuzzy fallback. Kind is already pushed into the semantic query, - // so no kind retain is needed here. + // but we still pass it as a defensive retain (matching the CLI tail) so + // an off-kind hit cannot slip through if the semantic backend regresses. let results = crate::search_engine::finalize_fuzzy_results( outcome.results, - None, + kind_filter_dir, params.sort.as_deref(), limit, );