From 5aee6415620a62510e1cc8cc41e242eaf8a8372d Mon Sep 17 00:00:00 2001 From: RahulHere Date: Sun, 5 Jul 2026 09:16:40 +0800 Subject: [PATCH 01/15] Improve Python agent create errors Summary:\n- include native error details in Python FFI error messages\n- use an actionable create-agent fallback when native returns null without last_error\n- add tests for native error detail propagation and fallback create errors --- gopher_mcp_python/agent.py | 21 ++++++++- gopher_mcp_python/ffi/library.py | 6 ++- tests/test_agent_error_message.py | 77 +++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 tests/test_agent_error_message.py diff --git a/gopher_mcp_python/agent.py b/gopher_mcp_python/agent.py index 58a58f50..a02750ab 100644 --- a/gopher_mcp_python/agent.py +++ b/gopher_mcp_python/agent.py @@ -146,7 +146,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) @@ -395,7 +395,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) @@ -481,3 +481,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 GOPHER_DEBUG=1 to " + "see native-side logs." + ) diff --git a/gopher_mcp_python/ffi/library.py b/gopher_mcp_python/ffi/library.py index 716e71ee..a595173d 100644 --- a/gopher_mcp_python/ffi/library.py +++ b/gopher_mcp_python/ffi/library.py @@ -749,7 +749,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: diff --git a/tests/test_agent_error_message.py b/tests/test_agent_error_message.py new file mode 100644 index 00000000..dd05af21 --- /dev/null +++ b/tests/test_agent_error_message.py @@ -0,0 +1,77 @@ +"""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 + + +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 GOPHER_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" + ) From aa566ce46f339d70f4b61f45d480df63c9f645f4 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Sun, 5 Jul 2026 09:17:32 +0800 Subject: [PATCH 02/15] Raise Python agent errors for null runs Summary:\n- make GopherAgent.run raise AgentError when native returns no response\n- preserve native last_error text for null run results\n- add focused tests for fallback and native run error messages --- gopher_mcp_python/agent.py | 6 +++- tests/test_agent_error_message.py | 47 +++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/gopher_mcp_python/agent.py b/gopher_mcp_python/agent.py index a02750ab..9d822520 100644 --- a/gopher_mcp_python/agent.py +++ b/gopher_mcp_python/agent.py @@ -422,8 +422,12 @@ def run(self, query: str, timeout_ms: int = 60000) -> str: 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}") diff --git a/tests/test_agent_error_message.py b/tests/test_agent_error_message.py index dd05af21..524131e2 100644 --- a/tests/test_agent_error_message.py +++ b/tests/test_agent_error_message.py @@ -24,6 +24,21 @@ 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) @@ -75,3 +90,35 @@ def test_last_error_message_includes_native_details(monkeypatch) -> None: "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 From 8dd09ce6ff426410c53364cd38c863ebe48b920f Mon Sep 17 00:00:00 2001 From: RahulHere Date: Sun, 5 Jul 2026 09:19:17 +0800 Subject: [PATCH 03/15] Improve Python native loader diagnostics Summary:\n- allow GOPHER_MCP_PYTHON_LIBRARY_PATH and GOPHER_ORCH_LIBRARY_PATH to point at a library file or directory\n- collect native library load errors for clearer AgentError messages\n- add loader tests for file, directory, versioned library, and diagnostic handling --- gopher_mcp_python/agent.py | 14 +++++--- gopher_mcp_python/ffi/library.py | 56 ++++++++++++++++++++++++++++-- tests/test_library_search_paths.py | 42 ++++++++++++++++++++++ 3 files changed, 105 insertions(+), 7 deletions(-) diff --git a/gopher_mcp_python/agent.py b/gopher_mcp_python/agent.py index 9d822520..65d55bc4 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: @@ -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) @@ -417,7 +422,8 @@ 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) diff --git a/gopher_mcp_python/ffi/library.py b/gopher_mcp_python/ffi/library.py index a595173d..32041518 100644 --- a/gopher_mcp_python/ffi/library.py +++ b/gopher_mcp_python/ffi/library.py @@ -124,6 +124,7 @@ class GopherOrchLibrary: _debug: bool = False def __init__(self) -> None: + self._load_errors = [] self._load_library() @classmethod @@ -143,28 +144,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 +197,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 +210,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 +221,31 @@ 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 = sorted( + name + for name in os.listdir(candidate) + if name == library_name or name.startswith(f"{library_name}.") + ) + return os.path.join(candidate, matches[0]) if matches else None + + def _record_load_error(self, message: str) -> None: + self._load_errors.append(message) + def _setup_functions(self) -> None: if self._lib is None: return diff --git a/tests/test_library_search_paths.py b/tests/test_library_search_paths.py index 64ae759c..949b887d 100644 --- a/tests/test_library_search_paths.py +++ b/tests/test_library_search_paths.py @@ -19,3 +19,45 @@ 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_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_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_records_load_errors(): + lib = object.__new__(GopherOrchLibrary) + lib._load_errors = [] + + lib._record_load_error("failed path") + + assert lib._load_errors == ["failed path"] From caa383c8fa7ac849ec5dc24e7feeb372238e6859 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Sun, 5 Jul 2026 09:20:47 +0800 Subject: [PATCH 04/15] Add Python native platform search paths Summary:\n- search native//lib and native/current/lib before compatibility native/lib\n- keep local build outputs ahead of installed platform packages\n- update main and auth loader tests for platform-specific native output paths --- gopher_mcp_python/ffi/auth/loader.py | 29 ++++++++++++++++++++++++++++ gopher_mcp_python/ffi/library.py | 29 ++++++++++++++++++++++++++-- tests/ffi/auth/test_loader.py | 14 ++++++++++++++ tests/test_library_search_paths.py | 14 ++++++++++++++ 4 files changed, 84 insertions(+), 2 deletions(-) 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 32041518..9c1caaf6 100644 --- a/gopher_mcp_python/ffi/library.py +++ b/gopher_mcp_python/ffi/library.py @@ -453,6 +453,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 = [] @@ -463,15 +483,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"), ] ) 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_library_search_paths.py b/tests/test_library_search_paths.py index 949b887d..ddcf9a7b 100644 --- a/tests/test_library_search_paths.py +++ b/tests/test_library_search_paths.py @@ -21,6 +21,20 @@ def test_prefers_platform_package_before_local_native_lib(monkeypatch): ) +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) From a213dad002ea000cd28297d1374ac9cd6d59c361 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Sun, 5 Jul 2026 09:23:36 +0800 Subject: [PATCH 05/15] Expand Python auth public exports Summary:\n- re-export OAuth, session, auto-refresh, metadata, URL, and validation auth helpers from ffi.auth\n- expose common auth helpers at gopher_mcp_python.auth and the package root\n- add import-contract tests for root and auth package exports --- gopher_mcp_python/__init__.py | 76 ++++++++++++++++++++++ gopher_mcp_python/auth/__init__.py | 52 +++++++++++++++ gopher_mcp_python/ffi/auth/__init__.py | 38 +++++++++++ tests/test_auth_exports.py | 88 ++++++++++++++++++++++++++ 4 files changed, 254 insertions(+) create mode 100644 tests/test_auth_exports.py diff --git a/gopher_mcp_python/__init__.py b/gopher_mcp_python/__init__.py index eb85f490..3ea0d760 100644 --- a/gopher_mcp_python/__init__.py +++ b/gopher_mcp_python/__init__.py @@ -44,17 +44,56 @@ from gopher_mcp_python.ffi.auth import ( # Types GopherAuthError, + AutoRefreshResult, + RegistrationResponse, + TokenResponse, ValidationResult, TokenPayload, GopherAuthContext, + ERROR_DESCRIPTIONS, + get_error_description, + gopher_create_empty_auth_context, + is_gopher_auth_error, # Classes GopherAuthClient, + GopherAuthConfig, + GopherOAuthClient, + GopherSessionManager, GopherValidationOptions, # Functions + 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, ) +from gopher_mcp_python.auth import ( + GopherAuth, + GopherAuthError as GopherAuthBaseError, + ConfigurationError, + InsufficientScopesError, + JwksError, + TokenExchangeError, + TokenValidationError, + has_all_scopes, + has_any_scope, + has_scope, +) __version__ = "0.1.2" @@ -78,14 +117,51 @@ "GopherOrchHandle", # Auth "GopherAuthError", + "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", + "GopherAuth", + "GopherAuthBaseError", + "ConfigurationError", + "InsufficientScopesError", + "JwksError", + "TokenExchangeError", + "TokenValidationError", + "has_all_scopes", + "has_any_scope", + "has_scope", # Version "__version__", ] 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/tests/test_auth_exports.py b/tests/test_auth_exports.py new file mode 100644 index 00000000..3caaba18 --- /dev/null +++ b/tests/test_auth_exports.py @@ -0,0 +1,88 @@ +"""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, + GopherAuthBaseError, + InsufficientScopesError, + TokenExchangeError, + TokenValidationError, + has_all_scopes, + has_any_scope, + has_scope, + ) + from gopher_mcp_python.auth import GopherAuthError + + assert GopherAuth is not None + assert GopherAuthBaseError is GopherAuthError + assert issubclass(InsufficientScopesError, GopherAuthBaseError) + assert issubclass(TokenExchangeError, GopherAuthBaseError) + assert issubclass(TokenValidationError, GopherAuthBaseError) + 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) From 1a1fe2026663b40750efa0e9506b076c0c0f0bf0 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Sun, 5 Jul 2026 09:26:19 +0800 Subject: [PATCH 06/15] Improve Python build script parity Summary:\n- add target parsing and resolved native output directories for macOS and Linux builds\n- preserve local gopher-orch and gopher-mcp submodule checkouts instead of forcing remote updates\n- install native outputs under native/ while maintaining native/current and native/lib compatibility paths --- build.sh | 220 ++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 195 insertions(+), 25 deletions(-) diff --git a/build.sh b/build.sh index dbc6b9b1..9efe94ab 100755 --- a/build.sh +++ b/build.sh @@ -6,18 +6,126 @@ 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" +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 +215,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 +232,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 +256,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 @@ -122,7 +275,13 @@ fi # 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 +294,9 @@ 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 (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 @@ -161,7 +320,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 +336,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 @@ -204,8 +363,19 @@ 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}" From abf1440eff7eadb8d4966109782ae636972c0611 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Sun, 5 Jul 2026 10:30:10 +0800 Subject: [PATCH 07/15] Support Linux native builds for Python SDK Add a Docker-based linux-x64 build path on macOS, preserve local submodule checkouts during builds, and bundle Linux shared-library dependencies for portable artifacts. Update API example runners to install the published PyPI SDK/native packages through the venv Python so Debian environments do not accidentally invoke a system pip. --- build.sh | 107 ++++++++++++++++++- examples/api/_run_common.sh | 8 ++ scripts/docker/Dockerfile.linux-x64-ubuntu20 | 25 +++++ scripts/docker/build-linux-x64-ubuntu20.sh | 105 ++++++++++++++++++ 4 files changed, 241 insertions(+), 4 deletions(-) create mode 100644 scripts/docker/Dockerfile.linux-x64-ubuntu20 create mode 100755 scripts/docker/build-linux-x64-ubuntu20.sh diff --git a/build.sh b/build.sh index 9efe94ab..81c57d02 100755 --- a/build.sh +++ b/build.sh @@ -15,6 +15,7 @@ NATIVE_DIR="${SCRIPT_DIR}/third_party/gopher-orch" BUILD_DIR="${NATIVE_DIR}/build" NATIVE_ROOT="${SCRIPT_DIR}/native" ACTIVE_NATIVE_DIR="${NATIVE_ROOT}/current" +LINUX_X64_DOCKERFILE="${SCRIPT_DIR}/scripts/docker/Dockerfile.linux-x64-ubuntu20" REQUESTED_TARGET="" RESOLVED_TARGET="" TARGET_NATIVE_DIR="" @@ -27,7 +28,7 @@ Usage: ./build.sh [target] [--clean] [--build] Targets: macos Build the local macOS native library (default on macOS) - linux Build the local Linux native library (default on Linux) + linux Build Linux x64 native library locally on Linux or with Docker on macOS linux-x64 Same as linux Options: @@ -106,8 +107,8 @@ detect_host_target() { RESOLVED_TARGET="${REQUESTED_TARGET}" ;; linux|linux-x64) - if [ "$os" != "Linux" ]; then - echo -e "${RED}Error: Linux target must be built on Linux for the Python SDK.${NC}" + if [ "$os" != "Linux" ] && [ "$os" != "Darwin" ]; then + echo -e "${RED}Error: Linux target must be built on Linux or macOS with Docker.${NC}" exit 1 fi RESOLVED_TARGET="linux-x64" @@ -271,6 +272,82 @@ 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 \ + -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 \ + 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. @@ -294,7 +371,11 @@ 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 || nm -g "${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 ${TARGET_NATIVE_DIR}/lib/ first)" SKIP_NATIVE_BUILD=true @@ -305,6 +386,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}" @@ -358,6 +442,7 @@ 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 @@ -386,6 +471,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 @@ -398,6 +485,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}" @@ -431,9 +522,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}" @@ -457,6 +554,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/scripts/docker/Dockerfile.linux-x64-ubuntu20 b/scripts/docker/Dockerfile.linux-x64-ubuntu20 new file mode 100644 index 00000000..367c397b --- /dev/null +++ b/scripts/docker/Dockerfile.linux-x64-ubuntu20 @@ -0,0 +1,25 @@ +# 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. +FROM ubuntu:20.04 + +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..7ab72af5 --- /dev/null +++ b/scripts/docker/build-linux-x64-ubuntu20.sh @@ -0,0 +1,105 @@ +#!/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 + libc.so*|libm.so*|libdl.so*|librt.so*|libpthread.so*|linux-vdso*|ld-linux*|libstdc++*|libgcc_s*|libresolv*|libnss*|libnsl*) 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" 2>/dev/null || true +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/ From 404e9a0e5b3eac0c7f706f5f5f02e503be85d79f Mon Sep 17 00:00:00 2001 From: RahulHere Date: Sun, 5 Jul 2026 10:37:33 +0800 Subject: [PATCH 08/15] Clarify Python packaging requirements Document that Python 3.8+ is the base requirement, while venv and pip are needed for PyPI installation, examples, and development workflows. Add Debian/Ubuntu package guidance and use python3 -m pip in the source install command. --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5da09b37..2f14b884 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,16 @@ 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 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 ### From Source @@ -34,7 +42,7 @@ cd gopher-mcp-python 3. Install the Python package: ```bash -pip install -e . +python3 -m pip install -e . ``` ## Quick Start From ed5853832c7f2ab24153232eed947ab6d40e3e43 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Thu, 30 Jul 2026 23:28:11 +0800 Subject: [PATCH 09/15] Fix Python debug hint in agent errors Summary:\n- replace stale GOPHER_DEBUG guidance with DEBUG=1\n- mention Python native library load diagnostics in the fallback message\n- update error-message coverage to lock the documented env var --- gopher_mcp_python/agent.py | 4 ++-- tests/test_agent_error_message.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/gopher_mcp_python/agent.py b/gopher_mcp_python/agent.py index 65d55bc4..9f43664c 100644 --- a/gopher_mcp_python/agent.py +++ b/gopher_mcp_python/agent.py @@ -505,6 +505,6 @@ def _build_create_error_message() -> str: "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 GOPHER_DEBUG=1 to " - "see native-side logs." + "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/tests/test_agent_error_message.py b/tests/test_agent_error_message.py index 524131e2..45aa5698 100644 --- a/tests/test_agent_error_message.py +++ b/tests/test_agent_error_message.py @@ -56,7 +56,7 @@ def test_create_failure_uses_actionable_fallback(monkeypatch) -> None: assert "native library returned null without a specific error" in str( exc_info.value ) - assert "Set GOPHER_DEBUG=1" in str(exc_info.value) + assert "Set DEBUG=1" in str(exc_info.value) assert fake.cleared is True From d862666801591d4475d15c937735906ef565be28 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Thu, 30 Jul 2026 23:34:32 +0800 Subject: [PATCH 10/15] Document run null-response behavior Summary:\n- add an Unreleased changelog entry for the GopherAgent.run behavior change\n- note that null native responses now raise AgentError instead of returning a sentinel string --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) 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 From 5ef81063115250f26a93b7570057308edf868ca6 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Thu, 30 Jul 2026 23:48:42 +0800 Subject: [PATCH 11/15] Avoid bundling OpenSSL in Linux wheels Summary:\n- skip libssl and libcrypto when copying Linux native package dependencies\n- keep TLS libraries system-provided so distro security updates apply\n- add regression coverage for the Linux dependency skip policy --- scripts/docker/build-linux-x64-ubuntu20.sh | 4 +++- tests/test_linux_native_packaging.py | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 tests/test_linux_native_packaging.py diff --git a/scripts/docker/build-linux-x64-ubuntu20.sh b/scripts/docker/build-linux-x64-ubuntu20.sh index 7ab72af5..940bde35 100755 --- a/scripts/docker/build-linux-x64-ubuntu20.sh +++ b/scripts/docker/build-linux-x64-ubuntu20.sh @@ -56,7 +56,9 @@ for dylib in /tmp/output/libgopher-*.so*; do dep_path=$(echo "$line" | sed 's/.*=> //' | sed 's/ (.*//' | tr -d '[:space:]') dep_name=$(basename "$dep_path") case "$dep_name" in - libc.so*|libm.so*|libdl.so*|librt.so*|libpthread.so*|linux-vdso*|ld-linux*|libstdc++*|libgcc_s*|libresolv*|libnss*|libnsl*) continue ;; + # 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" diff --git a/tests/test_linux_native_packaging.py b/tests/test_linux_native_packaging.py new file mode 100644 index 00000000..e56935f3 --- /dev/null +++ b/tests/test_linux_native_packaging.py @@ -0,0 +1,18 @@ +"""Regression tests for Linux native package dependency policy.""" + +import re +from pathlib import Path + + +def test_linux_x64_builder_does_not_bundle_openssl() -> None: + script = ( + Path(__file__).resolve().parents[1] + / "scripts" + / "docker" + / "build-linux-x64-ubuntu20.sh" + ).read_text() + 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") From a91827417dc6b64f05c5a09f6db63d59a20f62c3 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Thu, 30 Jul 2026 23:53:30 +0800 Subject: [PATCH 12/15] Verify Linux native package dependencies Summary:\n- make Linux patchelf rpath updates fail the Docker build on error\n- add linux-x64 publish workflow checks for ORIGIN rpath and missing dependencies\n- fail publishing if OpenSSL libraries are bundled into the native package\n- document Linux OpenSSL as a system-provided runtime dependency\n- expand packaging policy tests for rpath and dependency verification --- .github/workflows/publish-packages.yml | 34 ++++++++++++++++++++++ README.md | 3 ++ scripts/docker/build-linux-x64-ubuntu20.sh | 2 +- tests/test_linux_native_packaging.py | 33 +++++++++++++++++---- 4 files changed, 65 insertions(+), 7 deletions(-) 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/README.md b/README.md index 2f14b884..e392010f 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,9 @@ Python SDK for gopher-mcp-python, providing AI agent orchestration with native C - 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: diff --git a/scripts/docker/build-linux-x64-ubuntu20.sh b/scripts/docker/build-linux-x64-ubuntu20.sh index 940bde35..95966d84 100755 --- a/scripts/docker/build-linux-x64-ubuntu20.sh +++ b/scripts/docker/build-linux-x64-ubuntu20.sh @@ -71,7 +71,7 @@ done for sofile in /tmp/output/*.so /tmp/output/*.so.*; do [ -L "$sofile" ] && continue [ -f "$sofile" ] || continue - patchelf --set-rpath '$ORIGIN' "$sofile" 2>/dev/null || true + patchelf --set-rpath '$ORIGIN' "$sofile" done echo "=== Bundling complete ===" diff --git a/tests/test_linux_native_packaging.py b/tests/test_linux_native_packaging.py index e56935f3..578840f3 100644 --- a/tests/test_linux_native_packaging.py +++ b/tests/test_linux_native_packaging.py @@ -4,15 +4,36 @@ 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 test_linux_x64_builder_does_not_bundle_openssl() -> None: - script = ( - Path(__file__).resolve().parents[1] - / "scripts" - / "docker" - / "build-linux-x64-ubuntu20.sh" - ).read_text() + 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 From 11f629f5bcc9c5a394c5b5b5cdf24842200539ee Mon Sep 17 00:00:00 2001 From: RahulHere Date: Thu, 30 Jul 2026 23:57:49 +0800 Subject: [PATCH 13/15] Fix versioned native library resolution Summary: - prefer exact unversioned native library names before scanning versioned files - sort Linux versioned .so candidates by parsed numeric version - support macOS versioned dylib names with the version before .dylib - add regression tests for unversioned preference and versioned fallback selection --- gopher_mcp_python/ffi/library.py | 46 +++++++++++++++++++++++++++--- tests/test_library_search_paths.py | 37 ++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/gopher_mcp_python/ffi/library.py b/gopher_mcp_python/ffi/library.py index 9c1caaf6..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 ( @@ -236,12 +237,19 @@ def _resolve_library_path(self, candidate: str, library_name: str) -> Optional[s if os.path.exists(direct): return direct - matches = sorted( + matches = [ name for name in os.listdir(candidate) - if name == library_name or name.startswith(f"{library_name}.") + 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]) if matches else None + return os.path.join(candidate, matches[0]) def _record_load_error(self, message: str) -> None: self._load_errors.append(message) @@ -868,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/tests/test_library_search_paths.py b/tests/test_library_search_paths.py index ddcf9a7b..965217d9 100644 --- a/tests/test_library_search_paths.py +++ b/tests/test_library_search_paths.py @@ -57,6 +57,19 @@ def test_resolves_environment_library_directory(tmp_path): ) +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) @@ -68,6 +81,30 @@ def test_resolves_versioned_library_in_directory(tmp_path): ) +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 = [] From 10ec6444ab86f65735048b7d4f0fb4dd395dbee3 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Fri, 31 Jul 2026 00:03:43 +0800 Subject: [PATCH 14/15] Clarify auth error exports Summary: - export the high-level auth exception as top-level GopherAuthError - expose the FFI auth enum as GopherAuthFfiError instead of GopherAuthBaseError - lazily resolve auth exports to avoid eager auth FFI imports from package import - update auth export tests for the corrected public contract --- gopher_mcp_python/__init__.py | 135 ++++++++++++++++++++-------------- tests/test_auth_exports.py | 15 ++-- 2 files changed, 87 insertions(+), 63 deletions(-) diff --git a/gopher_mcp_python/__init__.py b/gopher_mcp_python/__init__.py index 3ea0d760..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,63 +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, - AutoRefreshResult, - RegistrationResponse, - TokenResponse, - ValidationResult, - TokenPayload, - GopherAuthContext, - ERROR_DESCRIPTIONS, - get_error_description, - gopher_create_empty_auth_context, - is_gopher_auth_error, - # Classes - GopherAuthClient, - GopherAuthConfig, - GopherOAuthClient, - GopherSessionManager, - GopherValidationOptions, - # Functions - 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, -) -from gopher_mcp_python.auth import ( - GopherAuth, - GopherAuthError as GopherAuthBaseError, - ConfigurationError, - InsufficientScopesError, - JwksError, - TokenExchangeError, - TokenValidationError, - has_all_scopes, - has_any_scope, - has_scope, -) - __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", @@ -116,7 +137,9 @@ "GopherOrchLibrary", "GopherOrchHandle", # Auth + "GopherAuth", "GopherAuthError", + "GopherAuthFfiError", "AutoRefreshResult", "RegistrationResponse", "TokenResponse", @@ -152,8 +175,6 @@ "gopher_is_auth_library_initialized", "gopher_shutdown_auth_library", "is_auth_available", - "GopherAuth", - "GopherAuthBaseError", "ConfigurationError", "InsufficientScopesError", "JwksError", diff --git a/tests/test_auth_exports.py b/tests/test_auth_exports.py index 3caaba18..40ae7639 100644 --- a/tests/test_auth_exports.py +++ b/tests/test_auth_exports.py @@ -36,7 +36,8 @@ def test_root_exports_auth_ffi_helpers(): def test_root_exports_reusable_auth_aliases(): from gopher_mcp_python import ( GopherAuth, - GopherAuthBaseError, + GopherAuthError, + GopherAuthFfiError, InsufficientScopesError, TokenExchangeError, TokenValidationError, @@ -44,13 +45,15 @@ def test_root_exports_reusable_auth_aliases(): has_any_scope, has_scope, ) - from gopher_mcp_python.auth import GopherAuthError + 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 GopherAuthBaseError is GopherAuthError - assert issubclass(InsufficientScopesError, GopherAuthBaseError) - assert issubclass(TokenExchangeError, GopherAuthBaseError) - assert issubclass(TokenValidationError, GopherAuthBaseError) + 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) From 6a69838e2b9438ccd6d09da5837e45635f6d9e5e Mon Sep 17 00:00:00 2001 From: RahulHere Date: Fri, 31 Jul 2026 00:09:49 +0800 Subject: [PATCH 15/15] Pin Linux builder base image Summary: - pin the Ubuntu 20.04 Linux x64 builder image by digest - pass the pinned base image into the Dockerfile from build.sh - use the same pinned image for Linux artifact verification - add packaging tests to prevent direct mutable ubuntu:20.04 usage --- build.sh | 4 +++- scripts/docker/Dockerfile.linux-x64-ubuntu20 | 3 ++- tests/test_linux_native_packaging.py | 23 ++++++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/build.sh b/build.sh index 81c57d02..8d2bd9c1 100755 --- a/build.sh +++ b/build.sh @@ -16,6 +16,7 @@ BUILD_DIR="${NATIVE_DIR}/build" 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="" @@ -294,6 +295,7 @@ build_linux_x64_docker() { 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}" @@ -343,7 +345,7 @@ verify_linux_x64_docker_output() { --platform linux/amd64 \ -v "${TARGET_NATIVE_DIR}:/work" \ -w /work/lib \ - ubuntu:20.04 \ + "${UBUNTU_20_04_IMAGE}" \ sh -c 'LD_LIBRARY_PATH=/work/lib /work/bin/verify_orch' fi } diff --git a/scripts/docker/Dockerfile.linux-x64-ubuntu20 b/scripts/docker/Dockerfile.linux-x64-ubuntu20 index 367c397b..cd3fe6dc 100644 --- a/scripts/docker/Dockerfile.linux-x64-ubuntu20 +++ b/scripts/docker/Dockerfile.linux-x64-ubuntu20 @@ -1,6 +1,7 @@ # 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. -FROM ubuntu:20.04 +ARG UBUNTU_20_04_IMAGE=ubuntu:20.04@sha256:8feb4d8ca5354def3d8fce243717141ce31e2c428701f6682bd2fafe15388214 +FROM ${UBUNTU_20_04_IMAGE} ENV DEBIAN_FRONTEND=noninteractive diff --git a/tests/test_linux_native_packaging.py b/tests/test_linux_native_packaging.py index 578840f3..fe7de3d9 100644 --- a/tests/test_linux_native_packaging.py +++ b/tests/test_linux_native_packaging.py @@ -11,6 +11,29 @@ 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)