From 68c1dfddf3d4bae50d794e7169710b9e1f85a6a6 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Sun, 5 Jul 2026 09:16:40 +0800 Subject: [PATCH 1/8] 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 4b596eb5..f770808a 100644 --- a/gopher_mcp_python/agent.py +++ b/gopher_mcp_python/agent.py @@ -140,7 +140,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) @@ -380,7 +380,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) @@ -466,3 +466,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 027e7a74..e5ba46a3 100644 --- a/gopher_mcp_python/ffi/library.py +++ b/gopher_mcp_python/ffi/library.py @@ -761,7 +761,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 aaa9ecbc4098a32cd1809cb661a569507e3c635f Mon Sep 17 00:00:00 2001 From: RahulHere Date: Sun, 5 Jul 2026 09:17:32 +0800 Subject: [PATCH 2/8] 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 f770808a..a2dea63f 100644 --- a/gopher_mcp_python/agent.py +++ b/gopher_mcp_python/agent.py @@ -407,8 +407,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 92a02d67a389a0b29f98e9576ef21c76cbbe0d8a Mon Sep 17 00:00:00 2001 From: RahulHere Date: Sun, 5 Jul 2026 09:19:17 +0800 Subject: [PATCH 3/8] 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 a2dea63f..46e6d893 100644 --- a/gopher_mcp_python/agent.py +++ b/gopher_mcp_python/agent.py @@ -77,7 +77,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() @@ -116,7 +119,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: @@ -370,7 +374,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) @@ -402,7 +407,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 e5ba46a3..721db19c 100644 --- a/gopher_mcp_python/ffi/library.py +++ b/gopher_mcp_python/ffi/library.py @@ -117,6 +117,7 @@ class GopherOrchLibrary: _debug: bool = False def __init__(self) -> None: + self._load_errors = [] self._load_library() @classmethod @@ -136,28 +137,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: @@ -169,6 +190,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 @@ -181,6 +203,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) @@ -189,6 +214,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 a4642a31..d812f52e 100644 --- a/tests/test_library_search_paths.py +++ b/tests/test_library_search_paths.py @@ -19,3 +19,45 @@ def test_prefers_local_native_lib_before_platform_package(monkeypatch): assert paths.index(os.path.join(os.getcwd(), "native", "lib")) < paths.index( "/tmp/gopher-platform-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 b067ed87e699a9648f17d9be709b43b7782be913 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Sun, 5 Jul 2026 09:20:47 +0800 Subject: [PATCH 4/8] 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 | 33 ++++++++++++++++++++++++++-- gopher_mcp_python/ffi/library.py | 33 ++++++++++++++++++++++++---- tests/ffi/auth/test_loader.py | 14 ++++++++++++ tests/test_library_search_paths.py | 14 ++++++++++++ 4 files changed, 88 insertions(+), 6 deletions(-) diff --git a/gopher_mcp_python/ffi/auth/loader.py b/gopher_mcp_python/ffi/auth/loader.py index 150cff05..e82c73f0 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. @@ -119,14 +141,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. Prefer these so local runs use the library produced by - # ./build.sh before any installed platform package. + # Development paths. Prefer platform-specific and active local outputs so + # cross-built artifacts can coexist before any installed platform package. 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 721db19c..777bf11f 100644 --- a/gopher_mcp_python/ffi/library.py +++ b/gopher_mcp_python/ffi/library.py @@ -467,20 +467,45 @@ 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 = [] # 1. 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). Prefer these so - # examples and tests use the library produced by ./build.sh. + # Development paths. Prefer platform-specific and active local outputs + # so cross-built artifacts can coexist without changing environment vars. 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 279ce747..4db0b468 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_local_native_lib_before_platform_package(self, monkeypatch): platform_path = "/tmp/gopher-platform-native/lib" assert paths.index(local_path) < paths.index(platform_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 d812f52e..1c7767b7 100644 --- a/tests/test_library_search_paths.py +++ b/tests/test_library_search_paths.py @@ -21,6 +21,20 @@ def test_prefers_local_native_lib_before_platform_package(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 2ceb99ebdd794652cdb78e5ea805421ec7ece9d2 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Sun, 5 Jul 2026 09:23:36 +0800 Subject: [PATCH 5/8] 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 0cdfbdd4..5a536669 100644 --- a/gopher_mcp_python/__init__.py +++ b/gopher_mcp_python/__init__.py @@ -42,17 +42,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" @@ -76,14 +115,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 26a13afffcb57894f6072f82ba5ff20022e2ae10 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Sun, 5 Jul 2026 09:26:19 +0800 Subject: [PATCH 6/8] 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 | 217 ++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 191 insertions(+), 26 deletions(-) diff --git a/build.sh b/build.sh index e2f2c64a..20b3d7ed 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}" @@ -65,13 +206,16 @@ if [ -d "${NATIVE_DIR}" ] && [ ! -f "${NATIVE_DIR}/CMakeLists.txt" ]; then rm -rf .git/modules/third_party/gopher-orch 2>/dev/null || true fi -# Update main submodule to the latest commit on the branch configured in -# .gitmodules (currently br_release), not just the parent-recorded SHA. -if ! git submodule update --init --remote --checkout third_party/gopher-orch; then - echo -e "${RED}Error: Failed to update gopher-orch submodule to latest remote branch${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 --init third_party/gopher-orch 2>/dev/null; then + echo -e "${YELLOW} Recorded commit not found, fetching latest from remote...${NC}" + if ! git submodule update --init --remote third_party/gopher-orch 2>/dev/null; then + echo -e "${RED}Error: Failed to clone 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 fi # Update nested submodule (gopher-mcp inside gopher-orch) @@ -82,12 +226,16 @@ 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 gopher-mcp on the latest commit from its configured release branch. - if ! git submodule update --init --remote --checkout third_party/gopher-mcp; then - echo -e "${RED}Error: Failed to update gopher-mcp submodule to latest remote branch${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 + git submodule update --init --checkout third_party/gopher-mcp 2>/dev/null || true fi # Also update gopher-mcp's nested submodules recursively if [ -d "third_party/gopher-mcp" ]; then @@ -95,7 +243,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 @@ -114,7 +262,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 @@ -127,9 +281,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 @@ -153,7 +307,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 \ @@ -169,7 +323,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 @@ -196,8 +350,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 b30fdf26bfc6feb1eadc46e3390d0382e178de30 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Sun, 5 Jul 2026 10:30:10 +0800 Subject: [PATCH 7/8] 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/create_by_api_key_run.sh | 21 +++- examples/api/create_by_url_run.sh | 21 +++- scripts/docker/Dockerfile.linux-x64-ubuntu20 | 25 +++++ scripts/docker/build-linux-x64-ubuntu20.sh | 105 ++++++++++++++++++ third_party/gopher-orch | 2 +- 6 files changed, 266 insertions(+), 15 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 20b3d7ed..051ef122 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" @@ -258,6 +259,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. @@ -281,7 +358,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 @@ -292,6 +373,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}" @@ -345,6 +429,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 @@ -373,6 +458,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 @@ -385,6 +472,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}" @@ -418,9 +509,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}" @@ -444,6 +541,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/create_by_api_key_run.sh b/examples/api/create_by_api_key_run.sh index 3dfd1a97..db13498e 100755 --- a/examples/api/create_by_api_key_run.sh +++ b/examples/api/create_by_api_key_run.sh @@ -75,25 +75,36 @@ python3 -m venv venv # shellcheck disable=SC1091 source venv/bin/activate +VENV_PYTHON="$WORK_DIR/venv/bin/python" +if [ ! -x "$VENV_PYTHON" ]; then + VENV_PYTHON="$(command -v python3)" +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 or python3-pip, then rerun this script.${NC}" + exit 1 +fi + echo -e "${YELLOW}Installing gopher-mcp-python from PyPI...${NC}" if [ -n "$SDK_VERSION" ]; then echo -e "${CYAN}Installing version: $SDK_VERSION${NC}" - pip install --quiet "gopher-mcp-python==$SDK_VERSION" \ - "${NATIVE_PACKAGE}==$SDK_VERSION" + "$VENV_PYTHON" -m pip install --quiet "gopher-mcp-python==$SDK_VERSION" \ + "${NATIVE_PACKAGE}==$SDK_VERSION" else echo -e "${CYAN}Installing latest published version${NC}" - pip install --quiet gopher-mcp-python "$NATIVE_PACKAGE" + "$VENV_PYTHON" -m pip install --quiet gopher-mcp-python "$NATIVE_PACKAGE" fi echo -e "${CYAN}Installed packages:${NC}" -pip list | grep -i gopher || true +"$VENV_PYTHON" -m pip list | grep -i gopher || true cp "$SCRIPT_DIR/create_by_api_key.py" . echo "" echo -e "${YELLOW}Running example...${NC}" echo "" -python create_by_api_key.py "$@" +"$VENV_PYTHON" create_by_api_key.py "$@" echo "" echo -e "${GREEN}Example completed${NC}" diff --git a/examples/api/create_by_url_run.sh b/examples/api/create_by_url_run.sh index 4bf33cd6..3048308e 100755 --- a/examples/api/create_by_url_run.sh +++ b/examples/api/create_by_url_run.sh @@ -76,25 +76,36 @@ python3 -m venv venv # shellcheck disable=SC1091 source venv/bin/activate +VENV_PYTHON="$WORK_DIR/venv/bin/python" +if [ ! -x "$VENV_PYTHON" ]; then + VENV_PYTHON="$(command -v python3)" +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 or python3-pip, then rerun this script.${NC}" + exit 1 +fi + echo -e "${YELLOW}Installing gopher-mcp-python from PyPI...${NC}" if [ -n "$SDK_VERSION" ]; then echo -e "${CYAN}Installing version: $SDK_VERSION${NC}" - pip install --quiet "gopher-mcp-python==$SDK_VERSION" \ - "${NATIVE_PACKAGE}==$SDK_VERSION" + "$VENV_PYTHON" -m pip install --quiet "gopher-mcp-python==$SDK_VERSION" \ + "${NATIVE_PACKAGE}==$SDK_VERSION" else echo -e "${CYAN}Installing latest published version${NC}" - pip install --quiet gopher-mcp-python "$NATIVE_PACKAGE" + "$VENV_PYTHON" -m pip install --quiet gopher-mcp-python "$NATIVE_PACKAGE" fi echo -e "${CYAN}Installed packages:${NC}" -pip list | grep -i gopher || true +"$VENV_PYTHON" -m pip list | grep -i gopher || true cp "$SCRIPT_DIR/create_by_url.py" . echo "" echo -e "${YELLOW}Running example...${NC}" echo "" -python create_by_url.py "$@" +"$VENV_PYTHON" create_by_url.py "$@" echo "" echo -e "${GREEN}Example completed${NC}" 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/ diff --git a/third_party/gopher-orch b/third_party/gopher-orch index c78ca8cc..abc58713 160000 --- a/third_party/gopher-orch +++ b/third_party/gopher-orch @@ -1 +1 @@ -Subproject commit c78ca8cc1fb787d7ba45dbf52b67931cb69f1986 +Subproject commit abc587138fa748164ad99de442c9ab2e7246bbae From 5e658fb08e9d14ee7f3eaaf49d7c4225b4332c05 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Sun, 5 Jul 2026 10:37:33 +0800 Subject: [PATCH 8/8] 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