diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index b27ab7b9..3a13867d 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -154,6 +154,40 @@ jobs: echo "=== Package contents for ${{ matrix.platform }} ===" ls -la "$PKG_DIR/" + - name: Verify Linux native dependencies + if: matrix.platform == 'linux-x64' + run: | + set -euo pipefail + + PKG_DIR="packages/${{ matrix.platform }}/${{ matrix.pkg_name }}/lib" + echo "=== Verifying Linux native dependencies in $PKG_DIR ===" + + found=0 + for sofile in "$PKG_DIR"/*.so "$PKG_DIR"/*.so.*; do + [ -L "$sofile" ] && continue + [ -f "$sofile" ] || continue + found=1 + echo "--- $sofile" + readelf -d "$sofile" | grep -E 'RUNPATH|RPATH' | grep -F '$ORIGIN' + LD_LIBRARY_PATH="$PKG_DIR:${LD_LIBRARY_PATH:-}" ldd "$sofile" | tee /tmp/ldd.out + missing="$(awk '/not found/ {print $1}' /tmp/ldd.out | grep -Ev '^(libssl\.so|libcrypto\.so)' || true)" + if [ -n "$missing" ]; then + echo "$missing" + echo "Missing shared dependency for $sofile" + exit 1 + fi + done + + if find "$PKG_DIR" -maxdepth 1 -type f \( -name 'libssl.so*' -o -name 'libcrypto.so*' \) | grep .; then + echo "OpenSSL libraries must remain system-provided, not bundled." + exit 1 + fi + + if [ "$found" -ne 1 ]; then + echo "No Linux shared libraries found in $PKG_DIR" + exit 1 + fi + - name: Update package version run: | cd packages/${{ matrix.platform }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dc4f370..37f07dfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- `GopherAgent.run()` now raises `AgentError` when the native library returns a null response instead of returning a `"No response for query"` string. + ## [0.1.2] - 2026-03-12 diff --git a/README.md b/README.md index 5da09b37..e392010f 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,18 @@ Python SDK for gopher-mcp-python, providing AI agent orchestration with native C ## Requirements - Python 3.8 or higher +- `venv` and `pip` for PyPI installation, examples, and development workflows - Native gopher-mcp-python library (built from source) +- On Linux, system OpenSSL runtime libraries (`libssl` / `libcrypto`) from + your distribution. Native wheels do not bundle OpenSSL, so OS security + updates remain effective. + +On Debian/Ubuntu, install the Python venv and pip packages before using the +PyPI install path, running examples, or setting up development dependencies: + +```bash +sudo apt-get install python3 python3-venv python3-pip +``` ## Installation @@ -34,7 +45,7 @@ cd gopher-mcp-python 3. Install the Python package: ```bash -pip install -e . +python3 -m pip install -e . ``` ## Quick Start diff --git a/build.sh b/build.sh index dbc6b9b1..8d2bd9c1 100755 --- a/build.sh +++ b/build.sh @@ -6,18 +6,128 @@ set -e # Exit on error RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' +CYAN='\033[0;36m' NC='\033[0m' # No Color # Get the script directory SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" NATIVE_DIR="${SCRIPT_DIR}/third_party/gopher-orch" BUILD_DIR="${NATIVE_DIR}/build" -SOURCE_STAMP_FILE="${SCRIPT_DIR}/native/.gopher-orch-source" +NATIVE_ROOT="${SCRIPT_DIR}/native" +ACTIVE_NATIVE_DIR="${NATIVE_ROOT}/current" +LINUX_X64_DOCKERFILE="${SCRIPT_DIR}/scripts/docker/Dockerfile.linux-x64-ubuntu20" +UBUNTU_20_04_IMAGE="ubuntu:20.04@sha256:8feb4d8ca5354def3d8fce243717141ce31e2c428701f6682bd2fafe15388214" +REQUESTED_TARGET="" +RESOLVED_TARGET="" +TARGET_NATIVE_DIR="" +SOURCE_STAMP_FILE="" +RUN_BUILD_AFTER_CLEAN=0 + +usage() { + cat </dev/null 2>&1; then + RECORDED_COMMIT="$(git ls-tree HEAD third_party/gopher-orch | awk '{print $3}')" + CURRENT_COMMIT="$(git -C "${NATIVE_DIR}" rev-parse HEAD 2>/dev/null || true)" + SUBMODULE_STATUS="$(git -C "${NATIVE_DIR}" status --short 2>/dev/null || true)" + + if [ -n "${SUBMODULE_STATUS}" ] || { [ -n "${RECORDED_COMMIT}" ] && [ "${CURRENT_COMMIT}" != "${RECORDED_COMMIT}" ]; }; then + echo -e "${YELLOW} Using existing local gopher-orch checkout:${NC}" + echo -e "${YELLOW} recorded: ${RECORDED_COMMIT:-}${NC}" + echo -e "${YELLOW} current: ${CURRENT_COMMIT:-}${NC}" + if [ -n "${SUBMODULE_STATUS}" ]; then + echo -e "${YELLOW} local changes present; not running git submodule update for gopher-orch.${NC}" + fi + SKIP_GOPHER_ORCH_UPDATE=1 + else + SKIP_GOPHER_ORCH_UPDATE=0 + fi +else + SKIP_GOPHER_ORCH_UPDATE=0 +fi + # Check if submodule directory exists but is empty/broken (missing CMakeLists.txt) if [ -d "${NATIVE_DIR}" ] && [ ! -f "${NATIVE_DIR}/CMakeLists.txt" ]; then echo -e "${YELLOW} Submodule directory exists but appears incomplete, reinitializing...${NC}" @@ -74,11 +217,13 @@ if [ "${GOPHER_ORCH_TRACK_REMOTE:-}" = "1" ]; then SUBMODULE_UPDATE_ARGS+=(--remote) fi -if ! git submodule update "${SUBMODULE_UPDATE_ARGS[@]}" third_party/gopher-orch; then - echo -e "${RED}Error: Failed to update gopher-orch submodule${NC}" - echo -e "${YELLOW}If you have multiple GitHub accounts, use:${NC}" - echo -e " GITHUB_SSH_HOST=your-ssh-alias ./build.sh" - exit 1 +if [ "${SKIP_GOPHER_ORCH_UPDATE}" != 1 ]; then + if ! git submodule update "${SUBMODULE_UPDATE_ARGS[@]}" third_party/gopher-orch; then + echo -e "${RED}Error: Failed to update gopher-orch submodule${NC}" + echo -e "${YELLOW}If you have multiple GitHub accounts, use:${NC}" + echo -e " GITHUB_SSH_HOST=your-ssh-alias ./build.sh" + exit 1 + fi fi # Update nested submodule (gopher-mcp inside gopher-orch) @@ -89,13 +234,23 @@ if [ -d "${NATIVE_DIR}" ]; then git config --local url."git@${SSH_HOST}:GopherSecurity/".insteadOf "https://github.com/GopherSecurity/" git submodule sync -- third_party/gopher-mcp git config --local submodule.third_party/gopher-mcp.url "git@${SSH_HOST}:GopherSecurity/gopher-mcp.git" - # Keep the nested submodule pinned unless GOPHER_ORCH_TRACK_REMOTE=1 was - # requested above. - if ! git submodule update "${SUBMODULE_UPDATE_ARGS[@]}" third_party/gopher-mcp; then - echo -e "${RED}Error: Failed to update gopher-mcp submodule${NC}" - echo -e "${YELLOW}If you have multiple GitHub accounts, use:${NC}" - echo -e " GITHUB_SSH_HOST=your-ssh-alias ./build.sh" - exit 1 + + if [ -f "third_party/gopher-mcp/CMakeLists.txt" ] && git -C "third_party/gopher-mcp" rev-parse --git-dir >/dev/null 2>&1; then + NESTED_STATUS="$(git -C "third_party/gopher-mcp" status --short 2>/dev/null || true)" + else + NESTED_STATUS="" + fi + if [ -n "${NESTED_STATUS}" ]; then + echo -e "${YELLOW} local changes present; not running git submodule update for gopher-mcp.${NC}" + else + # Keep the nested submodule pinned unless GOPHER_ORCH_TRACK_REMOTE=1 + # was requested above. + if ! git submodule update "${SUBMODULE_UPDATE_ARGS[@]}" third_party/gopher-mcp; then + echo -e "${RED}Error: Failed to update gopher-mcp submodule${NC}" + echo -e "${YELLOW}If you have multiple GitHub accounts, use:${NC}" + echo -e " GITHUB_SSH_HOST=your-ssh-alias ./build.sh" + exit 1 + fi fi # Also update gopher-mcp's nested submodules recursively if [ -d "third_party/gopher-mcp" ]; then @@ -103,7 +258,7 @@ if [ -d "${NATIVE_DIR}" ]; then git config --local --unset-all url."git@github.com:GopherSecurity/".insteadOf 2>/dev/null || true git config --local --unset-all url."git@${SSH_HOST}:GopherSecurity/".insteadOf 2>/dev/null || true git config --local url."git@${SSH_HOST}:GopherSecurity/".insteadOf "https://github.com/GopherSecurity/" - git submodule update --init --recursive + git submodule update --init --recursive 2>/dev/null || true fi cd "${SCRIPT_DIR}" fi @@ -118,11 +273,94 @@ if [ ! -d "${NATIVE_DIR}" ]; then exit 1 fi +build_linux_x64_docker() { + echo -e "${YELLOW}Step 2: Building Ubuntu 20-compatible gopher-orch native library for linux-x64 with Docker...${NC}" + + if ! command -v docker >/dev/null 2>&1; then + echo -e "${RED}Error: Docker is required for ./build.sh linux on macOS.${NC}" + echo "Please install Docker Desktop from https://www.docker.com/products/docker-desktop/" + exit 1 + fi + + if [ ! -f "${LINUX_X64_DOCKERFILE}" ]; then + echo -e "${RED}Error: Linux Dockerfile not found: ${LINUX_X64_DOCKERFILE}${NC}" + exit 1 + fi + + local output_dir="${NATIVE_DIR}/build-output/linux-x64" + local build_cache_dir="${NATIVE_DIR}/build-cache/linux-x64" + rm -rf "${output_dir}" + mkdir -p "${output_dir}" "${build_cache_dir}" + + echo -e "${YELLOW} Building Docker image from Ubuntu 20.04...${NC}" + docker build \ + --platform linux/amd64 \ + --build-arg "UBUNTU_20_04_IMAGE=${UBUNTU_20_04_IMAGE}" \ + -t gopher-orch-python:linux-x64-ubuntu20 \ + -f "${LINUX_X64_DOCKERFILE}" \ + "${SCRIPT_DIR}" + + echo -e "${YELLOW} Building and extracting Linux x64 artifacts...${NC}" + echo -e "${YELLOW} Reusing CMake cache: ${build_cache_dir}${NC}" + docker run --rm \ + --platform linux/amd64 \ + -v "${NATIVE_DIR}:/source:ro" \ + -v "${build_cache_dir}:/build/cmake-build" \ + -v "${output_dir}:/host-output" \ + gopher-orch-python:linux-x64-ubuntu20 + + if [ ! -f "${output_dir}/libgopher-orch.so" ] && [ -z "$(find "${output_dir}" -maxdepth 1 -name 'libgopher-orch.so*' -type f 2>/dev/null | head -n 1)" ]; then + echo -e "${RED}Error: Linux Docker build did not produce libgopher-orch.so${NC}" + exit 1 + fi + + rm -rf "${TARGET_NATIVE_DIR}.tmp" + mkdir -p "${TARGET_NATIVE_DIR}.tmp/lib" "${TARGET_NATIVE_DIR}.tmp/bin" + cp -P "${output_dir}"/*.so* "${TARGET_NATIVE_DIR}.tmp/lib/" 2>/dev/null || true + cp -P "${output_dir}"/*.a "${TARGET_NATIVE_DIR}.tmp/lib/" 2>/dev/null || true + if [ -d "${output_dir}/include" ]; then + cp -R "${output_dir}/include" "${TARGET_NATIVE_DIR}.tmp/include" + fi + if [ -f "${output_dir}/verify_orch" ]; then + cp "${output_dir}/verify_orch" "${TARGET_NATIVE_DIR}.tmp/bin/" + chmod +x "${TARGET_NATIVE_DIR}.tmp/bin/verify_orch" + fi + + rm -rf "${TARGET_NATIVE_DIR}" + mv "${TARGET_NATIVE_DIR}.tmp" "${TARGET_NATIVE_DIR}" + + echo -e "${GREEN}✓ Native library built successfully for linux-x64${NC}" + printf "%s\n" "${SOURCE_STAMP}" > "${SOURCE_STAMP_FILE}" + echo "" +} + +verify_linux_x64_docker_output() { + if [ "${RESOLVED_TARGET}" != "linux-x64" ] || [ "$(uname -s)" != "Darwin" ]; then + return + fi + + if [ -x "${TARGET_NATIVE_DIR}/bin/verify_orch" ]; then + echo -e "${YELLOW} Verifying Linux artifact inside Ubuntu 20.04...${NC}" + docker run --rm \ + --platform linux/amd64 \ + -v "${TARGET_NATIVE_DIR}:/work" \ + -w /work/lib \ + "${UBUNTU_20_04_IMAGE}" \ + sh -c 'LD_LIBRARY_PATH=/work/lib /work/bin/verify_orch' + fi +} + # Step 3: Build gopher-orch native library # Skip build only when the installed native lib matches the current submodule # revisions and has the required auth symbols. SKIP_NATIVE_BUILD=false -EXISTING_LIB="${SCRIPT_DIR}/native/lib/libgopher-orch.dylib" +EXISTING_LIB="${TARGET_NATIVE_DIR}/lib/libgopher-orch.dylib" +if [ ! -f "${EXISTING_LIB}" ]; then + EXISTING_LIB="${TARGET_NATIVE_DIR}/lib/libgopher-orch.so" +fi +if [ ! -f "${EXISTING_LIB}" ]; then + EXISTING_LIB="${SCRIPT_DIR}/native/lib/libgopher-orch.dylib" +fi if [ ! -f "${EXISTING_LIB}" ]; then EXISTING_LIB="${SCRIPT_DIR}/native/lib/libgopher-orch.so" fi @@ -135,9 +373,13 @@ fi if [ -f "${EXISTING_LIB}" ]; then if [ -f "${SOURCE_STAMP_FILE}" ] && [ "$(cat "${SOURCE_STAMP_FILE}")" = "${SOURCE_STAMP}" ]; then - if nm -gU "${EXISTING_LIB}" 2>/dev/null | grep -q "gopher_auth_config_create"; then + if [ "${RESOLVED_TARGET}" = "linux-x64" ] && [ "$(uname -s)" = "Darwin" ]; then + echo -e "${GREEN}✓ Linux native library already matches latest submodule revisions — skipping rebuild${NC}" + echo -e " (To force rebuild, delete ${TARGET_NATIVE_DIR}/lib/ first)" + SKIP_NATIVE_BUILD=true + elif (nm -gU "${EXISTING_LIB}" 2>/dev/null || nm -g "${EXISTING_LIB}" 2>/dev/null) | grep -q "gopher_auth_config_create"; then echo -e "${GREEN}✓ Native library already matches latest submodule revisions — skipping rebuild${NC}" - echo -e " (To force rebuild, delete native/lib/ first)" + echo -e " (To force rebuild, delete ${TARGET_NATIVE_DIR}/lib/ first)" SKIP_NATIVE_BUILD=true fi else @@ -146,6 +388,9 @@ if [ -f "${EXISTING_LIB}" ]; then fi if [ "${SKIP_NATIVE_BUILD}" = false ]; then +if [ "${RESOLVED_TARGET}" = "linux-x64" ] && [ "$(uname -s)" = "Darwin" ]; then + build_linux_x64_docker +else echo -e "${YELLOW}Step 2: Building gopher-orch native library...${NC}" cd "${NATIVE_DIR}" @@ -161,7 +406,7 @@ cd "${BUILD_DIR}" echo -e "${YELLOW} Configuring CMake...${NC}" cmake .. \ -DCMAKE_BUILD_TYPE=Release \ - -DCMAKE_INSTALL_PREFIX="${SCRIPT_DIR}/native" \ + -DCMAKE_INSTALL_PREFIX="${TARGET_NATIVE_DIR}" \ -DBUILD_SHARED_LIBS=ON \ -DBUILD_BUNDLED_SHARED=OFF \ -DBUILD_TESTS=OFF \ @@ -177,7 +422,7 @@ cmake --install . # Copy dependency libraries (since BUILD_BUNDLED_SHARED=OFF) echo -e "${YELLOW} Copying dependency libraries...${NC}" -NATIVE_LIB="${SCRIPT_DIR}/native/lib" +NATIVE_LIB="${TARGET_NATIVE_DIR}/lib" mkdir -p "${NATIVE_LIB}" # Copy gopher-mcp libraries @@ -199,13 +444,25 @@ echo -e "${GREEN}✓ Native library built successfully${NC}" printf "%s\n" "${SOURCE_STAMP}" > "${SOURCE_STAMP_FILE}" echo "" +fi fi # end SKIP_NATIVE_BUILD # Step 4: Verify build artifacts echo -e "${YELLOW}Step 3: Verifying native build artifacts...${NC}" -NATIVE_LIB_DIR="${SCRIPT_DIR}/native/lib" -NATIVE_INCLUDE_DIR="${SCRIPT_DIR}/native/include" +NATIVE_LIB_DIR="${TARGET_NATIVE_DIR}/lib" +NATIVE_INCLUDE_DIR="${TARGET_NATIVE_DIR}/include" + +if [ -d "${TARGET_NATIVE_DIR}" ]; then + rm -rf "${ACTIVE_NATIVE_DIR}" + ln -s "${RESOLVED_TARGET}" "${ACTIVE_NATIVE_DIR}" + + mkdir -p "${NATIVE_ROOT}/lib" + mkdir -p "${NATIVE_ROOT}/include" + cp -P "${TARGET_NATIVE_DIR}"/lib/* "${NATIVE_ROOT}/lib/" 2>/dev/null || true + cp -R "${TARGET_NATIVE_DIR}"/include/* "${NATIVE_ROOT}/include/" 2>/dev/null || true + printf "%s\n" "${SOURCE_STAMP}" > "${NATIVE_ROOT}/.gopher-orch-source" +fi if [ -d "${NATIVE_LIB_DIR}" ]; then echo -e "${GREEN}✓ Libraries installed to: ${NATIVE_LIB_DIR}${NC}" @@ -216,6 +473,8 @@ else echo -e "${YELLOW}⚠ Library directory not found: ${NATIVE_LIB_DIR}${NC}" fi +verify_linux_x64_docker_output + if [ -d "${NATIVE_INCLUDE_DIR}" ]; then echo -e "${GREEN}✓ Headers installed to: ${NATIVE_INCLUDE_DIR}${NC}" else @@ -228,6 +487,10 @@ echo "" echo -e "${YELLOW}Step 4: Setting up Python environment...${NC}" cd "${SCRIPT_DIR}" +if [ "${RESOLVED_TARGET}" = "linux-x64" ] && [ "$(uname -s)" = "Darwin" ]; then + echo -e "${YELLOW}Skipping Python environment setup for Linux native output on macOS.${NC}" +else + # Check for Python if ! command -v python3 &> /dev/null; then echo -e "${RED}Error: Python 3 not found. Please install Python 3.8+ first.${NC}" @@ -261,9 +524,15 @@ fi echo -e "${GREEN}✓ Python environment set up successfully${NC}" echo "" +fi + # Step 6: Run tests echo -e "${YELLOW}Step 5: Running tests...${NC}" +if [ "${RESOLVED_TARGET}" = "linux-x64" ] && [ "$(uname -s)" = "Darwin" ]; then + echo -e "${YELLOW}Skipping host Python tests for Linux native output on macOS.${NC}" +else + # Use PYTHONPATH to ensure gopher_orch module can be found even without editable install export PYTHONPATH="${SCRIPT_DIR}:${PYTHONPATH}" @@ -287,6 +556,8 @@ else echo -e "${YELLOW}⚠ pytest not found. Install with: pip3 install --user pytest${NC}" fi +fi + echo "" echo -e "${GREEN}======================================${NC}" echo -e "${GREEN}Build completed successfully!${NC}" diff --git a/examples/api/_run_common.sh b/examples/api/_run_common.sh index 733f722e..56d490ec 100644 --- a/examples/api/_run_common.sh +++ b/examples/api/_run_common.sh @@ -68,16 +68,24 @@ run_api_example() { python3 -m venv venv local activate_script="" + local venv_python="" if [ -x "venv/bin/python" ] && [ -f "venv/bin/activate" ]; then activate_script="venv/bin/activate" + venv_python="venv/bin/python" elif [ -x "venv/Scripts/python.exe" ] && [ -f "venv/Scripts/activate" ]; then activate_script="venv/Scripts/activate" + venv_python="venv/Scripts/python.exe" fi if [ -z "$activate_script" ]; then echo -e "${RED}Error: virtualenv creation did not produce a usable Python.${NC}" echo -e "${YELLOW}Install python3-venv and python3-pip, then rerun this script.${NC}" exit 1 fi + if ! "$venv_python" -m pip --version >/dev/null 2>&1; then + echo -e "${RED}Error: pip is not available in this virtual environment.${NC}" + echo -e "${YELLOW}Install python3-venv and python3-pip, then rerun this script.${NC}" + exit 1 + fi # shellcheck disable=SC1090 source "$activate_script" diff --git a/gopher_mcp_python/__init__.py b/gopher_mcp_python/__init__.py index eb85f490..34c3da15 100644 --- a/gopher_mcp_python/__init__.py +++ b/gopher_mcp_python/__init__.py @@ -22,6 +22,8 @@ >>> agent.dispose() """ +from importlib import import_module + from gopher_mcp_python.agent import GopherAgent from gopher_mcp_python.config import ( GopherAgentConfig, @@ -40,24 +42,82 @@ from gopher_mcp_python.server_config import ServerConfig from gopher_mcp_python.ffi import GopherOrchLibrary, GopherOrchHandle -# Auth module re-exports -from gopher_mcp_python.ffi.auth import ( - # Types - GopherAuthError, - ValidationResult, - TokenPayload, - GopherAuthContext, - # Classes - GopherAuthClient, - GopherValidationOptions, - # Functions - gopher_init_auth_library, - gopher_shutdown_auth_library, - is_auth_available, -) - __version__ = "0.1.2" +_AUTH_EXPORTS = { + "GopherAuth", + "GopherAuthError", + "ConfigurationError", + "InsufficientScopesError", + "JwksError", + "TokenExchangeError", + "TokenValidationError", + "has_all_scopes", + "has_any_scope", + "has_scope", +} + +_AUTH_FFI_EXPORTS = { + "AutoRefreshResult", + "RegistrationResponse", + "TokenResponse", + "ValidationResult", + "TokenPayload", + "GopherAuthContext", + "ERROR_DESCRIPTIONS", + "get_error_description", + "gopher_create_empty_auth_context", + "is_gopher_auth_error", + "GopherAuthClient", + "GopherAuthConfig", + "GopherOAuthClient", + "GopherSessionManager", + "GopherValidationOptions", + "gopher_auth_auto_refresh", + "gopher_auth_build_oidc_discovery_metadata", + "gopher_auth_build_oauth_server_metadata", + "gopher_auth_build_protected_resource_metadata", + "gopher_auth_extract_bearer_token", + "gopher_auth_extract_method", + "gopher_auth_extract_path", + "gopher_auth_url_decode", + "gopher_auth_url_encode", + "gopher_auth_validate_all_scopes", + "gopher_auth_validate_any_scopes", + "gopher_auth_validate_idp", + "gopher_create_validation_options", + "gopher_generate_www_authenticate_header", + "gopher_generate_www_authenticate_header_v2", + "gopher_get_auth_library_version", + "gopher_init_auth_library", + "gopher_is_auth_library_initialized", + "gopher_shutdown_auth_library", + "is_auth_available", +} + + +def __getattr__(name: str): + if name in _AUTH_EXPORTS: + auth = import_module("gopher_mcp_python.auth") + value = getattr(auth, name) + globals()[name] = value + return value + + if name == "GopherAuthFfiError": + from gopher_mcp_python.ffi.auth import GopherAuthError as GopherAuthFfiError + + globals()[name] = GopherAuthFfiError + return GopherAuthFfiError + + if name in _AUTH_FFI_EXPORTS: + ffi_auth = import_module("gopher_mcp_python.ffi.auth") + value = getattr(ffi_auth, name) + globals()[name] = value + return value + + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + __all__ = [ # Main classes "GopherAgent", @@ -77,15 +137,52 @@ "GopherOrchLibrary", "GopherOrchHandle", # Auth + "GopherAuth", "GopherAuthError", + "GopherAuthFfiError", + "AutoRefreshResult", + "RegistrationResponse", + "TokenResponse", "ValidationResult", "TokenPayload", "GopherAuthContext", + "ERROR_DESCRIPTIONS", + "get_error_description", + "gopher_create_empty_auth_context", + "is_gopher_auth_error", "GopherAuthClient", + "GopherAuthConfig", + "GopherOAuthClient", + "GopherSessionManager", "GopherValidationOptions", + "gopher_auth_auto_refresh", + "gopher_auth_build_oidc_discovery_metadata", + "gopher_auth_build_oauth_server_metadata", + "gopher_auth_build_protected_resource_metadata", + "gopher_auth_extract_bearer_token", + "gopher_auth_extract_method", + "gopher_auth_extract_path", + "gopher_auth_url_decode", + "gopher_auth_url_encode", + "gopher_auth_validate_all_scopes", + "gopher_auth_validate_any_scopes", + "gopher_auth_validate_idp", + "gopher_create_validation_options", + "gopher_generate_www_authenticate_header", + "gopher_generate_www_authenticate_header_v2", + "gopher_get_auth_library_version", "gopher_init_auth_library", + "gopher_is_auth_library_initialized", "gopher_shutdown_auth_library", "is_auth_available", + "ConfigurationError", + "InsufficientScopesError", + "JwksError", + "TokenExchangeError", + "TokenValidationError", + "has_all_scopes", + "has_any_scope", + "has_scope", # Version "__version__", ] diff --git a/gopher_mcp_python/agent.py b/gopher_mcp_python/agent.py index 58a58f50..9f43664c 100644 --- a/gopher_mcp_python/agent.py +++ b/gopher_mcp_python/agent.py @@ -81,7 +81,10 @@ def init() -> None: lib = GopherOrchLibrary.get_instance() if lib is None: - raise AgentError("Failed to load gopher-mcp-python native library") + load_error = GopherOrchLibrary.get_load_error_message() + raise AgentError( + f"Failed to load gopher-mcp-python native library.\n{load_error}" + ) _initialized = True _setup_cleanup_handler() @@ -120,7 +123,8 @@ def create(config: GopherAgentConfig) -> "GopherAgent": lib = GopherOrchLibrary.get_instance() if lib is None: - raise AgentError("Native library not available") + load_error = GopherOrchLibrary.get_load_error_message() + raise AgentError(f"Native library not available.\n{load_error}") handle: Optional[GopherOrchHandle] = None try: @@ -146,7 +150,7 @@ def create(config: GopherAgentConfig) -> "GopherAgent": if handle is None: error = lib.get_last_error_message() lib.clear_error() - raise AgentError(error or "Failed to create agent") + raise AgentError(error or _build_create_error_message()) return GopherAgent(handle) @@ -383,7 +387,8 @@ def _create_from_ffi( lib = GopherOrchLibrary.get_instance() if lib is None: - raise AgentError("Native library not available") + load_error = GopherOrchLibrary.get_load_error_message() + raise AgentError(f"Native library not available.\n{load_error}") try: handle = create_handle(lib) @@ -395,7 +400,7 @@ def _create_from_ffi( if handle is None: error = lib.get_last_error_message() lib.clear_error() - raise AgentError(error or "Failed to create agent") + raise AgentError(error or _build_create_error_message()) return GopherAgent(handle) @@ -417,13 +422,18 @@ def run(self, query: str, timeout_ms: int = 60000) -> str: lib = GopherOrchLibrary.get_instance() if lib is None: - raise AgentError("Native library not available") + load_error = GopherOrchLibrary.get_load_error_message() + raise AgentError(f"Native library not available.\n{load_error}") try: response = lib.agent_run(self._handle, query, timeout_ms) if response is None: - return f'No response for query: "{query}"' + error = lib.get_last_error_message() + lib.clear_error() + raise AgentError(error or f'No response for query: "{query}"') return response + except AgentError: + raise except Exception as e: raise AgentError(f"Query execution failed: {e}") @@ -481,3 +491,20 @@ def _setup_cleanup_handler() -> None: _cleanup_handler_registered = True atexit.register(GopherAgent.shutdown) + + +def _build_create_error_message() -> str: + """ + Build the AgentError message for a null native create*() result. + + Native should usually populate gopher_orch_last_error, but a few + defensive paths can still return null without details. Keep that + fallback actionable instead of raising only "Failed to create agent". + """ + return ( + "Failed to create agent: native library returned null without a " + "specific error. Most often this means every configured MCP server " + "failed to connect or returned no tools (TLS / network / bad URL), " + "or the LLM provider could not be initialized. Set DEBUG=1 to see " + "Python-side native library load diagnostics and native-side logs." + ) diff --git a/gopher_mcp_python/auth/__init__.py b/gopher_mcp_python/auth/__init__.py index 2c59b6aa..f8c03365 100644 --- a/gopher_mcp_python/auth/__init__.py +++ b/gopher_mcp_python/auth/__init__.py @@ -16,6 +16,33 @@ has_all_scopes, has_any_scope, ) +from gopher_mcp_python.ffi.auth import ( + AutoRefreshResult, + GopherAuthClient, + GopherAuthConfig, + GopherOAuthClient, + GopherSessionManager, + GopherValidationOptions, + RegistrationResponse, + TokenPayload, + TokenResponse, + ValidationResult, + gopher_auth_auto_refresh, + gopher_auth_build_oidc_discovery_metadata, + gopher_auth_build_oauth_server_metadata, + gopher_auth_build_protected_resource_metadata, + gopher_auth_extract_bearer_token, + gopher_auth_extract_method, + gopher_auth_extract_path, + gopher_auth_url_decode, + gopher_auth_url_encode, + gopher_auth_validate_all_scopes, + gopher_auth_validate_any_scopes, + gopher_auth_validate_idp, + gopher_create_validation_options, + gopher_generate_www_authenticate_header, + gopher_generate_www_authenticate_header_v2, +) __all__ = [ "GopherAuth", @@ -28,4 +55,29 @@ "has_scope", "has_all_scopes", "has_any_scope", + "AutoRefreshResult", + "GopherAuthClient", + "GopherAuthConfig", + "GopherOAuthClient", + "GopherSessionManager", + "GopherValidationOptions", + "RegistrationResponse", + "TokenPayload", + "TokenResponse", + "ValidationResult", + "gopher_auth_auto_refresh", + "gopher_auth_build_oidc_discovery_metadata", + "gopher_auth_build_oauth_server_metadata", + "gopher_auth_build_protected_resource_metadata", + "gopher_auth_extract_bearer_token", + "gopher_auth_extract_method", + "gopher_auth_extract_path", + "gopher_auth_url_decode", + "gopher_auth_url_encode", + "gopher_auth_validate_all_scopes", + "gopher_auth_validate_any_scopes", + "gopher_auth_validate_idp", + "gopher_create_validation_options", + "gopher_generate_www_authenticate_header", + "gopher_generate_www_authenticate_header_v2", ] diff --git a/gopher_mcp_python/ffi/auth/__init__.py b/gopher_mcp_python/ffi/auth/__init__.py index 6c3f3bfc..d4c741fb 100644 --- a/gopher_mcp_python/ffi/auth/__init__.py +++ b/gopher_mcp_python/ffi/auth/__init__.py @@ -26,6 +26,17 @@ get_library, is_auth_available, get_auth_functions, + gopher_auth_validate_idp, + gopher_auth_validate_all_scopes, + gopher_auth_validate_any_scopes, + gopher_auth_url_encode, + gopher_auth_url_decode, + gopher_auth_build_protected_resource_metadata, + gopher_auth_build_oauth_server_metadata, + gopher_auth_build_oidc_discovery_metadata, + gopher_auth_extract_bearer_token, + gopher_auth_extract_method, + gopher_auth_extract_path, ) from gopher_mcp_python.ffi.auth.validation_options import ( @@ -43,6 +54,15 @@ RegistrationResponse, ) +from gopher_mcp_python.ffi.auth.session_manager import ( + GopherSessionManager, +) + +from gopher_mcp_python.ffi.auth.auto_refresh import ( + AutoRefreshResult, + gopher_auth_auto_refresh, +) + from gopher_mcp_python.ffi.auth.auth_client import ( GopherAuthClient, gopher_init_auth_library, @@ -78,6 +98,17 @@ "get_library", "is_auth_available", "get_auth_functions", + "gopher_auth_validate_idp", + "gopher_auth_validate_all_scopes", + "gopher_auth_validate_any_scopes", + "gopher_auth_url_encode", + "gopher_auth_url_decode", + "gopher_auth_build_protected_resource_metadata", + "gopher_auth_build_oauth_server_metadata", + "gopher_auth_build_oidc_discovery_metadata", + "gopher_auth_extract_bearer_token", + "gopher_auth_extract_method", + "gopher_auth_extract_path", # Validation options "GopherValidationOptions", "gopher_create_validation_options", @@ -91,4 +122,11 @@ "gopher_is_auth_library_initialized", "gopher_generate_www_authenticate_header", "gopher_generate_www_authenticate_header_v2", + # OAuth/session helpers + "GopherOAuthClient", + "TokenResponse", + "RegistrationResponse", + "GopherSessionManager", + "AutoRefreshResult", + "gopher_auth_auto_refresh", ] diff --git a/gopher_mcp_python/ffi/auth/loader.py b/gopher_mcp_python/ffi/auth/loader.py index c5a4198a..9e1cb7d5 100644 --- a/gopher_mcp_python/ffi/auth/loader.py +++ b/gopher_mcp_python/ffi/auth/loader.py @@ -108,6 +108,28 @@ def _get_platform_package_path() -> Optional[str]: return None +def _get_platform_native_dir_name() -> str: + """Return the native build output directory name for this platform.""" + system = platform.system().lower() + arch = platform.machine().lower() + + arch_map = { + "x86_64": "x64", + "amd64": "x64", + "arm64": "arm64", + "aarch64": "arm64", + } + normalized_arch = arch_map.get(arch, arch) + + platform_map = { + "darwin": "darwin", + "linux": "linux", + "windows": "win32", + } + platform_name = platform_map.get(system, system) + return f"{platform_name}-{normalized_arch}" + + def _get_search_paths() -> List[str]: """ Get search paths for the native library. @@ -124,14 +146,21 @@ def _get_search_paths() -> List[str]: # Get the directory containing this module module_dir = Path(__file__).parent.parent.parent.parent + platform_native_dir = _get_platform_native_dir_name() # Development paths. Explicitly set GOPHER_MCP_PYTHON_LIBRARY_PATH to # force a local build from ./build.sh. paths.extend( [ + str(Path.cwd() / "native" / platform_native_dir / "lib"), + str(Path.cwd() / "native" / "current" / "lib"), str(Path.cwd() / "native" / "lib"), str(Path.cwd() / "lib"), + str(module_dir / "native" / platform_native_dir / "lib"), + str(module_dir / "native" / "current" / "lib"), str(module_dir / "native" / "lib"), + str(module_dir.parent / "native" / platform_native_dir / "lib"), + str(module_dir.parent / "native" / "current" / "lib"), str(module_dir.parent / "native" / "lib"), ] ) diff --git a/gopher_mcp_python/ffi/library.py b/gopher_mcp_python/ffi/library.py index 716e71ee..c17dc6ef 100644 --- a/gopher_mcp_python/ffi/library.py +++ b/gopher_mcp_python/ffi/library.py @@ -4,10 +4,11 @@ import ctypes import os +import re import sys from ctypes import c_char_p, c_void_p, c_int32, c_int64, c_size_t, POINTER, Structure from pathlib import Path -from typing import Optional, Any +from typing import Optional, Any, Tuple from gopher_mcp_python.errors import AgentError from gopher_mcp_python.runtime_options import ( @@ -124,6 +125,7 @@ class GopherOrchLibrary: _debug: bool = False def __init__(self) -> None: + self._load_errors = [] self._load_library() @classmethod @@ -143,28 +145,48 @@ def is_available(cls) -> bool: instance = cls.get_instance() return instance is not None and instance._available + @classmethod + def get_load_error_message(cls) -> str: + """Return native library load diagnostics from the last load attempt.""" + instance = cls._instance + if instance is None or not instance._load_errors: + return "Native library not loaded." + return "\n".join(instance._load_errors) + def _load_library(self) -> None: self._debug = os.environ.get("DEBUG") is not None + self._load_errors = [] library_name = self._get_library_name() search_paths = self._get_search_paths() - # Try custom path from environment variable + # Try custom path from environment variable. It may be either the + # library file itself or a directory containing the platform library. env_path = os.environ.get("GOPHER_MCP_PYTHON_LIBRARY_PATH") or os.environ.get( "GOPHER_ORCH_LIBRARY_PATH" ) - if env_path and os.path.exists(env_path): + env_lib_file = ( + self._resolve_library_path(env_path, library_name) if env_path else None + ) + if env_lib_file: try: - self._lib = ctypes.CDLL(env_path) + self._lib = ctypes.CDLL(env_lib_file) self._setup_functions() self._available = True return except OSError as e: + self._record_load_error( + f"Failed to load environment library path {env_lib_file}: {e}" + ) if self._debug: print( f"Failed to load from environment library path: {e}", file=sys.stderr, ) + elif env_path: + self._record_load_error( + f"Environment library path does not contain {library_name}: {env_path}" + ) # Try search paths for search_path in search_paths: @@ -176,6 +198,7 @@ def _load_library(self) -> None: self._available = True return except OSError as e: + self._record_load_error(f"Failed to load {lib_file}: {e}") if self._debug: print( f"Failed to load from {search_path}: {e}", file=sys.stderr @@ -188,6 +211,9 @@ def _load_library(self) -> None: self._available = True return except OSError as e: + self._record_load_error( + f"Failed to load {library_name} from system library paths: {e}" + ) if self._debug: print(f"Failed to load gopher-mcp-python library: {e}", file=sys.stderr) print("Searched paths:", file=sys.stderr) @@ -196,6 +222,38 @@ def _load_library(self) -> None: self._available = False + def _resolve_library_path(self, candidate: str, library_name: str) -> Optional[str]: + """Resolve a library file or a directory containing the library.""" + if not os.path.exists(candidate): + return None + + if os.path.isfile(candidate): + return candidate + + if not os.path.isdir(candidate): + return None + + direct = os.path.join(candidate, library_name) + if os.path.exists(direct): + return direct + + matches = [ + name + for name in os.listdir(candidate) + if _library_version_key(name, library_name) is not None + ] + if not matches: + return None + + matches.sort( + key=lambda name: _library_version_key(name, library_name), + reverse=True, + ) + return os.path.join(candidate, matches[0]) + + def _record_load_error(self, message: str) -> None: + self._load_errors.append(message) + def _setup_functions(self) -> None: if self._lib is None: return @@ -403,6 +461,26 @@ def _get_platform_package_path(self) -> Optional[str]: return None + def _get_platform_native_dir_name(self) -> str: + """Return the native build output directory name for this platform.""" + import platform as plat + + arch_map = { + "arm64": "arm64", + "aarch64": "arm64", + "x86_64": "x64", + "amd64": "x64", + "x64": "x64", + } + arch = arch_map.get(plat.machine().lower(), plat.machine().lower()) + platform_map = { + "darwin": "darwin", + "linux": "linux", + "win32": "win32", + } + platform_name = platform_map.get(sys.platform, sys.platform) + return f"{platform_name}-{arch}" + def _get_search_paths(self) -> list: paths = [] @@ -413,15 +491,20 @@ def _get_search_paths(self) -> list: # 2. Get the directory containing this module for development fallbacks module_dir = Path(__file__).parent.parent.parent + platform_native_dir = self._get_platform_native_dir_name() # Development paths (native/lib in various locations). Explicitly set # GOPHER_MCP_PYTHON_LIBRARY_PATH to force a local build. paths.extend( [ - # Project root native/lib + os.path.join(os.getcwd(), "native", platform_native_dir, "lib"), + os.path.join(os.getcwd(), "native", "current", "lib"), os.path.join(os.getcwd(), "native", "lib"), - # Relative to module location + os.path.join(module_dir, "native", platform_native_dir, "lib"), + os.path.join(module_dir, "native", "current", "lib"), os.path.join(module_dir, "native", "lib"), + os.path.join(module_dir.parent, "native", platform_native_dir, "lib"), + os.path.join(module_dir.parent, "native", "current", "lib"), os.path.join(module_dir.parent, "native", "lib"), ] ) @@ -749,7 +832,11 @@ def get_last_error_message(self) -> Optional[str]: """Get the last error message.""" error_info = self.last_error() if error_info and error_info.message: - return error_info.message.decode("utf-8") + message = error_info.message.decode("utf-8") + if error_info.details: + details = error_info.details.decode("utf-8") + return f"{message}: {details}" + return message return None def clear_error(self) -> None: @@ -789,3 +876,33 @@ def _missing_routing_factory_message() -> str: "this build of libgopher-orch predates the routing factories; " "upgrade to a native gopher-orch library release that includes them" ) + + +def _library_version_key( + filename: str, library_name: str +) -> Optional[Tuple[int, Tuple[int, ...], str]]: + """ + Return a sortable key for versioned variants of library_name. + + Exact library_name is handled before this helper. Linux uses + libname.so.X.Y.Z; macOS uses libname.X.Y.Z.dylib. + """ + linux_prefix = f"{library_name}." + if filename.startswith(linux_prefix): + version = filename[len(linux_prefix) :] + return (1, _parse_library_version(version), filename) + + dylib_suffix = ".dylib" + if library_name.endswith(dylib_suffix) and filename.endswith(dylib_suffix): + stem = library_name[: -len(dylib_suffix)] + versioned_prefix = f"{stem}." + if filename.startswith(versioned_prefix): + version = filename[len(versioned_prefix) : -len(dylib_suffix)] + return (1, _parse_library_version(version), filename) + + return None + + +def _parse_library_version(version: str) -> Tuple[int, ...]: + parts = re.findall(r"\d+", version) + return tuple(int(part) for part in parts) if parts else (0,) diff --git a/scripts/docker/Dockerfile.linux-x64-ubuntu20 b/scripts/docker/Dockerfile.linux-x64-ubuntu20 new file mode 100644 index 00000000..cd3fe6dc --- /dev/null +++ b/scripts/docker/Dockerfile.linux-x64-ubuntu20 @@ -0,0 +1,26 @@ +# Dockerfile for Ubuntu 20.04-compatible Linux x86_64 gopher-orch builds. +# Ubuntu 20.04 ships GLIBC 2.31, so artifacts built here can run on Ubuntu 20. +ARG UBUNTU_20_04_IMAGE=ubuntu:20.04@sha256:8feb4d8ca5354def3d8fce243717141ce31e2c428701f6682bd2fafe15388214 +FROM ${UBUNTU_20_04_IMAGE} + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y \ + build-essential \ + cmake \ + g++ \ + libssl-dev \ + libevent-dev \ + libcurl4-openssl-dev \ + libnghttp2-dev \ + pkg-config \ + git \ + patchelf \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /build + +COPY scripts/docker/build-linux-x64-ubuntu20.sh /usr/local/bin/build-linux-x64-ubuntu20.sh +RUN chmod +x /usr/local/bin/build-linux-x64-ubuntu20.sh + +CMD ["/usr/local/bin/build-linux-x64-ubuntu20.sh"] diff --git a/scripts/docker/build-linux-x64-ubuntu20.sh b/scripts/docker/build-linux-x64-ubuntu20.sh new file mode 100755 index 00000000..95966d84 --- /dev/null +++ b/scripts/docker/build-linux-x64-ubuntu20.sh @@ -0,0 +1,107 @@ +#!/bin/sh + +set -e + +echo "=== Checking gopher-mcp submodule ===" +ls /source/third_party/gopher-mcp/CMakeLists.txt + +mkdir -p /build/cmake-build /tmp/output +rm -rf /build/cmake-build/install /tmp/output/* /host-output/* + +cd /build/cmake-build +cmake -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_CXX_STANDARD=14 \ + -DCMAKE_POSITION_INDEPENDENT_CODE=ON \ + -DBUILD_SHARED_LIBS=ON \ + -DBUILD_STATIC_LIBS=ON \ + -DBUILD_BUNDLED_SHARED=OFF \ + -DBUILD_TESTS=OFF \ + -DBUILD_EXAMPLES=OFF \ + -DUSE_SUBMODULE_GOPHER_MCP=ON \ + -DCMAKE_INSTALL_PREFIX=/build/cmake-build/install \ + -DCMAKE_INSTALL_RPATH='$ORIGIN' \ + /source + +make -j"$(nproc)" +make install + +cp /build/cmake-build/install/lib/libgopher-orch*.so* /tmp/output/ 2>/dev/null || true +cp /build/cmake-build/install/lib/libgopher-orch*.a /tmp/output/ 2>/dev/null || true +cp /build/cmake-build/install/lib/libgopher-mcp*.so* /tmp/output/ 2>/dev/null || true +cp /build/cmake-build/install/lib/libgopher-mcp-event*.so* /tmp/output/ 2>/dev/null || true +cp /build/cmake-build/install/lib/libgopher-mcp-logging*.so* /tmp/output/ 2>/dev/null || true +cp /build/cmake-build/lib/libgopher-mcp*.so* /tmp/output/ 2>/dev/null || true +cp /build/cmake-build/lib/libgopher-mcp-event*.so* /tmp/output/ 2>/dev/null || true +cp /build/cmake-build/lib/libgopher-mcp-logging*.so* /tmp/output/ 2>/dev/null || true +cp /build/cmake-build/install/lib/libfmt*.so* /tmp/output/ 2>/dev/null || true +cp /build/cmake-build/install/lib/libfmt*.a /tmp/output/ 2>/dev/null || true +cp /build/cmake-build/install/lib/libllhttp*.so* /tmp/output/ 2>/dev/null || true +cp /build/cmake-build/install/lib/libllhttp*.a /tmp/output/ 2>/dev/null || true +cp /build/cmake-build/lib/libfmt*.so* /tmp/output/ 2>/dev/null || true +cp /build/cmake-build/lib/libfmt*.a /tmp/output/ 2>/dev/null || true +cp /build/cmake-build/lib/libllhttp*.so* /tmp/output/ 2>/dev/null || true +cp /build/cmake-build/lib/libllhttp*.a /tmp/output/ 2>/dev/null || true +cp /build/cmake-build/_deps/fmt-build/libfmt*.a /tmp/output/ 2>/dev/null || true +cp /build/cmake-build/_deps/llhttp-build/libllhttp*.a /tmp/output/ 2>/dev/null || true + +mkdir -p /tmp/output/include +cp -r /source/include/* /tmp/output/include/ 2>/dev/null || true +cp -r /source/third_party/gopher-mcp/include/* /tmp/output/include/ 2>/dev/null || true + +echo "=== Bundling third-party dependencies ===" +for dylib in /tmp/output/libgopher-*.so*; do + [ -L "$dylib" ] && continue + [ -f "$dylib" ] || continue + ldd "$dylib" 2>/dev/null | grep "=> /" | while read -r line; do + dep_path=$(echo "$line" | sed 's/.*=> //' | sed 's/ (.*//' | tr -d '[:space:]') + dep_name=$(basename "$dep_path") + case "$dep_name" in + # Keep toolchain, libc, NSS, and TLS libraries system-provided so + # distro security updates apply instead of vendoring stale copies. + libc.so*|libm.so*|libdl.so*|librt.so*|libpthread.so*|linux-vdso*|ld-linux*|libstdc++*|libgcc_s*|libresolv*|libnss*|libnsl*|libssl.so*|libcrypto.so*) continue ;; + esac + if [ -f "$dep_path" ] && [ ! -f "/tmp/output/$dep_name" ]; then + echo " Bundling: $dep_name" + cp "$dep_path" "/tmp/output/$dep_name" + chmod 644 "/tmp/output/$dep_name" + fi + done +done + +for sofile in /tmp/output/*.so /tmp/output/*.so.*; do + [ -L "$sofile" ] && continue + [ -f "$sofile" ] || continue + patchelf --set-rpath '$ORIGIN' "$sofile" +done +echo "=== Bundling complete ===" + +cat > /tmp/verify_orch.c <<'EOF' +#include +#include + +int main() { + printf("libgopher-orch verification tool (Linux x86_64, Ubuntu 20 compatible)\n"); + printf("==================================================================\n\n"); + void* handle = dlopen("./libgopher-orch.so", RTLD_NOW); + if (!handle) { + printf("X Failed to load gopher-orch library: %s\n", dlerror()); + return 1; + } + printf("OK gopher-orch library loaded successfully\n"); + void* mcp_handle = dlopen("./libgopher-mcp.so", RTLD_NOW); + if (mcp_handle) { + printf("OK gopher-mcp library loaded successfully\n"); + dlclose(mcp_handle); + } else { + printf("-- gopher-mcp library not found (may be statically linked)\n"); + } + dlclose(handle); + printf("\nOK Verification complete\n"); + return 0; +} +EOF +gcc -o /tmp/output/verify_orch /tmp/verify_orch.c -ldl -O2 + +cp -r /tmp/output/* /host-output/ +echo "Ubuntu 20 compatible x86_64 build complete!" +ls -la /tmp/output/ diff --git a/tests/ffi/auth/test_loader.py b/tests/ffi/auth/test_loader.py index dcf1ab5c..8d5c1d08 100644 --- a/tests/ffi/auth/test_loader.py +++ b/tests/ffi/auth/test_loader.py @@ -6,6 +6,7 @@ from gopher_mcp_python.ffi.auth.loader import ( _get_library_name, + _get_platform_native_dir_name, _get_search_paths, load_library, is_library_loaded, @@ -83,6 +84,19 @@ def test_prefers_platform_package_before_local_native_lib(self, monkeypatch): platform_path = "/tmp/gopher-platform-native/lib" assert paths.index(platform_path) < paths.index(local_path) + def test_includes_platform_and_current_native_paths(self): + """Test includes JS-compatible local native output directories.""" + paths = _get_search_paths() + platform_dir = _get_platform_native_dir_name() + platform_path = str(auth_loader.Path.cwd() / "native" / platform_dir / "lib") + current_path = str(auth_loader.Path.cwd() / "native" / "current" / "lib") + + assert platform_path in paths + assert current_path in paths + assert paths.index(platform_path) < paths.index( + str(auth_loader.Path.cwd() / "native" / "lib") + ) + @patch("platform.system") def test_darwin_includes_homebrew(self, mock_system): """Test macOS includes homebrew paths.""" diff --git a/tests/test_agent_error_message.py b/tests/test_agent_error_message.py new file mode 100644 index 00000000..45aa5698 --- /dev/null +++ b/tests/test_agent_error_message.py @@ -0,0 +1,124 @@ +"""Tests for Python AgentError message formatting.""" + +from types import SimpleNamespace + +import pytest + +import gopher_mcp_python.agent as agent_module +from gopher_mcp_python import AgentError, GopherAgent +from gopher_mcp_python.ffi.library import GopherOrchLibrary + + +class NullCreateLibrary: + def __init__(self, message=None): + self.message = message + self.cleared = False + + def agent_create_by_url(self, provider, model, url, runtime_options=None): + return None + + def get_last_error_message(self): + return self.message + + def clear_error(self): + self.cleared = True + + +class NullRunLibrary: + def __init__(self, message=None): + self.message = message + self.cleared = False + + def agent_run(self, handle, query, timeout_ms): + return None + + def get_last_error_message(self): + return self.message + + def clear_error(self): + self.cleared = True + + +def test_create_failure_uses_actionable_fallback(monkeypatch) -> None: + fake = NullCreateLibrary() + monkeypatch.setattr(agent_module, "_initialized", True) + monkeypatch.setattr( + agent_module.GopherOrchLibrary, + "get_instance", + staticmethod(lambda: fake), + ) + + with pytest.raises(AgentError) as exc_info: + GopherAgent.create_with_url( + "Provider", "model", "http://127.0.0.1:5001/mcp" + ) + + assert "native library returned null without a specific error" in str( + exc_info.value + ) + assert "Set DEBUG=1" in str(exc_info.value) + assert fake.cleared is True + + +def test_create_failure_keeps_native_error_message(monkeypatch) -> None: + fake = NullCreateLibrary("Failed to create agent from MCP server URL: Timeout") + monkeypatch.setattr(agent_module, "_initialized", True) + monkeypatch.setattr( + agent_module.GopherOrchLibrary, + "get_instance", + staticmethod(lambda: fake), + ) + + with pytest.raises(AgentError) as exc_info: + GopherAgent.create_with_url( + "Provider", "model", "http://127.0.0.1:5001/mcp" + ) + + assert str(exc_info.value) == "Failed to create agent from MCP server URL: Timeout" + assert fake.cleared is True + + +def test_last_error_message_includes_native_details(monkeypatch) -> None: + lib = object.__new__(GopherOrchLibrary) + error_info = SimpleNamespace( + message=b"Failed to create agent from JSON configuration", + details=b"No configured MCP servers connected: server-1: Init timeout after 5s", + ) + monkeypatch.setattr(lib, "last_error", lambda: error_info) + + assert lib.get_last_error_message() == ( + "Failed to create agent from JSON configuration: " + "No configured MCP servers connected: server-1: Init timeout after 5s" + ) + + +def test_run_null_response_raises_agent_error(monkeypatch) -> None: + fake = NullRunLibrary() + monkeypatch.setattr( + agent_module.GopherOrchLibrary, + "get_instance", + staticmethod(lambda: fake), + ) + agent = GopherAgent(1234) + + with pytest.raises(AgentError) as exc_info: + agent.run("what tools we have?", 1000) + + assert str(exc_info.value) == 'No response for query: "what tools we have?"' + assert fake.cleared is True + + +def test_run_null_response_uses_native_error(monkeypatch) -> None: + fake = NullRunLibrary("Tool call timed out") + monkeypatch.setattr( + agent_module.GopherOrchLibrary, + "get_instance", + staticmethod(lambda: fake), + ) + agent = GopherAgent(1234) + + with pytest.raises(AgentError) as exc_info: + agent.run("what tools we have?", 1000) + + assert str(exc_info.value) == "Tool call timed out" + assert fake.cleared is True diff --git a/tests/test_auth_exports.py b/tests/test_auth_exports.py new file mode 100644 index 00000000..40ae7639 --- /dev/null +++ b/tests/test_auth_exports.py @@ -0,0 +1,91 @@ +"""Import contract tests for public auth exports.""" + + +def test_root_exports_auth_ffi_helpers(): + from gopher_mcp_python import ( + AutoRefreshResult, + GopherAuthClient, + GopherAuthConfig, + GopherOAuthClient, + GopherSessionManager, + GopherValidationOptions, + gopher_auth_auto_refresh, + gopher_auth_build_protected_resource_metadata, + gopher_auth_extract_bearer_token, + gopher_auth_url_encode, + gopher_auth_validate_all_scopes, + gopher_create_validation_options, + gopher_generate_www_authenticate_header_v2, + ) + + assert AutoRefreshResult is not None + assert GopherAuthClient is not None + assert GopherAuthConfig is not None + assert GopherOAuthClient is not None + assert GopherSessionManager is not None + assert GopherValidationOptions is not None + assert callable(gopher_auth_auto_refresh) + assert callable(gopher_auth_build_protected_resource_metadata) + assert callable(gopher_auth_extract_bearer_token) + assert callable(gopher_auth_url_encode) + assert callable(gopher_auth_validate_all_scopes) + assert callable(gopher_create_validation_options) + assert callable(gopher_generate_www_authenticate_header_v2) + + +def test_root_exports_reusable_auth_aliases(): + from gopher_mcp_python import ( + GopherAuth, + GopherAuthError, + GopherAuthFfiError, + InsufficientScopesError, + TokenExchangeError, + TokenValidationError, + has_all_scopes, + has_any_scope, + has_scope, + ) + from gopher_mcp_python.auth import GopherAuthError as AuthGopherAuthError + from gopher_mcp_python.ffi.auth import GopherAuthError as FfiGopherAuthError + + assert GopherAuth is not None + assert GopherAuthError is AuthGopherAuthError + assert GopherAuthFfiError is FfiGopherAuthError + assert issubclass(InsufficientScopesError, GopherAuthError) + assert issubclass(TokenExchangeError, GopherAuthError) + assert issubclass(TokenValidationError, GopherAuthError) + assert callable(has_all_scopes) + assert callable(has_any_scope) + assert callable(has_scope) + + +def test_auth_package_exports_ffi_helpers(): + from gopher_mcp_python.auth import ( + AutoRefreshResult, + GopherAuthClient, + GopherAuthConfig, + GopherOAuthClient, + GopherSessionManager, + GopherValidationOptions, + gopher_auth_auto_refresh, + gopher_auth_build_protected_resource_metadata, + gopher_auth_extract_bearer_token, + gopher_auth_url_encode, + gopher_auth_validate_all_scopes, + gopher_create_validation_options, + gopher_generate_www_authenticate_header_v2, + ) + + assert AutoRefreshResult is not None + assert GopherAuthClient is not None + assert GopherAuthConfig is not None + assert GopherOAuthClient is not None + assert GopherSessionManager is not None + assert GopherValidationOptions is not None + assert callable(gopher_auth_auto_refresh) + assert callable(gopher_auth_build_protected_resource_metadata) + assert callable(gopher_auth_extract_bearer_token) + assert callable(gopher_auth_url_encode) + assert callable(gopher_auth_validate_all_scopes) + assert callable(gopher_create_validation_options) + assert callable(gopher_generate_www_authenticate_header_v2) diff --git a/tests/test_library_search_paths.py b/tests/test_library_search_paths.py index 64ae759c..965217d9 100644 --- a/tests/test_library_search_paths.py +++ b/tests/test_library_search_paths.py @@ -19,3 +19,96 @@ def test_prefers_platform_package_before_local_native_lib(monkeypatch): assert paths.index("/tmp/gopher-platform-native/lib") < paths.index( os.path.join(os.getcwd(), "native", "lib") ) + + +def test_includes_platform_and_current_native_paths(): + """Local search paths include JS-compatible native output directories.""" + lib = object.__new__(GopherOrchLibrary) + platform_dir = lib._get_platform_native_dir_name() + + paths = lib._get_search_paths() + + assert os.path.join(os.getcwd(), "native", platform_dir, "lib") in paths + assert os.path.join(os.getcwd(), "native", "current", "lib") in paths + assert paths.index( + os.path.join(os.getcwd(), "native", platform_dir, "lib") + ) < paths.index(os.path.join(os.getcwd(), "native", "lib")) + + +def test_resolves_environment_library_file(tmp_path): + """Environment override can point directly at the native library file.""" + lib = object.__new__(GopherOrchLibrary) + lib_file = tmp_path / "libgopher-orch.dylib" + lib_file.write_bytes(b"") + + assert lib._resolve_library_path(str(lib_file), "libgopher-orch.dylib") == str( + lib_file + ) + + +def test_resolves_environment_library_directory(tmp_path): + """Environment override can point at a directory containing the library.""" + lib = object.__new__(GopherOrchLibrary) + lib_file = tmp_path / "libgopher-orch.dylib" + lib_file.write_bytes(b"") + + assert lib._resolve_library_path(str(tmp_path), "libgopher-orch.dylib") == str( + lib_file + ) + + +def test_prefers_unversioned_library_in_directory(tmp_path): + """Directory overrides prefer the canonical unversioned library name.""" + lib = object.__new__(GopherOrchLibrary) + unversioned = tmp_path / "libgopher-orch.so" + versioned = tmp_path / "libgopher-orch.so.0.1.30" + unversioned.write_bytes(b"") + versioned.write_bytes(b"") + + assert lib._resolve_library_path(str(tmp_path), "libgopher-orch.so") == str( + unversioned + ) + + +def test_resolves_versioned_library_in_directory(tmp_path): + """Directory overrides can contain version-suffixed shared libraries.""" + lib = object.__new__(GopherOrchLibrary) + lib_file = tmp_path / "libgopher-orch.so.0.1.30" + lib_file.write_bytes(b"") + + assert lib._resolve_library_path(str(tmp_path), "libgopher-orch.so") == str( + lib_file + ) + + +def test_resolves_highest_linux_versioned_library_in_directory(tmp_path): + """Linux version sorting should be numeric, not lexicographic.""" + lib = object.__new__(GopherOrchLibrary) + old = tmp_path / "libgopher-orch.so.0.1.2" + new = tmp_path / "libgopher-orch.so.0.1.30" + old.write_bytes(b"") + new.write_bytes(b"") + + assert lib._resolve_library_path(str(tmp_path), "libgopher-orch.so") == str(new) + + +def test_resolves_macos_versioned_dylib_in_directory(tmp_path): + """macOS versioned dylibs put the version before .dylib.""" + lib = object.__new__(GopherOrchLibrary) + old = tmp_path / "libgopher-orch.0.1.2.dylib" + new = tmp_path / "libgopher-orch.0.1.30.dylib" + old.write_bytes(b"") + new.write_bytes(b"") + + assert lib._resolve_library_path(str(tmp_path), "libgopher-orch.dylib") == str( + new + ) + + +def test_records_load_errors(): + lib = object.__new__(GopherOrchLibrary) + lib._load_errors = [] + + lib._record_load_error("failed path") + + assert lib._load_errors == ["failed path"] diff --git a/tests/test_linux_native_packaging.py b/tests/test_linux_native_packaging.py new file mode 100644 index 00000000..fe7de3d9 --- /dev/null +++ b/tests/test_linux_native_packaging.py @@ -0,0 +1,62 @@ +"""Regression tests for Linux native package dependency policy.""" + +import re +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def _linux_builder_script() -> str: + return (ROOT / "scripts" / "docker" / "build-linux-x64-ubuntu20.sh").read_text() + + +def _linux_builder_dockerfile() -> str: + return (ROOT / "scripts" / "docker" / "Dockerfile.linux-x64-ubuntu20").read_text() + + +def _root_build_script() -> str: + return (ROOT / "build.sh").read_text() + + +def test_linux_x64_uses_digest_pinned_ubuntu_builder_image() -> None: + dockerfile = _linux_builder_dockerfile() + build_script = _root_build_script() + pinned_image_pattern = r"ubuntu:20\.04@sha256:[0-9a-f]{64}" + + assert re.search(pinned_image_pattern, dockerfile) + assert "FROM ubuntu:20.04\n" not in dockerfile + assert "ARG UBUNTU_20_04_IMAGE=" in dockerfile + assert "FROM ${UBUNTU_20_04_IMAGE}" in dockerfile + + assert re.search(pinned_image_pattern, build_script) + assert "--build-arg \"UBUNTU_20_04_IMAGE=${UBUNTU_20_04_IMAGE}\"" in build_script + assert re.search(r"\subuntu:20\.04\s", build_script) is None + + +def test_linux_x64_builder_does_not_bundle_openssl() -> None: + script = _linux_builder_script() + dep_skip_block = re.search(r"case \"\$dep_name\" in(?P.*?)esac", script, re.S) + + assert dep_skip_block is not None + assert "libssl.so*" in dep_skip_block.group("body") + assert "libcrypto.so*" in dep_skip_block.group("body") + + +def test_linux_x64_builder_fails_on_patchelf_errors() -> None: + script = _linux_builder_script() + + assert "patchelf --set-rpath '$ORIGIN' \"$sofile\"" in script + assert "patchelf --set-rpath '$ORIGIN' \"$sofile\" 2>/dev/null || true" not in script + + +def test_publish_workflow_checks_linux_x64_dependencies() -> None: + workflow = (ROOT / ".github" / "workflows" / "publish-packages.yml").read_text() + + assert "Verify Linux native dependencies" in workflow + assert "matrix.platform == 'linux-x64'" in workflow + assert "set -euo pipefail" in workflow + assert "readelf -d \"$sofile\"" in workflow + assert "ldd \"$sofile\"" in workflow + assert "grep -Ev '^(libssl\\.so|libcrypto\\.so)'" in workflow + assert "OpenSSL libraries must remain system-provided" in workflow