From ed14ea66ee0b1f7bea89750bd7c4959f29b4fbc6 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Thu, 13 Aug 2026 20:50:20 +0800 Subject: [PATCH 01/13] Fix native owned string cleanup (#15) Summary: - Preserve native-owned char pointers as c_void_p for agent and API responses. - Decode owned strings with ctypes.string_at and release them through gopher_orch_free. - Cover owned response freeing and null-return behavior in FFI tests. --- gopher_mcp_python/ffi/library.py | 21 ++++++---- tests/test_ffi.py | 67 ++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 8 deletions(-) diff --git a/gopher_mcp_python/ffi/library.py b/gopher_mcp_python/ffi/library.py index c17dc6ef..6e253413 100644 --- a/gopher_mcp_python/ffi/library.py +++ b/gopher_mcp_python/ffi/library.py @@ -353,7 +353,7 @@ def _setup_functions(self) -> None: self._bind_optional_agent_options_symbol(options_name, options_argtypes) self._lib.gopher_orch_agent_run.argtypes = [c_void_p, c_char_p, c_int64] - self._lib.gopher_orch_agent_run.restype = c_char_p + self._lib.gopher_orch_agent_run.restype = c_void_p self._lib.gopher_orch_agent_add_ref.argtypes = [c_void_p] self._lib.gopher_orch_agent_add_ref.restype = None @@ -363,7 +363,7 @@ def _setup_functions(self) -> None: # API functions self._lib.gopher_orch_api_fetch_servers.argtypes = [c_char_p] - self._lib.gopher_orch_api_fetch_servers.restype = c_char_p + self._lib.gopher_orch_api_fetch_servers.restype = c_void_p # Error functions self._lib.gopher_orch_last_error.argtypes = [] @@ -794,9 +794,7 @@ def agent_run( result = self._lib.gopher_orch_agent_run( agent, query.encode("utf-8"), timeout_ms ) - if result: - return result.decode("utf-8") - return None + return self._decode_owned_c_string(result) def agent_add_ref(self, agent: GopherOrchHandle) -> None: """Add a reference to the agent.""" @@ -814,9 +812,7 @@ def api_fetch_servers(self, api_key: str) -> Optional[str]: if not self._available or self._lib is None: return None result = self._lib.gopher_orch_api_fetch_servers(api_key.encode("utf-8")) - if result: - return result.decode("utf-8") - return None + return self._decode_owned_c_string(result) # Error functions def last_error(self) -> Optional[GopherOrchErrorInfo]: @@ -849,6 +845,15 @@ def free(self, ptr: Any) -> None: if self._available and self._lib is not None: self._lib.gopher_orch_free(ptr) + def _decode_owned_c_string(self, ptr: Any) -> Optional[str]: + """Decode an owned native string and always release it.""" + if not ptr: + return None + try: + return ctypes.string_at(ptr).decode("utf-8") + finally: + self.free(ptr) + def set_log_level(self, level: int) -> None: """ Set the global log level for the native library. diff --git a/tests/test_ffi.py b/tests/test_ffi.py index c78b00bb..e85a9c1c 100644 --- a/tests/test_ffi.py +++ b/tests/test_ffi.py @@ -80,6 +80,73 @@ def __getattr__(self, name): c_char_p, c_char_p, ] + assert fake_lib.gopher_orch_agent_run.restype is c_void_p + assert fake_lib.gopher_orch_api_fetch_servers.restype is c_void_p + + def test_agent_run_decodes_and_frees_owned_string(self): + """Owned run responses must be released with gopher_orch_free.""" + + class FakeLib: + def __init__(self): + self.buffer = b"agent response" + self.freed = [] + + def gopher_orch_agent_run(self, agent, query, timeout_ms): + return c_char_p(self.buffer) + + def gopher_orch_free(self, ptr): + self.freed.append(ptr) + + fake_lib = FakeLib() + lib = GopherOrchLibrary.__new__(GopherOrchLibrary) + lib._available = True + lib._lib = fake_lib + + assert lib.agent_run(c_void_p(123), "hello", 1000) == "agent response" + assert len(fake_lib.freed) == 1 + + def test_api_fetch_servers_decodes_and_frees_owned_string(self): + """Owned API config responses must be released with gopher_orch_free.""" + + class FakeLib: + def __init__(self): + self.buffer = b'{"succeeded":true}' + self.freed = [] + + def gopher_orch_api_fetch_servers(self, api_key): + return c_char_p(self.buffer) + + def gopher_orch_free(self, ptr): + self.freed.append(ptr) + + fake_lib = FakeLib() + lib = GopherOrchLibrary.__new__(GopherOrchLibrary) + lib._available = True + lib._lib = fake_lib + + assert lib.api_fetch_servers("key") == '{"succeeded":true}' + assert len(fake_lib.freed) == 1 + + def test_owned_string_null_return_is_not_freed(self): + """Null native string returns should stay None without a free call.""" + + class FakeLib: + def __init__(self): + self.freed = [] + + def gopher_orch_agent_run(self, agent, query, timeout_ms): + return None + + def gopher_orch_free(self, ptr): + self.freed.append(ptr) + + fake_lib = FakeLib() + lib = GopherOrchLibrary.__new__(GopherOrchLibrary) + lib._available = True + lib._lib = fake_lib + + assert lib.agent_run(c_void_p(123), "hello", 1000) is None + assert fake_lib.freed == [] def test_missing_optional_routing_symbol_raises_upgrade_error(self): """Absent routing factories should not look like native NULL returns.""" From ffb122de114c2c0b667d921c51558919b9d80521 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Thu, 13 Aug 2026 20:52:50 +0800 Subject: [PATCH 02/13] Fix agent options ABI (#15) Summary: - Add server option fields to the Python ctypes agent options struct. - Preserve global runtime option behavior with null server options and zero count. - Add regression coverage for native field order and default server-option values. --- gopher_mcp_python/ffi/library.py | 6 ++++++ tests/test_ffi_runtime_options.py | 17 +++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/gopher_mcp_python/ffi/library.py b/gopher_mcp_python/ffi/library.py index 6e253413..5bf5d7a7 100644 --- a/gopher_mcp_python/ffi/library.py +++ b/gopher_mcp_python/ffi/library.py @@ -64,6 +64,8 @@ class GopherOrchAgentOptions(Structure): const char* access_token; const gopher_orch_header_t* headers; gopher_orch_size_t header_count; + const gopher_orch_server_agent_options_t* server_options; + gopher_orch_size_t server_option_count; } gopher_orch_agent_options_t; """ @@ -71,6 +73,8 @@ class GopherOrchAgentOptions(Structure): ("access_token", c_char_p), ("headers", POINTER(GopherOrchHeader)), ("header_count", c_size_t), + ("server_options", c_void_p), + ("server_option_count", c_size_t), ] @@ -107,6 +111,8 @@ def __init__(self, options: GopherAgentRuntimeOptions) -> None: access_token_bytes, headers_ptr, self.header_count, + None, + 0, ) @property diff --git a/tests/test_ffi_runtime_options.py b/tests/test_ffi_runtime_options.py index 3d76ba85..93368455 100644 --- a/tests/test_ffi_runtime_options.py +++ b/tests/test_ffi_runtime_options.py @@ -37,6 +37,20 @@ def _read_options(options_ptr): return access_token, headers +def _read_raw_options(options_ptr): + return ctypes.cast(options_ptr, ctypes.POINTER(GopherOrchAgentOptions)).contents + + +def test_agent_options_struct_matches_native_field_order() -> None: + assert [name for name, _ctype in GopherOrchAgentOptions._fields_] == [ + "access_token", + "headers", + "header_count", + "server_options", + "server_option_count", + ] + + def test_build_agent_options_maps_access_token_and_headers() -> None: lib = object.__new__(GopherOrchLibrary) @@ -45,12 +59,15 @@ def test_build_agent_options_maps_access_token_and_headers() -> None: ) assert storage is not None + raw_options = _read_raw_options(storage.pointer) access_token, headers = _read_options(storage.pointer) assert access_token == "abc123" assert headers == { "X-Trace": "trace-1", "Authorization": "Bearer abc123", } + assert not raw_options.server_options + assert raw_options.server_option_count == 0 def test_build_agent_options_normalizes_empty_options_to_none() -> None: From c309e4cb8d05765b6c2e010b3b8bcfdae11167f5 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Thu, 13 Aug 2026 20:57:04 +0800 Subject: [PATCH 03/13] Fix OAuth create option types (#15) Summary: - Add OAuth create options, token records, and token-store protocol types. - Preserve runtime option compatibility while carrying SDK OAuth metadata. - Export OAuth create types and cover normalization and validation behavior. --- gopher_mcp_python/__init__.py | 8 ++ gopher_mcp_python/config.py | 37 +++-- gopher_mcp_python/runtime_options.py | 195 ++++++++++++++++++++++++++- tests/test_config.py | 68 ++++++++++ 4 files changed, 295 insertions(+), 13 deletions(-) diff --git a/gopher_mcp_python/__init__.py b/gopher_mcp_python/__init__.py index 34c3da15..d0cb40c0 100644 --- a/gopher_mcp_python/__init__.py +++ b/gopher_mcp_python/__init__.py @@ -30,7 +30,11 @@ GopherAgentConfigBuilder, ) from gopher_mcp_python.runtime_options import ( + GopherAgentCreateOptions, + GopherAgentOAuthOptions, GopherAgentRuntimeOptions, + GopherAgentTokenRecord, + GopherAgentTokenStore, ) from gopher_mcp_python.result import AgentResult, AgentResultStatus, AgentResultBuilder from gopher_mcp_python.errors import ( @@ -123,7 +127,11 @@ def __getattr__(name: str): "GopherAgent", "GopherAgentConfig", "GopherAgentConfigBuilder", + "GopherAgentCreateOptions", + "GopherAgentOAuthOptions", "GopherAgentRuntimeOptions", + "GopherAgentTokenRecord", + "GopherAgentTokenStore", "AgentResult", "AgentResultStatus", "AgentResultBuilder", diff --git a/gopher_mcp_python/config.py b/gopher_mcp_python/config.py index 5b6c672c..1df16707 100644 --- a/gopher_mcp_python/config.py +++ b/gopher_mcp_python/config.py @@ -7,8 +7,13 @@ from typing import Mapping, Optional from gopher_mcp_python.runtime_options import ( + GopherAgentCreateOptions, + GopherAgentOAuthOptions, GopherAgentRuntimeOptions, + GopherAgentTokenRecord, + GopherAgentTokenStore, RuntimeOptionsInput, + normalize_create_options, normalize_runtime_options, ) @@ -59,7 +64,7 @@ def __init__( self._model = model self._api_key = api_key self._server_config = server_config - self._runtime_options = normalize_runtime_options(runtime_options) + self._runtime_options = normalize_create_options(runtime_options) @property def provider(self) -> str: @@ -82,8 +87,8 @@ def server_config(self) -> Optional[str]: return self._server_config @property - def runtime_options(self) -> Optional[GopherAgentRuntimeOptions]: - """Get dynamic MCP runtime options.""" + def runtime_options(self) -> Optional[GopherAgentCreateOptions]: + """Get dynamic MCP runtime and SDK OAuth options.""" return self._runtime_options def has_api_key(self) -> bool: @@ -112,7 +117,7 @@ def __init__(self) -> None: self._model: Optional[str] = None self._api_key: Optional[str] = None self._server_config: Optional[str] = None - self._runtime_options: Optional[GopherAgentRuntimeOptions] = None + self._runtime_options: Optional[GopherAgentCreateOptions] = None def provider(self, provider: str) -> "GopherAgentConfigBuilder": """ @@ -182,7 +187,7 @@ def runtime_options( Returns: self for chaining """ - self._runtime_options = normalize_runtime_options(options) + self._runtime_options = normalize_create_options(options) return self def access_token(self, access_token: str) -> "GopherAgentConfigBuilder": @@ -198,8 +203,15 @@ def access_token(self, access_token: str) -> "GopherAgentConfigBuilder": current_headers = ( self._runtime_options.headers if self._runtime_options is not None else None ) - self._runtime_options = normalize_runtime_options( - {"access_token": access_token, "headers": current_headers} + current_oauth = ( + self._runtime_options.oauth if self._runtime_options is not None else None + ) + self._runtime_options = normalize_create_options( + { + "access_token": access_token, + "headers": current_headers, + "oauth": current_oauth, + } ) return self @@ -218,8 +230,15 @@ def headers(self, headers: Mapping[str, str]) -> "GopherAgentConfigBuilder": if self._runtime_options is not None else None ) - self._runtime_options = normalize_runtime_options( - {"access_token": current_token, "headers": headers} + current_oauth = ( + self._runtime_options.oauth if self._runtime_options is not None else None + ) + self._runtime_options = normalize_create_options( + { + "access_token": current_token, + "headers": headers, + "oauth": current_oauth, + } ) return self diff --git a/gopher_mcp_python/runtime_options.py b/gopher_mcp_python/runtime_options.py index 36befd1b..ee8e7f59 100644 --- a/gopher_mcp_python/runtime_options.py +++ b/gopher_mcp_python/runtime_options.py @@ -6,11 +6,117 @@ import cycle. """ -from typing import Any, Dict, Mapping, Optional, Union +from typing import Any, Dict, List, Mapping, Optional, Protocol, Union RuntimeOptionsInput = Optional[ - Union["GopherAgentRuntimeOptions", Mapping[str, Any]] + Union["GopherAgentRuntimeOptions", "GopherAgentCreateOptions", Mapping[str, Any]] ] +CreateOptionsInput = Optional[Union["GopherAgentCreateOptions", Mapping[str, Any]]] + + +class GopherAgentTokenRecord: + """OAuth token record stored by SDK-side token stores.""" + + def __init__( + self, + access_token: str, + token_type: str = "Bearer", + refresh_token: Optional[str] = None, + expires_at: Optional[float] = None, + scope: Optional[str] = None, + ) -> None: + if not isinstance(access_token, str) or not access_token: + raise ValueError("token access_token must be a non-empty string") + if not isinstance(token_type, str) or not token_type: + raise ValueError("token token_type must be a non-empty string") + if refresh_token is not None and not isinstance(refresh_token, str): + raise ValueError("token refresh_token must be a string") + if expires_at is not None and not isinstance(expires_at, (int, float)): + raise ValueError("token expires_at must be a number") + if scope is not None and not isinstance(scope, str): + raise ValueError("token scope must be a string") + self.access_token = access_token + self.token_type = token_type + self.refresh_token = refresh_token + self.expires_at = float(expires_at) if expires_at is not None else None + self.scope = scope + + +class GopherAgentTokenStore(Protocol): + """Async token store protocol used by SDK OAuth auto-flow.""" + + async def get(self, key: str) -> Optional[GopherAgentTokenRecord]: + """Return a cached token record for key, if any.""" + + async def set(self, key: str, token: GopherAgentTokenRecord) -> None: + """Store a token record for key.""" + + async def delete(self, key: str) -> None: + """Delete a cached token record for key, if supported.""" + + +class GopherAgentOAuthOptions: + """OAuth options for SDK-side agent creation.""" + + def __init__( + self, + mode: Optional[str] = None, + scopes: Optional[List[str]] = None, + client_name: Optional[str] = None, + redirect_uri: Optional[str] = None, + open_browser: Optional[bool] = None, + token_store: Optional[GopherAgentTokenStore] = None, + ) -> None: + if mode is not None and mode not in ("auto", "disabled"): + raise ValueError('oauth mode must be "auto" or "disabled"') + if scopes is not None: + if not isinstance(scopes, list) or not all( + isinstance(scope, str) for scope in scopes + ): + raise ValueError("oauth scopes must be a list of strings") + if client_name is not None and not isinstance(client_name, str): + raise ValueError("oauth client_name must be a string") + if redirect_uri is not None and not isinstance(redirect_uri, str): + raise ValueError("oauth redirect_uri must be a string") + if open_browser is not None and not isinstance(open_browser, bool): + raise ValueError("oauth open_browser must be a boolean") + + self.mode = mode + self.scopes = list(scopes) if scopes is not None else None + self.client_name = client_name + self.redirect_uri = redirect_uri + self.open_browser = open_browser + self.token_store = token_store + + +class GopherAgentCreateOptions: + """Agent creation options: native runtime options plus SDK OAuth options.""" + + def __init__( + self, + access_token: Optional[str] = None, + headers: Optional[Mapping[str, str]] = None, + oauth: Optional[Union[GopherAgentOAuthOptions, Mapping[str, Any]]] = None, + ) -> None: + runtime = GopherAgentRuntimeOptions(access_token=access_token, headers=headers) + self._access_token = runtime.access_token + self._headers = runtime.headers + self._oauth = normalize_oauth_options(oauth) + + @property + def access_token(self) -> Optional[str]: + """Get the MCP runtime bearer token.""" + return self._access_token + + @property + def headers(self) -> Dict[str, str]: + """Get dynamic MCP runtime headers.""" + return dict(self._headers) + + @property + def oauth(self) -> Optional[GopherAgentOAuthOptions]: + """Get SDK-side OAuth options.""" + return self._oauth class GopherAgentRuntimeOptions: @@ -53,10 +159,13 @@ def normalize_runtime_options( if options is None: return None - if isinstance(options, GopherAgentRuntimeOptions): + if isinstance(options, (GopherAgentRuntimeOptions, GopherAgentCreateOptions)): if options.access_token is None and len(options.headers) == 0: return None - return options + return GopherAgentRuntimeOptions( + access_token=options.access_token, + headers=options.headers, + ) if isinstance(options, Mapping): access_token = options.get("access_token") @@ -72,6 +181,84 @@ def normalize_runtime_options( ) +def normalize_create_options( + options: Union[RuntimeOptionsInput, CreateOptionsInput] = None, +) -> Optional[GopherAgentCreateOptions]: + """Normalize agent creation options, preserving optional OAuth options.""" + if options is None: + return None + + if isinstance(options, GopherAgentCreateOptions): + if ( + options.access_token is None + and len(options.headers) == 0 + and options.oauth is None + ): + return None + return options + + if isinstance(options, GopherAgentRuntimeOptions): + if options.access_token is None and len(options.headers) == 0: + return None + return GopherAgentCreateOptions( + access_token=options.access_token, + headers=options.headers, + ) + + if isinstance(options, Mapping): + access_token = options.get("access_token") + headers = options.get("headers") + oauth = options.get("oauth") + if access_token == "": + access_token = None + if access_token is None and (headers is None or len(headers) == 0): + runtime_empty = True + else: + runtime_empty = False + if runtime_empty and oauth is None: + return None + return GopherAgentCreateOptions( + access_token=access_token, + headers=headers, + oauth=oauth, + ) + + raise ValueError( + "create options must be a GopherAgentCreateOptions instance or mapping" + ) + + +def normalize_oauth_options( + options: Optional[Union[GopherAgentOAuthOptions, Mapping[str, Any]]] = None, +) -> Optional[GopherAgentOAuthOptions]: + """Normalize OAuth options into a GopherAgentOAuthOptions instance.""" + if options is None: + return None + + if isinstance(options, GopherAgentOAuthOptions): + return options + + if isinstance(options, Mapping): + return GopherAgentOAuthOptions( + mode=options.get("mode"), + scopes=options.get("scopes"), + client_name=options.get("client_name") + if "client_name" in options + else options.get("clientName"), + redirect_uri=options.get("redirect_uri") + if "redirect_uri" in options + else options.get("redirectUri"), + open_browser=options.get("open_browser") + if "open_browser" in options + else options.get("openBrowser"), + token_store=options.get("token_store") + if "token_store" in options + else options.get("tokenStore"), + ) + + raise ValueError("oauth options must be a GopherAgentOAuthOptions instance or mapping") + + def _normalize_headers( access_token: Optional[str], headers: Optional[Mapping[str, str]], diff --git a/tests/test_config.py b/tests/test_config.py index d1775c0f..335b94b3 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,11 +1,16 @@ """Tests for GopherAgentConfig.""" import pytest +import gopher_mcp_python from gopher_mcp_python.config import ( GopherAgentConfig, + GopherAgentCreateOptions, + GopherAgentOAuthOptions, GopherAgentRuntimeOptions, + GopherAgentTokenRecord, normalize_runtime_options, ) +from gopher_mcp_python.runtime_options import normalize_create_options class TestGopherAgentConfig: @@ -211,3 +216,66 @@ def test_should_reject_invalid_runtime_access_token(self): """Test access_token must be a string.""" with pytest.raises(ValueError, match="access_token must be a string"): GopherAgentRuntimeOptions(access_token=123) + + def test_should_create_options_with_oauth_disabled(self): + """Test OAuth options normalize alongside runtime options.""" + options = normalize_create_options({"oauth": {"mode": "disabled"}}) + + assert options is not None + assert options.access_token is None + assert options.headers == {} + assert options.oauth is not None + assert options.oauth.mode == "disabled" + + def test_should_create_options_with_oauth_metadata(self): + """Test OAuth option fields support Python and JS-style keys.""" + options = GopherAgentCreateOptions( + access_token="abc123", + oauth={ + "scopes": ["openid", "email"], + "clientName": "Python Client", + "redirectUri": "http://127.0.0.1:4321/callback", + "openBrowser": False, + }, + ) + + assert options.access_token == "abc123" + assert options.headers == {"Authorization": "Bearer abc123"} + assert options.oauth is not None + assert options.oauth.scopes == ["openid", "email"] + assert options.oauth.client_name == "Python Client" + assert options.oauth.redirect_uri == "http://127.0.0.1:4321/callback" + assert options.oauth.open_browser is False + + def test_builder_preserves_oauth_when_setting_headers(self): + """Test builder header helpers do not drop OAuth options.""" + config = ( + GopherAgentConfig.builder() + .provider("AnthropicProvider") + .model("claude-3-haiku-20240307") + .api_key("test-key") + .runtime_options({"oauth": {"mode": "disabled"}}) + .headers({"X-Test": "one"}) + .build() + ) + + assert config.runtime_options is not None + assert config.runtime_options.headers == {"X-Test": "one"} + assert config.runtime_options.oauth is not None + assert config.runtime_options.oauth.mode == "disabled" + + def test_should_reject_invalid_oauth_mode(self): + """Test OAuth mode validation.""" + with pytest.raises(ValueError, match="oauth mode"): + GopherAgentOAuthOptions(mode="interactive") + + def test_token_record_requires_access_token(self): + """Test token records validate required fields.""" + with pytest.raises(ValueError, match="access_token"): + GopherAgentTokenRecord(access_token="") + + def test_root_exports_oauth_create_types(self): + """Test SDK-level OAuth types are exported.""" + assert gopher_mcp_python.GopherAgentCreateOptions is GopherAgentCreateOptions + assert gopher_mcp_python.GopherAgentOAuthOptions is GopherAgentOAuthOptions + assert gopher_mcp_python.GopherAgentTokenRecord is GopherAgentTokenRecord From 1aaac304a595db26453c60f0b91e445877593d67 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Thu, 13 Aug 2026 21:08:35 +0800 Subject: [PATCH 04/13] Fix OAuth helper modules (#15) Summary: - Add Python SDK OAuth PKCE, loopback, discovery, registration, token exchange, and token cache helpers. - Add resolver support for challenge probing, compatible issuer checks, and runtime token merging. - Cover the new OAuth helpers with local and hook-based tests. --- gopher_mcp_python/oauth_authorization_url.py | 49 +++ gopher_mcp_python/oauth_browser.py | 30 ++ gopher_mcp_python/oauth_discovery.py | 290 ++++++++++++++++ gopher_mcp_python/oauth_loopback.py | 148 ++++++++ gopher_mcp_python/oauth_pkce.py | 21 ++ gopher_mcp_python/oauth_registration.py | 89 +++++ gopher_mcp_python/oauth_resolver.py | 346 +++++++++++++++++++ gopher_mcp_python/oauth_runtime_options.py | 29 ++ gopher_mcp_python/oauth_server_targets.py | 76 ++++ gopher_mcp_python/oauth_token_exchange.py | 104 ++++++ gopher_mcp_python/oauth_token_store.py | 75 ++++ tests/test_oauth_discovery.py | 154 +++++++++ tests/test_oauth_loopback.py | 46 +++ tests/test_oauth_pkce.py | 23 ++ tests/test_oauth_registration.py | 56 +++ tests/test_oauth_resolver.py | 107 ++++++ tests/test_oauth_server_targets.py | 58 ++++ tests/test_oauth_token_exchange.py | 91 +++++ tests/test_oauth_token_store.py | 74 ++++ 19 files changed, 1866 insertions(+) create mode 100644 gopher_mcp_python/oauth_authorization_url.py create mode 100644 gopher_mcp_python/oauth_browser.py create mode 100644 gopher_mcp_python/oauth_discovery.py create mode 100644 gopher_mcp_python/oauth_loopback.py create mode 100644 gopher_mcp_python/oauth_pkce.py create mode 100644 gopher_mcp_python/oauth_registration.py create mode 100644 gopher_mcp_python/oauth_resolver.py create mode 100644 gopher_mcp_python/oauth_runtime_options.py create mode 100644 gopher_mcp_python/oauth_server_targets.py create mode 100644 gopher_mcp_python/oauth_token_exchange.py create mode 100644 gopher_mcp_python/oauth_token_store.py create mode 100644 tests/test_oauth_discovery.py create mode 100644 tests/test_oauth_loopback.py create mode 100644 tests/test_oauth_pkce.py create mode 100644 tests/test_oauth_registration.py create mode 100644 tests/test_oauth_resolver.py create mode 100644 tests/test_oauth_server_targets.py create mode 100644 tests/test_oauth_token_exchange.py create mode 100644 tests/test_oauth_token_store.py diff --git a/gopher_mcp_python/oauth_authorization_url.py b/gopher_mcp_python/oauth_authorization_url.py new file mode 100644 index 00000000..29f7b80f --- /dev/null +++ b/gopher_mcp_python/oauth_authorization_url.py @@ -0,0 +1,49 @@ +"""Build OAuth authorization URLs.""" + +from typing import List, Optional +from urllib.parse import urlencode, urlparse, parse_qsl, urlunparse + +from gopher_mcp_python.oauth_discovery import ( + OAuthAuthorizationServerMetadata, + OAuthProtectedResourceMetadata, +) + + +def build_oauth_authorization_url( + metadata: OAuthAuthorizationServerMetadata, + client_id: str, + redirect_uri: str, + state: str, + code_challenge: str, + scopes: Optional[List[str]] = None, + resource_metadata: Optional[OAuthProtectedResourceMetadata] = None, +) -> str: + """Build an authorization-code URL with PKCE parameters.""" + parsed = urlparse(metadata.authorization_endpoint) + params = dict(parse_qsl(parsed.query, keep_blank_values=True)) + params["response_type"] = "code" + params["client_id"] = client_id + params["redirect_uri"] = redirect_uri + params["state"] = state + params["code_challenge"] = code_challenge + params["code_challenge_method"] = "S256" + + selected_scopes = _select_scopes(metadata, resource_metadata, scopes) + if selected_scopes: + params["scope"] = " ".join(selected_scopes) + if resource_metadata is not None and resource_metadata.resource: + params["resource"] = resource_metadata.resource + + return urlunparse(parsed._replace(query=urlencode(params))) + + +def _select_scopes( + metadata: OAuthAuthorizationServerMetadata, + resource_metadata: Optional[OAuthProtectedResourceMetadata], + scopes: Optional[List[str]], +) -> List[str]: + if scopes: + return scopes + if resource_metadata is not None and resource_metadata.scopes_supported: + return resource_metadata.scopes_supported + return metadata.scopes_supported diff --git a/gopher_mcp_python/oauth_browser.py b/gopher_mcp_python/oauth_browser.py new file mode 100644 index 00000000..04a70104 --- /dev/null +++ b/gopher_mcp_python/oauth_browser.py @@ -0,0 +1,30 @@ +"""Browser opener for SDK-side OAuth authorization URLs.""" + +import sys +import webbrowser +from typing import Any, Dict, Optional + + +def open_authorization_url( + url: str, + open_browser: Optional[bool] = None, + opener: Any = None, +) -> Dict[str, Any]: + """Open an OAuth authorization URL, unless explicitly disabled.""" + if open_browser is False: + return {"opened": False, "url": url} + + open_fn = opener if opener is not None else webbrowser.open + opened = bool(open_fn(url)) + result: Dict[str, Any] = {"opened": opened, "url": url} + if opened: + result["command"] = _command_for_platform(sys.platform) + return result + + +def _command_for_platform(platform: str) -> str: + if platform == "darwin": + return "open" + if platform == "win32": + return "cmd" + return "xdg-open" diff --git a/gopher_mcp_python/oauth_discovery.py b/gopher_mcp_python/oauth_discovery.py new file mode 100644 index 00000000..6b98326a --- /dev/null +++ b/gopher_mcp_python/oauth_discovery.py @@ -0,0 +1,290 @@ +"""OAuth discovery helpers for protected MCP endpoints.""" + +import json +import urllib.error +import urllib.request +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import urlparse, urlunparse + + +MCP_DISCOVERY_BODY = json.dumps( + { + "jsonrpc": "2.0", + "id": "gopher-sdk-oauth-probe", + "method": "initialize", + "params": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": { + "name": "gopher-mcp-python-oauth-probe", + "version": "1.0", + }, + }, + } +).encode("utf-8") + + +@dataclass +class McpOAuthChallenge: + url: str + requires_oauth: bool + http_status: int + www_authenticate: Optional[str] = None + resource_metadata_url: Optional[str] = None + authorization_server: Optional[str] = None + resource: Optional[str] = None + scopes: Optional[List[str]] = None + + +@dataclass +class OAuthProtectedResourceMetadata: + resource: str + authorization_servers: List[str] + scopes_supported: List[str] + raw_json: str + + +@dataclass +class OAuthAuthorizationServerMetadata: + issuer: str + authorization_endpoint: str + token_endpoint: str + scopes_supported: List[str] + raw_json: str + registration_endpoint: Optional[str] = None + + +def probe_mcp_oauth_challenge(url: str, timeout: float = 10.0) -> McpOAuthChallenge: + """Probe an MCP URL and return OAuth challenge metadata if auth is required.""" + request = urllib.request.Request( + url, + data=MCP_DISCOVERY_BODY, + method="POST", + headers={ + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + }, + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + status = response.getcode() + if 200 <= status < 300: + return McpOAuthChallenge( + url=url, + requires_oauth=False, + http_status=status, + ) + raise RuntimeError( + f"oauth_metadata_fetch_failed: MCP OAuth probe for {url} " + f"received HTTP {status}" + ) + except urllib.error.HTTPError as exc: + if exc.code != 401: + raise RuntimeError( + f"oauth_metadata_fetch_failed: MCP OAuth probe for {url} " + f"received HTTP {exc.code}" + ) + www_authenticate = exc.headers.get("WWW-Authenticate") + resource_metadata_url = ( + parse_www_authenticate_param(www_authenticate, "resource_metadata") + if www_authenticate + else None + ) + if not resource_metadata_url: + raise RuntimeError( + f"oauth_metadata_missing: MCP OAuth challenge for {url} " + "is missing resource_metadata" + ) + return McpOAuthChallenge( + url=url, + requires_oauth=True, + http_status=exc.code, + www_authenticate=www_authenticate, + resource_metadata_url=resource_metadata_url, + ) + except urllib.error.URLError as exc: + raise RuntimeError( + f"oauth_metadata_fetch_failed: MCP OAuth probe failed for {url}: " + f"{exc.reason}" + ) + + +def parse_www_authenticate_param(challenge: str, name: str) -> Optional[str]: + """Parse a parameter from a Bearer WWW-Authenticate challenge.""" + for part in _split_challenge_params(challenge): + if "=" not in part: + continue + key, value = part.split("=", 1) + if key.strip() != name: + continue + value = value.strip() + if len(value) >= 2 and value.startswith('"') and value.endswith('"'): + return value[1:-1] + return value + return None + + +def fetch_oauth_protected_resource_metadata( + resource_metadata_url: str, + timeout: float = 10.0, +) -> OAuthProtectedResourceMetadata: + """Fetch RFC 9728 OAuth protected resource metadata.""" + body = _fetch_json(resource_metadata_url, "protected resource", timeout) + try: + parsed = json.loads(body) + except Exception as exc: + raise RuntimeError( + "oauth_metadata_fetch_failed: Invalid protected resource metadata " + f"JSON: {exc}" + ) + if not isinstance(parsed, dict): + raise RuntimeError( + "oauth_metadata_fetch_failed: Protected resource metadata must be " + "a JSON object" + ) + + resource = _read_string(parsed.get("resource")) + if resource is None: + raise RuntimeError( + "oauth_metadata_fetch_failed: Protected resource metadata is " + "missing resource" + ) + authorization_servers = _read_string_array(parsed.get("authorization_servers")) + if len(authorization_servers) == 0: + raise RuntimeError( + "oauth_metadata_fetch_failed: Protected resource metadata is " + "missing authorization_servers" + ) + return OAuthProtectedResourceMetadata( + resource=resource, + authorization_servers=authorization_servers, + scopes_supported=_read_string_array(parsed.get("scopes_supported")), + raw_json=body, + ) + + +def fetch_oauth_authorization_server_metadata( + authorization_server: str, + timeout: float = 10.0, +) -> OAuthAuthorizationServerMetadata: + """Fetch RFC 8414/OIDC authorization server metadata.""" + try: + body = _fetch_json( + _build_well_known_url(authorization_server, "oauth-authorization-server"), + "authorization server", + timeout, + ) + except Exception: + body = _fetch_json( + _build_well_known_url(authorization_server, "openid-configuration"), + "authorization server", + timeout, + ) + + try: + parsed = json.loads(body) + except Exception as exc: + raise RuntimeError( + "oauth_server_metadata_invalid: Invalid authorization server " + f"metadata JSON: {exc}" + ) + if not isinstance(parsed, dict): + raise RuntimeError( + "oauth_server_metadata_invalid: Authorization server metadata " + "must be a JSON object" + ) + + issuer = _read_string(parsed.get("issuer")) + authorization_endpoint = _read_string(parsed.get("authorization_endpoint")) + token_endpoint = _read_string(parsed.get("token_endpoint")) + if issuer is None: + raise RuntimeError( + "oauth_server_metadata_invalid: Authorization server metadata is " + "missing issuer" + ) + if authorization_endpoint is None: + raise RuntimeError( + "oauth_server_metadata_invalid: Authorization server metadata is " + "missing authorization_endpoint" + ) + if token_endpoint is None: + raise RuntimeError( + "oauth_server_metadata_invalid: Authorization server metadata is " + "missing token_endpoint" + ) + return OAuthAuthorizationServerMetadata( + issuer=issuer, + authorization_endpoint=authorization_endpoint, + token_endpoint=token_endpoint, + registration_endpoint=_read_string(parsed.get("registration_endpoint")), + scopes_supported=_read_string_array(parsed.get("scopes_supported")), + raw_json=body, + ) + + +def _split_challenge_params(challenge: str) -> List[str]: + value = challenge.strip() + if value.lower().startswith("bearer "): + value = value[7:] + parts: List[str] = [] + current = [] + quoted = False + for char in value: + if char == '"': + quoted = not quoted + current.append(char) + elif char == "," and not quoted: + parts.append("".join(current).strip()) + current = [] + else: + current.append(char) + if current: + parts.append("".join(current).strip()) + return parts + + +def _build_well_known_url(issuer: str, well_known_name: str) -> str: + parsed = urlparse(issuer) + path = "" if parsed.path in ("", "/") else parsed.path.rstrip("/") + return urlunparse( + ( + parsed.scheme, + parsed.netloc, + f"/.well-known/{well_known_name}{path}", + "", + "", + "", + ) + ) + + +def _fetch_json(url: str, label: str, timeout: float) -> str: + request = urllib.request.Request(url, headers={"Accept": "application/json"}) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + status = response.getcode() + if status < 200 or status >= 300: + raise RuntimeError( + f"oauth_metadata_fetch_failed: OAuth {label} metadata fetch " + f"from {url} received HTTP {status}" + ) + return response.read().decode("utf-8") + except urllib.error.HTTPError as exc: + raise RuntimeError( + f"oauth_metadata_fetch_failed: OAuth {label} metadata fetch from " + f"{url} received HTTP {exc.code}" + ) + except urllib.error.URLError as exc: + raise RuntimeError( + f"oauth_metadata_fetch_failed: Failed to fetch OAuth {label} " + f"metadata from {url}: {exc.reason}" + ) + + +def _read_string(value: Any) -> Optional[str]: + return value if isinstance(value, str) else None + + +def _read_string_array(value: Any) -> List[str]: + return [item for item in value if isinstance(item, str)] if isinstance(value, list) else [] diff --git a/gopher_mcp_python/oauth_loopback.py b/gopher_mcp_python/oauth_loopback.py new file mode 100644 index 00000000..33fb16f5 --- /dev/null +++ b/gopher_mcp_python/oauth_loopback.py @@ -0,0 +1,148 @@ +"""Loopback callback server for local OAuth authorization-code flows.""" + +import asyncio +import threading +from dataclasses import dataclass +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Optional +from urllib.parse import parse_qs, urlparse + + +@dataclass +class OAuthLoopbackCallbackResult: + code: str + state: str + + +class OAuthLoopbackCallbackServer: + """Small local HTTP server that captures one OAuth callback.""" + + def __init__(self, state: str, path: str = "/callback", timeout_ms: int = 120000): + self._state = state + self._path = path + self._timeout_ms = timeout_ms + self._future = None + self._loop = None + self._pending_result = None + self._pending_error = None + + owner = self + + class Handler(BaseHTTPRequestHandler): + def log_message(self, fmt, *args): + return + + def do_GET(self): + owner._handle_callback(self) + + self._server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) + self._thread.start() + + @property + def redirect_uri(self) -> str: + port = self._server.server_address[1] + return f"http://127.0.0.1:{port}{self._path}" + + async def wait_for_callback(self) -> OAuthLoopbackCallbackResult: + self._loop = asyncio.get_running_loop() + if self._future is None: + self._future = self._loop.create_future() + if self._pending_result is not None: + self._future.set_result(self._pending_result) + elif self._pending_error is not None: + self._future.set_exception(RuntimeError(self._pending_error)) + try: + return await asyncio.wait_for( + self._future, + timeout=self._timeout_ms / 1000, + ) + finally: + await self.close() + + async def close(self) -> None: + await asyncio.get_running_loop().run_in_executor(None, self._close_sync) + + def _close_sync(self) -> None: + self._server.shutdown() + self._server.server_close() + if self._thread.is_alive(): + self._thread.join(timeout=1) + + def _handle_callback(self, handler: BaseHTTPRequestHandler) -> None: + parsed = urlparse(handler.path) + if parsed.path != self._path: + _respond(handler, 404, "OAuth callback path was not found.") + return + + params = parse_qs(parsed.query) + state = _first(params.get("state")) + if state != self._state: + self._settle_error("OAuth callback state mismatch.") + _respond(handler, 400, "OAuth callback state mismatch.") + return + + error = _first(params.get("error")) + if error is not None: + description = _first(params.get("error_description")) + detail = f"{error}: {description}" if description else error + message = f"OAuth callback returned error: {detail}" + self._settle_error(message) + _respond(handler, 400, message) + return + + code = _first(params.get("code")) + if not code: + self._settle_error("OAuth callback is missing code.") + _respond(handler, 400, "OAuth callback is missing code.") + return + + self._settle_result(OAuthLoopbackCallbackResult(code=code, state=state)) + _respond(handler, 200, "OAuth authorization complete. You may close this tab.") + + def _settle_result(self, result: OAuthLoopbackCallbackResult) -> None: + self._pending_result = result + if self._loop is None or self._future is None or self._future.done(): + return + self._loop.call_soon_threadsafe(self._future.set_result, result) + + def _settle_error(self, message: str) -> None: + self._pending_error = message + if self._loop is None or self._future is None or self._future.done(): + return + self._loop.call_soon_threadsafe(self._future.set_exception, RuntimeError(message)) + + +async def create_oauth_loopback_callback_server( + state: str, + path: str = "/callback", + timeout_ms: int = 120000, +) -> OAuthLoopbackCallbackServer: + """Create and start a loopback OAuth callback server.""" + return OAuthLoopbackCallbackServer(state=state, path=path, timeout_ms=timeout_ms) + + +def _respond(handler: BaseHTTPRequestHandler, status: int, body: str) -> None: + data = ( + "OAuth

" + f"{_escape_html(body)}

" + ).encode("utf-8") + handler.send_response(status) + handler.send_header("Content-Type", "text/html; charset=utf-8") + handler.send_header("Cache-Control", "no-store") + handler.send_header("Content-Length", str(len(data))) + handler.end_headers() + handler.wfile.write(data) + + +def _first(values) -> Optional[str]: + return values[0] if values else None + + +def _escape_html(value: str) -> str: + return ( + value.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + ) diff --git a/gopher_mcp_python/oauth_pkce.py b/gopher_mcp_python/oauth_pkce.py new file mode 100644 index 00000000..184ca35a --- /dev/null +++ b/gopher_mcp_python/oauth_pkce.py @@ -0,0 +1,21 @@ +"""PKCE helpers for SDK-side OAuth flows.""" + +import base64 +import hashlib +import secrets + + +def create_code_verifier() -> str: + """Create a high-entropy OAuth PKCE code verifier.""" + return base64_url_encode(secrets.token_bytes(32)) + + +def create_code_challenge(verifier: str) -> str: + """Create an S256 PKCE code challenge for a verifier.""" + digest = hashlib.sha256(verifier.encode("utf-8")).digest() + return base64_url_encode(digest) + + +def base64_url_encode(value: bytes) -> str: + """Base64url encode bytes without padding.""" + return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=") diff --git a/gopher_mcp_python/oauth_registration.py b/gopher_mcp_python/oauth_registration.py new file mode 100644 index 00000000..1c54927d --- /dev/null +++ b/gopher_mcp_python/oauth_registration.py @@ -0,0 +1,89 @@ +"""Dynamic OAuth client registration.""" + +import json +import urllib.error +import urllib.request +from dataclasses import dataclass +from typing import List, Optional + +from gopher_mcp_python.oauth_discovery import OAuthAuthorizationServerMetadata +from gopher_mcp_python.runtime_options import GopherAgentOAuthOptions + + +@dataclass +class OAuthRegisteredClient: + client_id: str + client_secret: Optional[str] = None + + +def register_oauth_client( + metadata: OAuthAuthorizationServerMetadata, + redirect_uri: str, + scopes: List[str], + oauth: Optional[GopherAgentOAuthOptions] = None, + timeout: float = 10.0, +) -> OAuthRegisteredClient: + """Register a public OAuth client with the authorization server.""" + if metadata.registration_endpoint is None: + raise RuntimeError( + "oauth_registration_required: Authorization server metadata has " + "no registration_endpoint and caller-provided client metadata is " + "not supported yet." + ) + + client_name = oauth.client_name if oauth and oauth.client_name else "gopher-mcp-python" + body = { + "client_name": client_name, + "redirect_uris": [redirect_uri], + "token_endpoint_auth_method": "none", + "grant_types": ["authorization_code", "refresh_token"], + } + if scopes: + body["scope"] = " ".join(scopes) + + request = urllib.request.Request( + metadata.registration_endpoint, + data=json.dumps(body).encode("utf-8"), + method="POST", + headers={"Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + status = response.getcode() + text = response.read().decode("utf-8") + except urllib.error.HTTPError as exc: + text = exc.read().decode("utf-8") + detail = _error_from_json(text) or f"HTTP {exc.code}" + raise RuntimeError(f"oauth_registration_failed: {detail}") + except urllib.error.URLError as exc: + raise RuntimeError(f"oauth_registration_failed: {exc.reason}") + + try: + parsed = json.loads(text) if text else {} + except Exception as exc: + raise RuntimeError(f"oauth_registration_failed: invalid_registration_response: {exc}") + if not isinstance(parsed, dict): + raise RuntimeError("oauth_registration_failed: invalid_registration_response") + client_id = parsed.get("client_id") + if not isinstance(client_id, str) or not client_id: + detail = parsed.get("error") if isinstance(parsed.get("error"), str) else None + raise RuntimeError( + f"oauth_registration_failed: {detail or 'Dynamic client registration failed'}" + ) + if status < 200 or status >= 300: + raise RuntimeError(f"oauth_registration_failed: HTTP {status}") + client_secret = parsed.get("client_secret") + return OAuthRegisteredClient( + client_id=client_id, + client_secret=client_secret if isinstance(client_secret, str) else None, + ) + + +def _error_from_json(text: str) -> Optional[str]: + try: + parsed = json.loads(text) if text else {} + except Exception: + return None + if isinstance(parsed, dict) and isinstance(parsed.get("error"), str): + return parsed["error"] + return None diff --git a/gopher_mcp_python/oauth_resolver.py b/gopher_mcp_python/oauth_resolver.py new file mode 100644 index 00000000..7cdd5b86 --- /dev/null +++ b/gopher_mcp_python/oauth_resolver.py @@ -0,0 +1,346 @@ +"""OAuth runtime-option resolver for SDK-side agent creation.""" + +import base64 +import json +import os +import sys +from typing import Any, Awaitable, Callable, Dict, List, Optional + +from gopher_mcp_python.oauth_authorization_url import build_oauth_authorization_url +from gopher_mcp_python.oauth_browser import open_authorization_url +from gopher_mcp_python.oauth_discovery import ( + McpOAuthChallenge, + OAuthAuthorizationServerMetadata, + OAuthProtectedResourceMetadata, + fetch_oauth_authorization_server_metadata, + fetch_oauth_protected_resource_metadata, + probe_mcp_oauth_challenge, +) +from gopher_mcp_python.oauth_loopback import create_oauth_loopback_callback_server +from gopher_mcp_python.oauth_pkce import create_code_challenge, create_code_verifier +from gopher_mcp_python.oauth_registration import ( + OAuthRegisteredClient, + register_oauth_client, +) +from gopher_mcp_python.oauth_runtime_options import merge_oauth_token_into_runtime_options +from gopher_mcp_python.oauth_server_targets import extract_mcp_server_targets +from gopher_mcp_python.oauth_token_exchange import ( + exchange_oauth_code_for_token, + refresh_oauth_token, +) +from gopher_mcp_python.oauth_token_store import ( + InMemoryGopherAgentTokenStore, + create_oauth_token_cache_key, + resolve_oauth_token_from_store, +) +from gopher_mcp_python.runtime_options import ( + GopherAgentOAuthOptions, + GopherAgentRuntimeOptions, + GopherAgentTokenRecord, + normalize_oauth_options, + normalize_runtime_options, +) + + +OAuthChallengeProbe = Callable[[str], Awaitable[McpOAuthChallenge]] +OAuthTokenAcquirer = Callable[ + [List[McpOAuthChallenge], GopherAgentOAuthOptions], + Awaitable[Optional[GopherAgentRuntimeOptions]], +] +OAuthUrlRuntimeOptionsResolver = Callable[ + [str, Optional[GopherAgentRuntimeOptions], Optional[GopherAgentOAuthOptions]], + Awaitable[Optional[GopherAgentRuntimeOptions]], +] + + +_default_token_store = InMemoryGopherAgentTokenStore() + + +async def _default_probe_challenge(url: str) -> McpOAuthChallenge: + return probe_mcp_oauth_challenge(url) + + +async def _default_acquire_token( + challenges: List[McpOAuthChallenge], + oauth: GopherAgentOAuthOptions, +) -> GopherAgentRuntimeOptions: + challenge = challenges[0] + if challenge.resource_metadata_url is None: + raise RuntimeError( + f"oauth_metadata_missing: MCP OAuth challenge for {challenge.url} " + "is missing resource_metadata" + ) + + resource_metadata = fetch_oauth_protected_resource_metadata( + challenge.resource_metadata_url + ) + authorization_server = _select_authorization_server(challenge, resource_metadata) + authorization_metadata = fetch_oauth_authorization_server_metadata( + authorization_server + ) + scopes = _select_scopes(oauth, resource_metadata, authorization_metadata) + state = create_code_verifier() + loopback = await create_oauth_loopback_callback_server(state=state) + + try: + client = register_oauth_client( + metadata=authorization_metadata, + redirect_uri=loopback.redirect_uri, + scopes=scopes, + oauth=oauth, + ) + cache_key = create_oauth_token_cache_key( + resource=resource_metadata.resource, + issuer=authorization_metadata.issuer, + client_id=client.client_id, + scopes=scopes, + ) + store = oauth.token_store or _default_token_store + token = await resolve_oauth_token_from_store( + store=store, + key=cache_key, + refresh_token=lambda refresh: _refresh_token( + refresh, authorization_metadata, client + ), + acquire_token=lambda: _run_authorization_code_flow( + oauth=oauth, + resource_metadata=resource_metadata, + authorization_metadata=authorization_metadata, + client=client, + redirect_uri=loopback.redirect_uri, + wait_for_callback=loopback.wait_for_callback, + state=state, + ), + ) + _log_oauth_debug("resolved access token claims", _decode_jwt_claims(token.access_token)) + return merge_oauth_token_into_runtime_options(None, token) + finally: + await loopback.close() + + +async def _default_url_runtime_options_resolver( + url: str, + runtime_options: Optional[GopherAgentRuntimeOptions], + oauth: Optional[GopherAgentOAuthOptions], +) -> Optional[GopherAgentRuntimeOptions]: + return await resolve_runtime_options_with_oauth( + urls=[url], + runtime_options=runtime_options, + oauth=oauth, + ) + + +_probe_challenge: OAuthChallengeProbe = _default_probe_challenge +_acquire_token: OAuthTokenAcquirer = _default_acquire_token +_url_runtime_options_resolver: OAuthUrlRuntimeOptionsResolver = ( + _default_url_runtime_options_resolver +) + + +async def resolve_runtime_options_with_oauth( + urls: Optional[List[str]] = None, + server_config: Optional[str] = None, + runtime_options: Optional[GopherAgentRuntimeOptions] = None, + oauth: Optional[GopherAgentOAuthOptions] = None, +) -> Optional[GopherAgentRuntimeOptions]: + """Resolve runtime options, acquiring OAuth credentials when needed.""" + normalized_runtime_options = normalize_runtime_options(runtime_options) + normalized_oauth = normalize_oauth_options(oauth) + if ( + normalized_oauth is not None + and normalized_oauth.mode == "disabled" + ) or _has_runtime_authorization(normalized_runtime_options): + return normalized_runtime_options + + target_urls = list(urls or []) + target_urls.extend( + target["url"] + for target in extract_mcp_server_targets(server_config=server_config) + ) + challenges = [await _probe_challenge(url) for url in target_urls] + oauth_challenges = [ + challenge for challenge in challenges if challenge.requires_oauth + ] + if len(oauth_challenges) == 0: + return normalized_runtime_options + + _assert_compatible_oauth_challenges(oauth_challenges) + token_options = await _acquire_token( + oauth_challenges, + normalized_oauth or GopherAgentOAuthOptions(), + ) + return _merge_runtime_options(normalized_runtime_options, token_options) + + +async def resolve_url_runtime_options_with_oauth( + url: str, + runtime_options: Optional[GopherAgentRuntimeOptions] = None, + oauth: Optional[GopherAgentOAuthOptions] = None, +) -> Optional[GopherAgentRuntimeOptions]: + """Resolve runtime options for a direct MCP URL.""" + return await _url_runtime_options_resolver(url, runtime_options, oauth) + + +def set_oauth_resolver_hooks_for_test( + probe_challenge: Optional[OAuthChallengeProbe] = None, + acquire_token: Optional[OAuthTokenAcquirer] = None, +) -> None: + """Replace resolver hooks for tests, or reset omitted hooks to defaults.""" + global _probe_challenge, _acquire_token + _probe_challenge = probe_challenge or _default_probe_challenge + _acquire_token = acquire_token or _default_acquire_token + + +def set_oauth_url_runtime_options_resolver_for_test( + resolver: Optional[OAuthUrlRuntimeOptionsResolver] = None, +) -> None: + """Replace direct-URL resolver for tests, or reset to default.""" + global _url_runtime_options_resolver + _url_runtime_options_resolver = resolver or _default_url_runtime_options_resolver + + +def _has_runtime_authorization( + options: Optional[GopherAgentRuntimeOptions], +) -> bool: + if options is None: + return False + if options.access_token is not None: + return True + return any(name.lower() == "authorization" for name in options.headers) + + +def _assert_compatible_oauth_challenges(challenges: List[McpOAuthChallenge]) -> None: + keys = set() + for challenge in challenges: + issuer = ( + challenge.authorization_server + or challenge.resource_metadata_url + or challenge.url + ) + resource = challenge.resource or challenge.resource_metadata_url or challenge.url + scopes = " ".join(sorted(challenge.scopes or [])) + keys.add(json.dumps({"issuer": issuer, "resource": resource, "scopes": scopes})) + if len(keys) > 1: + raise RuntimeError( + "OAuth auto-flow found multiple protected MCP servers with different " + "OAuth issuers.\nPer-server OAuth tokens are not supported yet." + ) + + +def _merge_runtime_options( + base: Optional[GopherAgentRuntimeOptions], + token_options: Optional[GopherAgentRuntimeOptions], +) -> Optional[GopherAgentRuntimeOptions]: + base = normalize_runtime_options(base) + token_options = normalize_runtime_options(token_options) + if base is None: + return token_options + if token_options is None: + return base + headers = base.headers + headers.update(token_options.headers) + return normalize_runtime_options( + { + "access_token": token_options.access_token or base.access_token, + "headers": headers, + } + ) + + +async def _run_authorization_code_flow( + oauth: GopherAgentOAuthOptions, + resource_metadata: OAuthProtectedResourceMetadata, + authorization_metadata: OAuthAuthorizationServerMetadata, + client: OAuthRegisteredClient, + redirect_uri: str, + wait_for_callback, + state: str, +) -> GopherAgentTokenRecord: + code_verifier = create_code_verifier() + code_challenge = create_code_challenge(code_verifier) + authorization_url = build_oauth_authorization_url( + metadata=authorization_metadata, + client_id=client.client_id, + redirect_uri=redirect_uri, + state=state, + code_challenge=code_challenge, + scopes=oauth.scopes, + resource_metadata=resource_metadata, + ) + opened = open_authorization_url( + authorization_url, + open_browser=oauth.open_browser, + ) + if not opened["opened"]: + print(f"Open this OAuth authorization URL:\n{opened['url']}", file=sys.stderr) + + callback = await wait_for_callback() + return exchange_oauth_code_for_token( + code=callback.code, + redirect_uri=redirect_uri, + code_verifier=code_verifier, + token_endpoint=authorization_metadata.token_endpoint, + client_id=client.client_id, + client_secret=client.client_secret, + ) + + +async def _refresh_token( + refresh_token: str, + authorization_metadata: OAuthAuthorizationServerMetadata, + client: OAuthRegisteredClient, +) -> GopherAgentTokenRecord: + return refresh_oauth_token( + refresh_token=refresh_token, + token_endpoint=authorization_metadata.token_endpoint, + client_id=client.client_id, + client_secret=client.client_secret, + ) + + +def _select_authorization_server( + challenge: McpOAuthChallenge, + metadata: OAuthProtectedResourceMetadata, +) -> str: + if challenge.authorization_server is not None: + return challenge.authorization_server + if len(metadata.authorization_servers) == 0: + raise RuntimeError( + "oauth_metadata_fetch_failed: Protected resource metadata is " + "missing authorization_servers" + ) + return metadata.authorization_servers[0] + + +def _select_scopes( + oauth: GopherAgentOAuthOptions, + resource_metadata: OAuthProtectedResourceMetadata, + authorization_metadata: OAuthAuthorizationServerMetadata, +) -> List[str]: + if oauth.scopes: + return oauth.scopes + if resource_metadata.scopes_supported: + return resource_metadata.scopes_supported + return authorization_metadata.scopes_supported + + +def _decode_jwt_claims(token: str) -> Dict[str, Any]: + parts = token.split(".") + if len(parts) < 2: + return {"jwt": False} + try: + payload = parts[1] + "=" * ((4 - len(parts[1]) % 4) % 4) + decoded = base64.urlsafe_b64decode(payload.encode("ascii")).decode("utf-8") + parsed = json.loads(decoded) + if not isinstance(parsed, dict): + return {"jwt": True, "claims_decode_error": "JWT payload is not an object"} + names = ["iss", "aud", "azp", "client_id", "scope", "scp", "sub", "exp", "iat"] + return {"jwt": True, **{name: parsed[name] for name in names if name in parsed}} + except Exception as exc: + return {"jwt": True, "claims_decode_error": str(exc)} + + +def _log_oauth_debug(label: str, values: Any) -> None: + if os.environ.get("GOPHER_MCP_OAUTH_DEBUG") != "1" and os.environ.get("DEBUG") != "1": + return + print(f"[gopher-mcp-python oauth] {label}: {json.dumps(values)}", file=sys.stderr) diff --git a/gopher_mcp_python/oauth_runtime_options.py b/gopher_mcp_python/oauth_runtime_options.py new file mode 100644 index 00000000..8e350749 --- /dev/null +++ b/gopher_mcp_python/oauth_runtime_options.py @@ -0,0 +1,29 @@ +"""Helpers for merging OAuth tokens into agent runtime options.""" + +from typing import Optional + +from gopher_mcp_python.runtime_options import ( + GopherAgentRuntimeOptions, + GopherAgentTokenRecord, + normalize_runtime_options, +) + + +def merge_oauth_token_into_runtime_options( + base: Optional[GopherAgentRuntimeOptions], + token: GopherAgentTokenRecord, +) -> GopherAgentRuntimeOptions: + """Add an OAuth bearer token unless caller already supplied credentials.""" + normalized = normalize_runtime_options(base) + if normalized is not None: + if normalized.access_token is not None: + return normalized + for name in normalized.headers: + if name.lower() == "authorization": + return normalized + + headers = normalized.headers if normalized is not None else {} + return GopherAgentRuntimeOptions( + access_token=token.access_token, + headers=headers, + ) diff --git a/gopher_mcp_python/oauth_server_targets.py b/gopher_mcp_python/oauth_server_targets.py new file mode 100644 index 00000000..d68783e2 --- /dev/null +++ b/gopher_mcp_python/oauth_server_targets.py @@ -0,0 +1,76 @@ +"""Extract MCP HTTP targets from agent inputs for OAuth probing.""" + +import json +from typing import Any, Dict, List, Optional + + +def extract_mcp_server_targets( + url: Optional[str] = None, + server_config: Optional[str] = None, +) -> List[Dict[str, str]]: + """Extract URL-backed MCP targets from direct URL or server config JSON.""" + targets: List[Dict[str, str]] = [] + if url: + targets.append({"url": url}) + + if server_config is None: + return targets + + try: + parsed = json.loads(server_config) + except Exception as exc: + raise ValueError( + "Failed to parse MCP server config for OAuth URL extraction: " + f"{exc}" + ) + + for server in _collect_server_entries(parsed): + target = _target_from_server_entry(server) + if target is not None: + targets.append(target) + return targets + + +def _collect_server_entries(value: Any) -> List[Dict[str, Any]]: + if not isinstance(value, dict): + return [] + servers = value.get("servers") + if isinstance(servers, list): + return [item for item in servers if isinstance(item, dict)] + data = value.get("data") + if isinstance(data, dict) and isinstance(data.get("servers"), list): + return [item for item in data["servers"] if isinstance(item, dict)] + return [] + + +def _target_from_server_entry(server: Dict[str, Any]) -> Optional[Dict[str, str]]: + transport = _read_string(server.get("transport")) + if transport is not None and transport.lower() == "stdio": + return None + + config = server.get("config") + url = _read_string(server.get("url")) + if url is None and isinstance(config, dict): + url = _read_string(config.get("url")) + if not url: + return None + + target = {"url": url} + _copy_first_string(target, "server_id", server, "serverId", "server_id", "id") + _copy_first_string(target, "name", server, "name") + _copy_first_string(target, "server_name", server, "serverName", "server_name") + return target + + +def _copy_first_string( + target: Dict[str, str], out_key: str, source: Dict[str, Any], *keys: str +) -> None: + for key in keys: + value = _read_string(source.get(key)) + if value is not None: + target[out_key] = value + return + + +def _read_string(value: Any) -> Optional[str]: + return value if isinstance(value, str) else None diff --git a/gopher_mcp_python/oauth_token_exchange.py b/gopher_mcp_python/oauth_token_exchange.py new file mode 100644 index 00000000..715c9f00 --- /dev/null +++ b/gopher_mcp_python/oauth_token_exchange.py @@ -0,0 +1,104 @@ +"""OAuth token exchange helpers.""" + +import json +import time +import urllib.error +import urllib.parse +import urllib.request +from typing import Dict, Optional + +from gopher_mcp_python.runtime_options import GopherAgentTokenRecord + + +def exchange_oauth_code_for_token( + code: str, + redirect_uri: str, + code_verifier: str, + token_endpoint: str, + client_id: str, + client_secret: Optional[str] = None, + now_ms: Optional[float] = None, + timeout: float = 10.0, +) -> GopherAgentTokenRecord: + """Exchange an OAuth authorization code for tokens.""" + params = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "client_id": client_id, + } + if client_secret is not None: + params["client_secret"] = client_secret + if code_verifier: + params["code_verifier"] = code_verifier + return _token_request(token_endpoint, params, now_ms, timeout) + + +def refresh_oauth_token( + refresh_token: str, + token_endpoint: str, + client_id: str, + client_secret: Optional[str] = None, + now_ms: Optional[float] = None, + timeout: float = 10.0, +) -> GopherAgentTokenRecord: + """Refresh an OAuth access token.""" + params = { + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": client_id, + } + if client_secret is not None: + params["client_secret"] = client_secret + return _token_request(token_endpoint, params, now_ms, timeout) + + +def _token_request( + token_endpoint: str, + params: Dict[str, str], + now_ms: Optional[float], + timeout: float, +) -> GopherAgentTokenRecord: + encoded = urllib.parse.urlencode(params).encode("utf-8") + request = urllib.request.Request( + token_endpoint, + data=encoded, + method="POST", + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + text = response.read().decode("utf-8") + ok = 200 <= response.getcode() < 300 + except urllib.error.HTTPError as exc: + text = exc.read().decode("utf-8") + ok = False + except urllib.error.URLError as exc: + raise RuntimeError(f"oauth_token_exchange_failed: {exc.reason}") + + try: + parsed = json.loads(text) if text else {} + except Exception as exc: + raise RuntimeError(f"oauth_token_exchange_failed: invalid_token_response: {exc}") + if not isinstance(parsed, dict): + raise RuntimeError("oauth_token_exchange_failed: invalid_token_response") + if not ok or not isinstance(parsed.get("access_token"), str): + detail = parsed.get("error_description") or parsed.get("error") + raise RuntimeError(f"oauth_token_exchange_failed: {detail or 'OAuth token request failed'}") + + expires_in = parsed.get("expires_in") + expires_at = None + if isinstance(expires_in, (int, float)) and expires_in > 0: + expires_at = (now_ms if now_ms is not None else time.time() * 1000) + ( + expires_in * 1000 + ) + refresh = parsed.get("refresh_token") + scope = parsed.get("scope") + token_type = parsed.get("token_type") + return GopherAgentTokenRecord( + access_token=parsed["access_token"], + refresh_token=refresh if isinstance(refresh, str) else None, + token_type=token_type if isinstance(token_type, str) else "Bearer", + expires_at=expires_at, + scope=scope if isinstance(scope, str) else None, + ) diff --git a/gopher_mcp_python/oauth_token_store.py b/gopher_mcp_python/oauth_token_store.py new file mode 100644 index 00000000..0a46c7e0 --- /dev/null +++ b/gopher_mcp_python/oauth_token_store.py @@ -0,0 +1,75 @@ +"""OAuth token cache helpers.""" + +import json +import time +from typing import Awaitable, Callable, Dict, List, Optional + +from gopher_mcp_python.runtime_options import GopherAgentTokenRecord + + +class InMemoryGopherAgentTokenStore: + """Simple in-memory async token store.""" + + def __init__(self) -> None: + self._tokens: Dict[str, GopherAgentTokenRecord] = {} + + async def get(self, key: str) -> Optional[GopherAgentTokenRecord]: + return self._tokens.get(key) + + async def set(self, key: str, token: GopherAgentTokenRecord) -> None: + self._tokens[key] = token + + async def delete(self, key: str) -> None: + self._tokens.pop(key, None) + + +def create_oauth_token_cache_key( + resource: str, + issuer: str, + client_id: str, + scopes: List[str], +) -> str: + """Create a stable token cache key.""" + unique_scopes = " ".join(sorted(set(scopes))) + return json.dumps( + { + "resource": resource, + "issuer": issuer, + "client_id": client_id, + "scopes": unique_scopes, + }, + sort_keys=True, + separators=(",", ":"), + ) + + +async def resolve_oauth_token_from_store( + store, + key: str, + refresh_token: Callable[[str], Awaitable[GopherAgentTokenRecord]], + acquire_token: Callable[[], Awaitable[GopherAgentTokenRecord]], + now_ms: Optional[float] = None, +) -> GopherAgentTokenRecord: + """Return a valid cached token, refresh it, or acquire a new one.""" + now = now_ms if now_ms is not None else time.time() * 1000 + cached = await store.get(key) + if cached is not None and not _is_expired(cached, now): + return cached + + if cached is not None and cached.refresh_token: + try: + refreshed = await refresh_token(cached.refresh_token) + await store.set(key, refreshed) + return refreshed + except Exception: + delete = getattr(store, "delete", None) + if delete is not None: + await delete(key) + + acquired = await acquire_token() + await store.set(key, acquired) + return acquired + + +def _is_expired(token: GopherAgentTokenRecord, now_ms: float) -> bool: + return token.expires_at is not None and token.expires_at <= now_ms diff --git a/tests/test_oauth_discovery.py b/tests/test_oauth_discovery.py new file mode 100644 index 00000000..86016ffb --- /dev/null +++ b/tests/test_oauth_discovery.py @@ -0,0 +1,154 @@ +"""Tests for OAuth discovery helpers.""" + +import json +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import pytest + +from gopher_mcp_python.oauth_discovery import ( + fetch_oauth_authorization_server_metadata, + fetch_oauth_protected_resource_metadata, + parse_www_authenticate_param, + probe_mcp_oauth_challenge, +) + + +def test_parses_quoted_resource_metadata() -> None: + challenge = 'Bearer realm="mcp", resource_metadata="https://mcp.example.com/meta"' + + assert ( + parse_www_authenticate_param(challenge, "resource_metadata") + == "https://mcp.example.com/meta" + ) + + +def test_probe_treats_2xx_as_no_oauth() -> None: + server = _start_server(lambda handler: _json(handler, 200, {})) + try: + result = probe_mcp_oauth_challenge(f"{server.url}/mcp") + finally: + server.close() + + assert result.requires_oauth is False + assert result.http_status == 200 + + +def test_probe_returns_oauth_challenge() -> None: + def handle(handler): + handler.send_response(401) + handler.send_header( + "WWW-Authenticate", + f'Bearer realm="mcp", resource_metadata="{server.url}/resource"', + ) + handler.end_headers() + + server = _start_server(handle) + try: + result = probe_mcp_oauth_challenge(f"{server.url}/mcp") + finally: + server.close() + + assert result.requires_oauth is True + assert result.resource_metadata_url == f"{server.url}/resource" + + +def test_probe_missing_resource_metadata_fails() -> None: + def handle(handler): + handler.send_response(401) + handler.send_header("WWW-Authenticate", 'Bearer realm="mcp"') + handler.end_headers() + + server = _start_server(handle) + try: + with pytest.raises(RuntimeError, match="missing resource_metadata"): + probe_mcp_oauth_challenge(f"{server.url}/mcp") + finally: + server.close() + + +def test_fetches_protected_resource_metadata() -> None: + server = _start_server( + lambda handler: _json( + handler, + 200, + { + "resource": "https://mcp.example.com/mcp", + "authorization_servers": ["https://auth.example.com"], + "scopes_supported": ["openid"], + }, + ) + ) + try: + metadata = fetch_oauth_protected_resource_metadata(f"{server.url}/resource") + finally: + server.close() + + assert metadata.resource == "https://mcp.example.com/mcp" + assert metadata.authorization_servers == ["https://auth.example.com"] + assert metadata.scopes_supported == ["openid"] + + +def test_fetches_authorization_server_metadata_with_oidc_fallback() -> None: + def handle(handler): + if handler.path.startswith("/.well-known/oauth-authorization-server"): + _json(handler, 404, {}) + return + _json( + handler, + 200, + { + "issuer": server.url, + "authorization_endpoint": f"{server.url}/authorize", + "token_endpoint": f"{server.url}/token", + "registration_endpoint": f"{server.url}/register", + "scopes_supported": ["openid"], + }, + ) + + server = _start_server(handle) + try: + metadata = fetch_oauth_authorization_server_metadata(server.url) + finally: + server.close() + + assert metadata.issuer == server.url + assert metadata.authorization_endpoint == f"{server.url}/authorize" + assert metadata.token_endpoint == f"{server.url}/token" + assert metadata.registration_endpoint == f"{server.url}/register" + + +class _Server: + def __init__(self, server): + self._server = server + self._thread = threading.Thread(target=server.serve_forever, daemon=True) + self._thread.start() + self.url = f"http://127.0.0.1:{server.server_address[1]}" + + def close(self): + self._server.shutdown() + self._server.server_close() + self._thread.join(timeout=1) + + +def _start_server(callback): + class Handler(BaseHTTPRequestHandler): + def log_message(self, fmt, *args): + return + + def do_GET(self): + callback(self) + + def do_POST(self): + callback(self) + + return _Server(ThreadingHTTPServer(("127.0.0.1", 0), Handler)) + + +def _json(handler, status, body): + data = json.dumps(body).encode("utf-8") + handler.send_response(status) + handler.send_header("Content-Type", "application/json") + handler.send_header("Content-Length", str(len(data))) + handler.end_headers() + handler.wfile.write(data) diff --git a/tests/test_oauth_loopback.py b/tests/test_oauth_loopback.py new file mode 100644 index 00000000..05c7f823 --- /dev/null +++ b/tests/test_oauth_loopback.py @@ -0,0 +1,46 @@ +"""Tests for OAuth loopback callback server.""" + +import asyncio +import urllib.request + +import pytest + +from gopher_mcp_python.oauth_loopback import create_oauth_loopback_callback_server + + +def test_loopback_receives_code_and_state() -> None: + asyncio.run(_test_loopback_receives_code_and_state()) + + +async def _test_loopback_receives_code_and_state() -> None: + server = await create_oauth_loopback_callback_server( + state="state", + timeout_ms=1000, + ) + + async def send_callback(): + urllib.request.urlopen(f"{server.redirect_uri}?code=abc&state=state").read() + + task = asyncio.create_task(server.wait_for_callback()) + await send_callback() + result = await task + + assert result.code == "abc" + assert result.state == "state" + + +def test_loopback_rejects_wrong_state() -> None: + asyncio.run(_test_loopback_rejects_wrong_state()) + + +async def _test_loopback_rejects_wrong_state() -> None: + server = await create_oauth_loopback_callback_server( + state="state", + timeout_ms=1000, + ) + task = asyncio.create_task(server.wait_for_callback()) + + with pytest.raises(Exception): + urllib.request.urlopen(f"{server.redirect_uri}?code=abc&state=wrong").read() + with pytest.raises(RuntimeError, match="state mismatch"): + await task diff --git a/tests/test_oauth_pkce.py b/tests/test_oauth_pkce.py new file mode 100644 index 00000000..718d467a --- /dev/null +++ b/tests/test_oauth_pkce.py @@ -0,0 +1,23 @@ +"""Tests for OAuth PKCE helpers.""" + +from gopher_mcp_python.oauth_pkce import ( + base64_url_encode, + create_code_challenge, + create_code_verifier, +) + + +def test_challenge_is_deterministic_for_verifier() -> None: + verifier = "fixed-verifier" + + assert create_code_challenge(verifier) == create_code_challenge(verifier) + + +def test_base64_url_encode_omits_padding() -> None: + assert base64_url_encode(b"\xfb\xff") == "-_8" + + +def test_verifier_length_is_valid() -> None: + verifier = create_code_verifier() + + assert 43 <= len(verifier) <= 128 diff --git a/tests/test_oauth_registration.py b/tests/test_oauth_registration.py new file mode 100644 index 00000000..83962764 --- /dev/null +++ b/tests/test_oauth_registration.py @@ -0,0 +1,56 @@ +"""Tests for OAuth dynamic registration.""" + +from tests.test_oauth_discovery import _json, _start_server + +from gopher_mcp_python.oauth_discovery import OAuthAuthorizationServerMetadata +from gopher_mcp_python.oauth_registration import register_oauth_client +from gopher_mcp_python.runtime_options import GopherAgentOAuthOptions + + +def test_register_oauth_client_sends_redirect_uri_and_scopes() -> None: + captured = {} + + def handle(handler): + length = int(handler.headers.get("Content-Length", "0")) + captured["body"] = handler.rfile.read(length).decode("utf-8") + _json(handler, 200, {"client_id": "cid", "client_secret": "secret"}) + + server = _start_server(handle) + try: + client = register_oauth_client( + metadata=OAuthAuthorizationServerMetadata( + issuer=server.url, + authorization_endpoint=f"{server.url}/authorize", + token_endpoint=f"{server.url}/token", + registration_endpoint=f"{server.url}/register", + scopes_supported=[], + raw_json="{}", + ), + redirect_uri="http://127.0.0.1/callback", + scopes=["openid"], + oauth=GopherAgentOAuthOptions(client_name="Test Client"), + ) + finally: + server.close() + + assert client.client_id == "cid" + assert client.client_secret == "secret" + assert "http://127.0.0.1/callback" in captured["body"] + assert "openid" in captured["body"] + + +def test_register_oauth_client_requires_registration_endpoint() -> None: + metadata = OAuthAuthorizationServerMetadata( + issuer="https://auth.example.com", + authorization_endpoint="https://auth.example.com/authorize", + token_endpoint="https://auth.example.com/token", + scopes_supported=[], + raw_json="{}", + ) + + try: + register_oauth_client(metadata, "http://127.0.0.1/callback", []) + except RuntimeError as exc: + assert "oauth_registration_required" in str(exc) + else: + raise AssertionError("expected registration failure") diff --git a/tests/test_oauth_resolver.py b/tests/test_oauth_resolver.py new file mode 100644 index 00000000..30407509 --- /dev/null +++ b/tests/test_oauth_resolver.py @@ -0,0 +1,107 @@ +"""Tests for OAuth runtime option resolution.""" + +import asyncio + +import pytest + +from gopher_mcp_python.oauth_discovery import McpOAuthChallenge +from gopher_mcp_python.oauth_resolver import ( + resolve_runtime_options_with_oauth, + set_oauth_resolver_hooks_for_test, +) +from gopher_mcp_python.runtime_options import GopherAgentRuntimeOptions + + +def teardown_function() -> None: + set_oauth_resolver_hooks_for_test() + + +def test_disabled_mode_is_noop() -> None: + asyncio.run(_test_disabled_mode_is_noop()) + + +async def _test_disabled_mode_is_noop() -> None: + async def probe(_url): + raise AssertionError("probe should not run") + + set_oauth_resolver_hooks_for_test(probe_challenge=probe) + + result = await resolve_runtime_options_with_oauth( + urls=["https://mcp.example.com/mcp"], + oauth={"mode": "disabled"}, + ) + + assert result is None + + +def test_existing_authorization_header_skips_probe() -> None: + asyncio.run(_test_existing_authorization_header_skips_probe()) + + +async def _test_existing_authorization_header_skips_probe() -> None: + async def probe(_url): + raise AssertionError("probe should not run") + + runtime_options = GopherAgentRuntimeOptions( + headers={"Authorization": "Bearer explicit"} + ) + set_oauth_resolver_hooks_for_test(probe_challenge=probe) + + result = await resolve_runtime_options_with_oauth( + urls=["https://mcp.example.com/mcp"], + runtime_options=runtime_options, + ) + + assert result is not None + assert result.headers == {"Authorization": "Bearer explicit"} + + +def test_one_oauth_server_returns_token_options() -> None: + asyncio.run(_test_one_oauth_server_returns_token_options()) + + +async def _test_one_oauth_server_returns_token_options() -> None: + async def probe(url): + return McpOAuthChallenge( + url=url, + requires_oauth=True, + http_status=401, + resource_metadata_url="https://mcp.example.com/resource", + ) + + async def acquire(challenges, oauth): + assert len(challenges) == 1 + return GopherAgentRuntimeOptions(access_token="token") + + set_oauth_resolver_hooks_for_test( + probe_challenge=probe, + acquire_token=acquire, + ) + + result = await resolve_runtime_options_with_oauth( + urls=["https://mcp.example.com/mcp"], + ) + + assert result is not None + assert result.access_token == "token" + + +def test_incompatible_oauth_servers_fail() -> None: + asyncio.run(_test_incompatible_oauth_servers_fail()) + + +async def _test_incompatible_oauth_servers_fail() -> None: + async def probe(url): + return McpOAuthChallenge( + url=url, + requires_oauth=True, + http_status=401, + resource_metadata_url=f"{url}/resource", + ) + + set_oauth_resolver_hooks_for_test(probe_challenge=probe) + + with pytest.raises(RuntimeError, match="multiple protected MCP servers"): + await resolve_runtime_options_with_oauth( + urls=["https://one.example.com/mcp", "https://two.example.com/mcp"] + ) diff --git a/tests/test_oauth_server_targets.py b/tests/test_oauth_server_targets.py new file mode 100644 index 00000000..051b915e --- /dev/null +++ b/tests/test_oauth_server_targets.py @@ -0,0 +1,58 @@ +"""Tests for OAuth MCP server target extraction.""" + +import json + +import pytest + +from gopher_mcp_python.oauth_server_targets import extract_mcp_server_targets + + +def test_extracts_direct_url() -> None: + assert extract_mcp_server_targets(url="https://mcp.example.com/mcp") == [ + {"url": "https://mcp.example.com/mcp"} + ] + + +def test_extracts_nested_config_url_with_identity_metadata() -> None: + config = json.dumps( + { + "data": { + "servers": [ + { + "serverId": "srv-1", + "name": "weather", + "serverName": "weather-tools", + "transport": "http_sse", + "config": {"url": "https://mcp.example.com/mcp"}, + } + ] + } + } + ) + + assert extract_mcp_server_targets(server_config=config) == [ + { + "server_id": "srv-1", + "name": "weather", + "server_name": "weather-tools", + "url": "https://mcp.example.com/mcp", + } + ] + + +def test_ignores_stdio_and_missing_url() -> None: + config = json.dumps( + { + "servers": [ + {"transport": "stdio", "config": {"url": "ignored"}}, + {"transport": "http_sse", "config": {}}, + ] + } + ) + + assert extract_mcp_server_targets(server_config=config) == [] + + +def test_malformed_json_has_useful_error() -> None: + with pytest.raises(ValueError, match="Failed to parse MCP server config"): + extract_mcp_server_targets(server_config="{") diff --git a/tests/test_oauth_token_exchange.py b/tests/test_oauth_token_exchange.py new file mode 100644 index 00000000..df67acdc --- /dev/null +++ b/tests/test_oauth_token_exchange.py @@ -0,0 +1,91 @@ +"""Tests for OAuth token exchange helpers.""" + +import urllib.parse + +import pytest + +from tests.test_oauth_discovery import _json, _start_server + +from gopher_mcp_python.oauth_token_exchange import ( + exchange_oauth_code_for_token, + refresh_oauth_token, +) + + +def test_exchange_code_returns_token_record() -> None: + captured = {} + + def handle(handler): + length = int(handler.headers.get("Content-Length", "0")) + body = handler.rfile.read(length).decode("utf-8") + captured["params"] = urllib.parse.parse_qs(body) + _json( + handler, + 200, + { + "access_token": "access", + "refresh_token": "refresh", + "token_type": "Bearer", + "expires_in": 60, + "scope": "openid", + }, + ) + + server = _start_server(handle) + try: + token = exchange_oauth_code_for_token( + code="code", + redirect_uri="http://127.0.0.1/callback", + code_verifier="verifier", + token_endpoint=f"{server.url}/token", + client_id="cid", + now_ms=1000, + ) + finally: + server.close() + + assert token.access_token == "access" + assert token.refresh_token == "refresh" + assert token.expires_at == 61000 + assert captured["params"]["code_verifier"] == ["verifier"] + + +def test_refresh_token_returns_token_record() -> None: + server = _start_server( + lambda handler: _json( + handler, + 200, + {"access_token": "new-access", "token_type": "Bearer"}, + ) + ) + try: + token = refresh_oauth_token( + refresh_token="refresh", + token_endpoint=f"{server.url}/token", + client_id="cid", + ) + finally: + server.close() + + assert token.access_token == "new-access" + + +def test_token_error_preserves_description() -> None: + server = _start_server( + lambda handler: _json( + handler, + 400, + {"error": "invalid_grant", "error_description": "bad code"}, + ) + ) + try: + with pytest.raises(RuntimeError, match="bad code"): + exchange_oauth_code_for_token( + code="bad", + redirect_uri="http://127.0.0.1/callback", + code_verifier="verifier", + token_endpoint=f"{server.url}/token", + client_id="cid", + ) + finally: + server.close() diff --git a/tests/test_oauth_token_store.py b/tests/test_oauth_token_store.py new file mode 100644 index 00000000..abeb8605 --- /dev/null +++ b/tests/test_oauth_token_store.py @@ -0,0 +1,74 @@ +"""Tests for OAuth token store helpers.""" + +import asyncio + +from gopher_mcp_python.oauth_token_store import ( + InMemoryGopherAgentTokenStore, + create_oauth_token_cache_key, + resolve_oauth_token_from_store, +) +from gopher_mcp_python.runtime_options import GopherAgentTokenRecord + + +def test_cache_key_sorts_and_deduplicates_scopes() -> None: + assert create_oauth_token_cache_key( + resource="res", + issuer="iss", + client_id="cid", + scopes=["email", "openid", "email"], + ) == create_oauth_token_cache_key( + resource="res", + issuer="iss", + client_id="cid", + scopes=["openid", "email"], + ) + + +def test_valid_cached_token_is_returned() -> None: + asyncio.run(_test_valid_cached_token_is_returned()) + + +async def _test_valid_cached_token_is_returned() -> None: + store = InMemoryGopherAgentTokenStore() + token = GopherAgentTokenRecord(access_token="cached", expires_at=2000) + await store.set("key", token) + + result = await resolve_oauth_token_from_store( + store=store, + key="key", + now_ms=1000, + refresh_token=lambda refresh: None, + acquire_token=lambda: None, + ) + + assert result.access_token == "cached" + + +def test_expired_token_refreshes() -> None: + asyncio.run(_test_expired_token_refreshes()) + + +async def _test_expired_token_refreshes() -> None: + store = InMemoryGopherAgentTokenStore() + await store.set( + "key", + GopherAgentTokenRecord( + access_token="old", + refresh_token="refresh", + expires_at=1000, + ), + ) + + async def refresh_token(refresh: str) -> GopherAgentTokenRecord: + assert refresh == "refresh" + return GopherAgentTokenRecord(access_token="new") + + result = await resolve_oauth_token_from_store( + store=store, + key="key", + now_ms=2000, + refresh_token=refresh_token, + acquire_token=lambda: None, + ) + + assert result.access_token == "new" From a2bd07d13f80147154046924747a0af4f4250425 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Thu, 13 Aug 2026 21:17:06 +0800 Subject: [PATCH 05/13] Fix OAuth async agent factories (#15) Summary: - Add OAuth-aware async create paths for direct URL, server config, API key, server selectors, and gateway selectors. - Fetch scoped API configs before OAuth resolution so async selector factories create from the resolved JSON config. - Cover async factory resolver wiring, skip paths, routed config fetches, and resolved-token propagation. --- gopher_mcp_python/__init__.py | 3 +- gopher_mcp_python/agent.py | 258 ++++++++++++ gopher_mcp_python/server_config.py | 69 +++- tests/test_agent_create_with_oauth_async.py | 429 ++++++++++++++++++++ tests/test_server_config.py | 74 ++++ 5 files changed, 830 insertions(+), 3 deletions(-) create mode 100644 tests/test_agent_create_with_oauth_async.py create mode 100644 tests/test_server_config.py diff --git a/gopher_mcp_python/__init__.py b/gopher_mcp_python/__init__.py index d0cb40c0..2bef1bcd 100644 --- a/gopher_mcp_python/__init__.py +++ b/gopher_mcp_python/__init__.py @@ -43,7 +43,7 @@ ConnectionError, TimeoutError, ) -from gopher_mcp_python.server_config import ServerConfig +from gopher_mcp_python.server_config import ServerConfig, ServerConfigRoute from gopher_mcp_python.ffi import GopherOrchLibrary, GopherOrchHandle __version__ = "0.1.2" @@ -136,6 +136,7 @@ def __getattr__(name: str): "AgentResultStatus", "AgentResultBuilder", "ServerConfig", + "ServerConfigRoute", # Errors "AgentError", "ApiKeyError", diff --git a/gopher_mcp_python/agent.py b/gopher_mcp_python/agent.py index 9f43664c..d9c8f329 100644 --- a/gopher_mcp_python/agent.py +++ b/gopher_mcp_python/agent.py @@ -29,12 +29,17 @@ import atexit from typing import Callable, Optional +import gopher_mcp_python.oauth_resolver as oauth_resolver from gopher_mcp_python.config import GopherAgentConfig from gopher_mcp_python.runtime_options import ( + GopherAgentOAuthOptions, + GopherAgentRuntimeOptions, RuntimeOptionsInput, + normalize_create_options, normalize_runtime_options, ) from gopher_mcp_python.result import AgentResult, AgentResultStatus +from gopher_mcp_python.server_config import ServerConfig, ServerConfigRoute from gopher_mcp_python.errors import AgentError, TimeoutError from gopher_mcp_python.ffi import GopherOrchLibrary, GopherOrchHandle @@ -154,6 +159,28 @@ def create(config: GopherAgentConfig) -> "GopherAgent": return GopherAgent(handle) + @staticmethod + async def create_async(config: GopherAgentConfig) -> "GopherAgent": + """ + Create a new GopherAgent, resolving SDK OAuth credentials if needed. + + Sync create() remains non-interactive. This async factory is the + OAuth-aware path for local/desktop clients. + """ + if config.has_api_key(): + return await GopherAgent.create_with_api_key_async( + config.provider, + config.model, + config.api_key, + config.runtime_options, + ) + return await GopherAgent.create_with_server_config_async( + config.provider, + config.model, + config.server_config, + config.runtime_options, + ) + @staticmethod def create_with_api_key( provider: str, @@ -183,6 +210,33 @@ def create_with_api_key( builder.runtime_options(runtime_options) return GopherAgent.create(builder.build()) + @staticmethod + async def create_with_api_key_async( + provider: str, + model: str, + api_key: str, + options: RuntimeOptionsInput = None, + ) -> "GopherAgent": + """ + Create an agent from a Gopher API key with SDK-side OAuth auto-flow. + """ + create_options = normalize_create_options(options) + runtime_options = normalize_runtime_options(create_options) + oauth = create_options.oauth if create_options is not None else None + if _should_skip_oauth(runtime_options, oauth): + return GopherAgent.create_with_api_key( + provider, model, api_key, runtime_options + ) + + return await _create_from_api_config_async( + provider, + model, + api_key, + route=None, + runtime_options=runtime_options, + oauth=oauth, + ) + @staticmethod def create_with_server_config( provider: str, @@ -212,6 +266,36 @@ def create_with_server_config( builder.runtime_options(runtime_options) return GopherAgent.create(builder.build()) + @staticmethod + async def create_with_server_config_async( + provider: str, + model: str, + server_config: str, + options: RuntimeOptionsInput = None, + ) -> "GopherAgent": + """ + Create an agent from server config with SDK-side OAuth auto-flow. + """ + create_options = normalize_create_options(options) + runtime_options = normalize_runtime_options(create_options) + oauth = create_options.oauth if create_options is not None else None + if _should_skip_oauth(runtime_options, oauth): + return GopherAgent.create_with_server_config( + provider, model, server_config, runtime_options + ) + + resolved_runtime_options = await oauth_resolver.resolve_runtime_options_with_oauth( + urls=[], + server_config=server_config, + runtime_options=runtime_options, + oauth=oauth, + ) + return GopherAgent._create_from_ffi( + lambda lib: lib.agent_create_by_json( + provider, model, server_config, resolved_runtime_options + ) + ) + @staticmethod def create_with_server_id( provider: str, @@ -244,6 +328,34 @@ def create_with_server_id( ) ) + @staticmethod + async def create_with_server_id_async( + provider: str, + model: str, + api_key: str, + server_id: str, + options: RuntimeOptionsInput = None, + ) -> "GopherAgent": + """ + Create an agent scoped by MCP server id with SDK-side OAuth auto-flow. + """ + create_options = normalize_create_options(options) + runtime_options = normalize_runtime_options(create_options) + oauth = create_options.oauth if create_options is not None else None + if _should_skip_oauth(runtime_options, oauth): + return GopherAgent.create_with_server_id( + provider, model, api_key, server_id, runtime_options + ) + + return await _create_from_api_config_async( + provider, + model, + api_key, + route=ServerConfigRoute("serverId", server_id), + runtime_options=runtime_options, + oauth=oauth, + ) + @staticmethod def create_with_server_name( provider: str, @@ -276,6 +388,34 @@ def create_with_server_name( ) ) + @staticmethod + async def create_with_server_name_async( + provider: str, + model: str, + api_key: str, + server_name: str, + options: RuntimeOptionsInput = None, + ) -> "GopherAgent": + """ + Create an agent scoped by MCP server name with SDK-side OAuth auto-flow. + """ + create_options = normalize_create_options(options) + runtime_options = normalize_runtime_options(create_options) + oauth = create_options.oauth if create_options is not None else None + if _should_skip_oauth(runtime_options, oauth): + return GopherAgent.create_with_server_name( + provider, model, api_key, server_name, runtime_options + ) + + return await _create_from_api_config_async( + provider, + model, + api_key, + route=ServerConfigRoute("serverName", server_name), + runtime_options=runtime_options, + oauth=oauth, + ) + @staticmethod def create_with_gateway_id( provider: str, @@ -308,6 +448,34 @@ def create_with_gateway_id( ) ) + @staticmethod + async def create_with_gateway_id_async( + provider: str, + model: str, + api_key: str, + gateway_id: str, + options: RuntimeOptionsInput = None, + ) -> "GopherAgent": + """ + Create an agent scoped by MCP gateway id with SDK-side OAuth auto-flow. + """ + create_options = normalize_create_options(options) + runtime_options = normalize_runtime_options(create_options) + oauth = create_options.oauth if create_options is not None else None + if _should_skip_oauth(runtime_options, oauth): + return GopherAgent.create_with_gateway_id( + provider, model, api_key, gateway_id, runtime_options + ) + + return await _create_from_api_config_async( + provider, + model, + api_key, + route=ServerConfigRoute("gatewayId", gateway_id), + runtime_options=runtime_options, + oauth=oauth, + ) + @staticmethod def create_with_gateway_name( provider: str, @@ -340,6 +508,34 @@ def create_with_gateway_name( ) ) + @staticmethod + async def create_with_gateway_name_async( + provider: str, + model: str, + api_key: str, + gateway_name: str, + options: RuntimeOptionsInput = None, + ) -> "GopherAgent": + """ + Create an agent scoped by MCP gateway name with SDK-side OAuth auto-flow. + """ + create_options = normalize_create_options(options) + runtime_options = normalize_runtime_options(create_options) + oauth = create_options.oauth if create_options is not None else None + if _should_skip_oauth(runtime_options, oauth): + return GopherAgent.create_with_gateway_name( + provider, model, api_key, gateway_name, runtime_options + ) + + return await _create_from_api_config_async( + provider, + model, + api_key, + route=ServerConfigRoute("gatewayName", gateway_name), + runtime_options=runtime_options, + oauth=oauth, + ) + @staticmethod def create_with_url( provider: str, @@ -371,6 +567,33 @@ def create_with_url( ) ) + @staticmethod + async def create_with_url_async( + provider: str, + model: str, + url: str, + options: RuntimeOptionsInput = None, + ) -> "GopherAgent": + """ + Create an agent for a direct MCP URL with SDK-side OAuth auto-flow. + """ + create_options = normalize_create_options(options) + runtime_options = normalize_runtime_options(create_options) + oauth = create_options.oauth if create_options is not None else None + if _should_skip_oauth(runtime_options, oauth): + return GopherAgent.create_with_url(provider, model, url, runtime_options) + + resolved_runtime_options = await oauth_resolver.resolve_url_runtime_options_with_oauth( + url, + runtime_options=runtime_options, + oauth=oauth, + ) + return GopherAgent._create_from_ffi( + lambda lib: lib.agent_create_by_url( + provider, model, url, resolved_runtime_options + ) + ) + @staticmethod def _create_from_ffi( create_handle: Callable[[GopherOrchLibrary], Optional[GopherOrchHandle]], @@ -493,6 +716,41 @@ def _setup_cleanup_handler() -> None: atexit.register(GopherAgent.shutdown) +async def _create_from_api_config_async( + provider: str, + model: str, + api_key: str, + route: Optional[ServerConfigRoute], + runtime_options: Optional[GopherAgentRuntimeOptions], + oauth: Optional[GopherAgentOAuthOptions], +) -> GopherAgent: + server_config = ServerConfig.fetch(api_key, route=route) + resolved_runtime_options = await oauth_resolver.resolve_runtime_options_with_oauth( + urls=[], + server_config=server_config, + runtime_options=runtime_options, + oauth=oauth, + ) + return GopherAgent._create_from_ffi( + lambda lib: lib.agent_create_by_json( + provider, model, server_config, resolved_runtime_options + ) + ) + + +def _should_skip_oauth( + runtime_options: Optional[GopherAgentRuntimeOptions], + oauth: Optional[GopherAgentOAuthOptions], +) -> bool: + if oauth is not None and oauth.mode == "disabled": + return True + if runtime_options is None: + return False + if runtime_options.access_token is not None: + return True + return any(name.lower() == "authorization" for name in runtime_options.headers) + + def _build_create_error_message() -> str: """ Build the AgentError message for a null native create*() result. diff --git a/gopher_mcp_python/server_config.py b/gopher_mcp_python/server_config.py index c8070d68..54b42622 100644 --- a/gopher_mcp_python/server_config.py +++ b/gopher_mcp_python/server_config.py @@ -4,9 +4,26 @@ Provides utilities for fetching and managing MCP server configurations. """ +import os +from dataclasses import dataclass from typing import Optional +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode +from urllib.request import Request, urlopen + +from gopher_mcp_python.errors import AgentError, ApiKeyError, ConnectionError from gopher_mcp_python.ffi import GopherOrchLibrary -from gopher_mcp_python.errors import ApiKeyError, ConnectionError + +FETCH_TIMEOUT_SECONDS = 30 +FETCH_MAX_BODY_PREVIEW = 512 + + +@dataclass(frozen=True) +class ServerConfigRoute: + """Scoped Gopher API route for fetching a subset of MCP servers.""" + + key: str + value: str class ServerConfig: @@ -15,12 +32,13 @@ class ServerConfig: """ @staticmethod - def fetch(api_key: str) -> str: + def fetch(api_key: str, route: Optional[ServerConfigRoute] = None) -> str: """ Fetch server configuration from the API. Args: api_key: API key for authentication + route: Optional scoped server/gateway route Returns: JSON string containing server configuration @@ -32,6 +50,9 @@ def fetch(api_key: str) -> str: if not api_key: raise ApiKeyError("API key is required") + if route is not None: + return _fetch_with_route(api_key, route) + lib = GopherOrchLibrary.get_instance() if lib is None: raise ConnectionError("Native library not available") @@ -45,3 +66,47 @@ def fetch(api_key: str) -> str: raise ConnectionError(error_msg or "Failed to fetch server configuration") return result + + +def _fetch_with_route(api_key: str, route: ServerConfigRoute) -> str: + if route.key not in {"serverId", "serverName", "gatewayId", "gatewayName"}: + raise AgentError(f"Unsupported server config route: {route.key}") + + query = urlencode({route.key: route.value}) + url = f"{_gopher_api_root()}/v1/mcp-servers?{query}" + request = Request( + url, + headers={ + "Accept": "application/json", + "Authorization": f"Bearer {api_key}", + }, + ) + + try: + with urlopen(request, timeout=FETCH_TIMEOUT_SECONDS) as response: + return response.read().decode("utf-8") + except HTTPError as error: + body = error.read().decode("utf-8", errors="replace") + preview = ( + f"{body[:FETCH_MAX_BODY_PREVIEW]}..." + if len(body) > FETCH_MAX_BODY_PREVIEW + else body + ) + suffix = f": {preview}" if preview else "" + raise AgentError(f"HTTP request failed with status {error.code}{suffix}") + except TimeoutError as error: + raise AgentError( + f"Failed to fetch servers: request timed out after " + f"{FETCH_TIMEOUT_SECONDS * 1000}ms" + ) from error + except URLError as error: + raise AgentError(f"Failed to fetch servers: {error.reason}") from error + except OSError as error: + raise AgentError(f"Failed to fetch servers: {error}") from error + + +def _gopher_api_root() -> str: + value = os.environ.get("GOPHER_SDK_TEST") + if value is not None and value.strip().lower() in {"true", "1", "yes"}: + return "https://api-test.gopher.security" + return "https://api.gopher.security" diff --git a/tests/test_agent_create_with_oauth_async.py b/tests/test_agent_create_with_oauth_async.py new file mode 100644 index 00000000..2715ae58 --- /dev/null +++ b/tests/test_agent_create_with_oauth_async.py @@ -0,0 +1,429 @@ +"""Tests for OAuth-aware async GopherAgent factories.""" + +import asyncio + +import gopher_mcp_python.agent as agent_module +from gopher_mcp_python import GopherAgent +from gopher_mcp_python.runtime_options import GopherAgentRuntimeOptions + + +class FakeLibrary: + def __init__(self) -> None: + self.calls = [] + + def agent_create_by_url(self, provider, model, url, runtime_options=None): + self.calls.append(("url", provider, model, url, runtime_options)) + return 2001 + + def agent_create_by_json(self, provider, model, server_config, runtime_options=None): + self.calls.append(("json", provider, model, server_config, runtime_options)) + return 2002 + + def agent_create_by_api_key(self, provider, model, api_key, runtime_options=None): + self.calls.append(("api_key", provider, model, api_key, runtime_options)) + return 2003 + + def agent_create_by_server_id( + self, provider, model, api_key, server_id, runtime_options=None + ): + self.calls.append( + ("server_id", provider, model, api_key, server_id, runtime_options) + ) + return 2004 + + def agent_create_by_server_name( + self, provider, model, api_key, server_name, runtime_options=None + ): + self.calls.append( + ("server_name", provider, model, api_key, server_name, runtime_options) + ) + return 2005 + + def agent_create_by_gateway_id( + self, provider, model, api_key, gateway_id, runtime_options=None + ): + self.calls.append( + ("gateway_id", provider, model, api_key, gateway_id, runtime_options) + ) + return 2006 + + def agent_create_by_gateway_name( + self, provider, model, api_key, gateway_name, runtime_options=None + ): + self.calls.append( + ("gateway_name", provider, model, api_key, gateway_name, runtime_options) + ) + return 2007 + + def agent_release(self, handle): + self.calls.append(("release", handle)) + + def get_last_error_message(self): + return None + + def clear_error(self): + self.calls.append(("clear_error",)) + + +def _install_fake_library(monkeypatch): + fake = FakeLibrary() + monkeypatch.setattr(agent_module, "_initialized", True) + monkeypatch.setattr( + agent_module.GopherOrchLibrary, + "get_instance", + staticmethod(lambda: fake), + ) + return fake + + +def test_create_with_url_async_resolves_oauth_token(monkeypatch) -> None: + asyncio.run(_test_create_with_url_async_resolves_oauth_token(monkeypatch)) + + +async def _test_create_with_url_async_resolves_oauth_token(monkeypatch) -> None: + fake = _install_fake_library(monkeypatch) + resolver_calls = [] + + async def resolver(url, runtime_options=None, oauth=None): + resolver_calls.append((url, runtime_options, oauth)) + return GopherAgentRuntimeOptions(access_token="oauth-token") + + monkeypatch.setattr( + agent_module.oauth_resolver, + "resolve_url_runtime_options_with_oauth", + resolver, + ) + + agent = await GopherAgent.create_with_url_async( + "Provider", + "model", + "https://mcp.example.com/mcp", + ) + + call = fake.calls[0] + assert call[:4] == ("url", "Provider", "model", "https://mcp.example.com/mcp") + assert call[4].access_token == "oauth-token" + assert resolver_calls[0][0] == "https://mcp.example.com/mcp" + agent.dispose() + + +def test_create_with_url_async_disabled_oauth_skips_resolver(monkeypatch) -> None: + asyncio.run(_test_create_with_url_async_disabled_oauth_skips_resolver(monkeypatch)) + + +async def _test_create_with_url_async_disabled_oauth_skips_resolver(monkeypatch) -> None: + fake = _install_fake_library(monkeypatch) + + async def resolver(*args, **kwargs): + raise AssertionError("resolver should not run") + + monkeypatch.setattr( + agent_module.oauth_resolver, + "resolve_url_runtime_options_with_oauth", + resolver, + ) + + agent = await GopherAgent.create_with_url_async( + "Provider", + "model", + "https://mcp.example.com/mcp", + {"oauth": {"mode": "disabled"}}, + ) + + assert fake.calls[0] == ( + "url", + "Provider", + "model", + "https://mcp.example.com/mcp", + None, + ) + agent.dispose() + + +def test_create_with_url_async_explicit_token_skips_resolver(monkeypatch) -> None: + asyncio.run(_test_create_with_url_async_explicit_token_skips_resolver(monkeypatch)) + + +async def _test_create_with_url_async_explicit_token_skips_resolver(monkeypatch) -> None: + fake = _install_fake_library(monkeypatch) + + async def resolver(*args, **kwargs): + raise AssertionError("resolver should not run") + + monkeypatch.setattr( + agent_module.oauth_resolver, + "resolve_url_runtime_options_with_oauth", + resolver, + ) + + agent = await GopherAgent.create_with_url_async( + "Provider", + "model", + "https://mcp.example.com/mcp", + {"access_token": "caller-token"}, + ) + + call = fake.calls[0] + assert call[:4] == ("url", "Provider", "model", "https://mcp.example.com/mcp") + assert call[4].access_token == "caller-token" + agent.dispose() + + +def test_create_with_server_config_async_uses_resolved_options(monkeypatch) -> None: + asyncio.run(_test_create_with_server_config_async_uses_resolved_options(monkeypatch)) + + +async def _test_create_with_server_config_async_uses_resolved_options(monkeypatch) -> None: + fake = _install_fake_library(monkeypatch) + resolver_calls = [] + + async def resolver(urls=None, server_config=None, runtime_options=None, oauth=None): + resolver_calls.append((urls, server_config, runtime_options, oauth)) + return GopherAgentRuntimeOptions(access_token="oauth-token") + + monkeypatch.setattr( + agent_module.oauth_resolver, + "resolve_runtime_options_with_oauth", + resolver, + ) + + agent = await GopherAgent.create_with_server_config_async( + "Provider", + "model", + '{"servers":[]}', + ) + + call = fake.calls[0] + assert call[:4] == ("json", "Provider", "model", '{"servers":[]}') + assert call[4].access_token == "oauth-token" + assert resolver_calls[0][1] == '{"servers":[]}' + agent.dispose() + + +def test_create_with_api_key_async_fetches_config_before_oauth(monkeypatch) -> None: + asyncio.run(_test_create_with_api_key_async_fetches_config_before_oauth(monkeypatch)) + + +async def _test_create_with_api_key_async_fetches_config_before_oauth(monkeypatch) -> None: + fake = _install_fake_library(monkeypatch) + server_config = '{"servers":[{"config":{"url":"https://mcp.example.com/mcp"}}]}' + + monkeypatch.setattr( + agent_module.ServerConfig, + "fetch", + staticmethod(lambda api_key, route=None: server_config), + ) + + async def resolver(urls=None, server_config=None, runtime_options=None, oauth=None): + return GopherAgentRuntimeOptions(access_token="oauth-token") + + monkeypatch.setattr( + agent_module.oauth_resolver, + "resolve_runtime_options_with_oauth", + resolver, + ) + + agent = await GopherAgent.create_with_api_key_async( + "Provider", + "model", + "api-key", + ) + + call = fake.calls[0] + assert call[:4] == ("json", "Provider", "model", server_config) + assert call[4].access_token == "oauth-token" + agent.dispose() + + +def test_create_with_server_id_async_fetches_routed_config(monkeypatch) -> None: + asyncio.run(_test_create_with_server_id_async_fetches_routed_config(monkeypatch)) + + +async def _test_create_with_server_id_async_fetches_routed_config(monkeypatch) -> None: + fake = _install_fake_library(monkeypatch) + server_config = '{"servers":[{"id":"srv-1"}]}' + fetch_calls = [] + + def fetch(api_key, route=None): + fetch_calls.append((api_key, route)) + return server_config + + monkeypatch.setattr(agent_module.ServerConfig, "fetch", staticmethod(fetch)) + + async def resolver(urls=None, server_config=None, runtime_options=None, oauth=None): + return GopherAgentRuntimeOptions(access_token="oauth-token") + + monkeypatch.setattr( + agent_module.oauth_resolver, + "resolve_runtime_options_with_oauth", + resolver, + ) + + agent = await GopherAgent.create_with_server_id_async( + "Provider", + "model", + "api-key", + "srv-1", + ) + + assert fetch_calls[0][0] == "api-key" + assert fetch_calls[0][1].key == "serverId" + assert fetch_calls[0][1].value == "srv-1" + call = fake.calls[0] + assert call[:4] == ("json", "Provider", "model", server_config) + assert call[4].access_token == "oauth-token" + agent.dispose() + + +def test_create_with_gateway_name_async_fetches_routed_config(monkeypatch) -> None: + asyncio.run(_test_create_with_gateway_name_async_fetches_routed_config(monkeypatch)) + + +async def _test_create_with_gateway_name_async_fetches_routed_config( + monkeypatch, +) -> None: + fake = _install_fake_library(monkeypatch) + server_config = '{"servers":[{"name":"gateway"}]}' + fetch_calls = [] + + def fetch(api_key, route=None): + fetch_calls.append((api_key, route)) + return server_config + + monkeypatch.setattr(agent_module.ServerConfig, "fetch", staticmethod(fetch)) + + async def resolver(urls=None, server_config=None, runtime_options=None, oauth=None): + return GopherAgentRuntimeOptions(access_token="oauth-token") + + monkeypatch.setattr( + agent_module.oauth_resolver, + "resolve_runtime_options_with_oauth", + resolver, + ) + + agent = await GopherAgent.create_with_gateway_name_async( + "Provider", + "model", + "api-key", + "gateway", + ) + + assert fetch_calls[0][0] == "api-key" + assert fetch_calls[0][1].key == "gatewayName" + assert fetch_calls[0][1].value == "gateway" + call = fake.calls[0] + assert call[:4] == ("json", "Provider", "model", server_config) + assert call[4].access_token == "oauth-token" + agent.dispose() + + +def test_create_with_server_name_async_explicit_token_uses_sync_selector( + monkeypatch, +) -> None: + asyncio.run( + _test_create_with_server_name_async_explicit_token_uses_sync_selector( + monkeypatch + ) + ) + + +async def _test_create_with_server_name_async_explicit_token_uses_sync_selector( + monkeypatch, +) -> None: + fake = _install_fake_library(monkeypatch) + + async def resolver(*args, **kwargs): + raise AssertionError("resolver should not run") + + monkeypatch.setattr( + agent_module.oauth_resolver, + "resolve_runtime_options_with_oauth", + resolver, + ) + + agent = await GopherAgent.create_with_server_name_async( + "Provider", + "model", + "api-key", + "server", + {"access_token": "caller-token"}, + ) + + call = fake.calls[0] + assert call[:5] == ("server_name", "Provider", "model", "api-key", "server") + assert call[5].access_token == "caller-token" + agent.dispose() + + +def test_create_with_gateway_id_async_disabled_oauth_uses_sync_selector( + monkeypatch, +) -> None: + asyncio.run( + _test_create_with_gateway_id_async_disabled_oauth_uses_sync_selector( + monkeypatch + ) + ) + + +async def _test_create_with_gateway_id_async_disabled_oauth_uses_sync_selector( + monkeypatch, +) -> None: + fake = _install_fake_library(monkeypatch) + + async def resolver(*args, **kwargs): + raise AssertionError("resolver should not run") + + monkeypatch.setattr( + agent_module.oauth_resolver, + "resolve_runtime_options_with_oauth", + resolver, + ) + + agent = await GopherAgent.create_with_gateway_id_async( + "Provider", + "model", + "api-key", + "gateway-id", + {"oauth": {"mode": "disabled"}}, + ) + + assert fake.calls[0] == ( + "gateway_id", + "Provider", + "model", + "api-key", + "gateway-id", + None, + ) + agent.dispose() + + +def test_create_async_uses_server_config_async_path(monkeypatch) -> None: + asyncio.run(_test_create_async_uses_server_config_async_path(monkeypatch)) + + +async def _test_create_async_uses_server_config_async_path(monkeypatch) -> None: + fake = _install_fake_library(monkeypatch) + + async def resolver(urls=None, server_config=None, runtime_options=None, oauth=None): + return GopherAgentRuntimeOptions(access_token="oauth-token") + + monkeypatch.setattr( + agent_module.oauth_resolver, + "resolve_runtime_options_with_oauth", + resolver, + ) + + config = ( + agent_module.GopherAgentConfig.builder() + .provider("Provider") + .model("model") + .server_config("{}") + .build() + ) + + agent = await GopherAgent.create_async(config) + + assert fake.calls[0][0] == "json" + assert fake.calls[0][4].access_token == "oauth-token" + agent.dispose() diff --git a/tests/test_server_config.py b/tests/test_server_config.py new file mode 100644 index 00000000..f5d7e4ee --- /dev/null +++ b/tests/test_server_config.py @@ -0,0 +1,74 @@ +"""Tests for Gopher API server config fetching.""" + +from urllib.error import HTTPError + +import pytest + +import gopher_mcp_python.server_config as server_config_module +from gopher_mcp_python.errors import AgentError +from gopher_mcp_python.server_config import ServerConfig, ServerConfigRoute + + +class FakeResponse: + def __init__(self, body: bytes) -> None: + self.body = body + + def __enter__(self) -> "FakeResponse": + return self + + def __exit__(self, exc_type, exc_value, traceback) -> None: + return None + + def read(self) -> bytes: + return self.body + + def close(self) -> None: + return None + + +def test_fetch_with_route_uses_scoped_test_api_url(monkeypatch) -> None: + requests = [] + + def fake_urlopen(request, timeout): + requests.append((request, timeout)) + return FakeResponse(b'{"servers":[]}') + + monkeypatch.setenv("GOPHER_SDK_TEST", "true") + monkeypatch.setattr(server_config_module, "urlopen", fake_urlopen) + + result = ServerConfig.fetch( + "api-key", + route=ServerConfigRoute("serverName", "Draft Mail"), + ) + + request, timeout = requests[0] + assert result == '{"servers":[]}' + assert timeout == server_config_module.FETCH_TIMEOUT_SECONDS + assert request.full_url == ( + "https://api-test.gopher.security/v1/mcp-servers?" + "serverName=Draft+Mail" + ) + assert request.headers["Authorization"] == "Bearer api-key" + assert request.headers["Accept"] == "application/json" + + +def test_fetch_with_route_rejects_unknown_route_key() -> None: + with pytest.raises(AgentError, match="Unsupported server config route"): + ServerConfig.fetch("api-key", route=ServerConfigRoute("bad", "value")) + + +def test_fetch_with_route_includes_http_error_preview(monkeypatch) -> None: + def fake_urlopen(request, timeout): + raise HTTPError( + request.full_url, + 403, + "Forbidden", + hdrs=None, + fp=FakeResponse(b"denied"), + ) + + monkeypatch.delenv("GOPHER_SDK_TEST", raising=False) + monkeypatch.setattr(server_config_module, "urlopen", fake_urlopen) + + with pytest.raises(AgentError, match="HTTP request failed with status 403: denied"): + ServerConfig.fetch("api-key", route=ServerConfigRoute("serverId", "srv-1")) From 756676bb1189776c54a75d189ca139493967a02b Mon Sep 17 00:00:00 2001 From: RahulHere Date: Thu, 13 Aug 2026 21:23:58 +0800 Subject: [PATCH 06/13] Fix OAuth parity coverage (#15) Summary: - Add Python parity tests for authorization URL construction, browser opening, runtime option merging, and loopback callback failures. - Add a local OAuth create-with-URL integration test covering discovery, registration, authorization redirect, token exchange, and native token propagation. - Wrap browser opener failures with the authorization URL for actionable OAuth diagnostics. --- gopher_mcp_python/oauth_browser.py | 5 +- tests/test_oauth_authorization_url.py | 112 +++++++++ tests/test_oauth_browser.py | 53 +++++ .../test_oauth_create_with_url_integration.py | 219 ++++++++++++++++++ tests/test_oauth_loopback.py | 34 +++ tests/test_oauth_runtime_options.py | 48 ++++ 6 files changed, 470 insertions(+), 1 deletion(-) create mode 100644 tests/test_oauth_authorization_url.py create mode 100644 tests/test_oauth_browser.py create mode 100644 tests/test_oauth_create_with_url_integration.py create mode 100644 tests/test_oauth_runtime_options.py diff --git a/gopher_mcp_python/oauth_browser.py b/gopher_mcp_python/oauth_browser.py index 04a70104..25746aee 100644 --- a/gopher_mcp_python/oauth_browser.py +++ b/gopher_mcp_python/oauth_browser.py @@ -15,7 +15,10 @@ def open_authorization_url( return {"opened": False, "url": url} open_fn = opener if opener is not None else webbrowser.open - opened = bool(open_fn(url)) + try: + opened = bool(open_fn(url)) + except Exception as exc: + raise RuntimeError(f"Failed to open OAuth authorization URL {url}: {exc}") from exc result: Dict[str, Any] = {"opened": opened, "url": url} if opened: result["command"] = _command_for_platform(sys.platform) diff --git a/tests/test_oauth_authorization_url.py b/tests/test_oauth_authorization_url.py new file mode 100644 index 00000000..9cc98d50 --- /dev/null +++ b/tests/test_oauth_authorization_url.py @@ -0,0 +1,112 @@ +"""Tests for OAuth authorization URL construction.""" + +from urllib.parse import parse_qs, urlparse + +from gopher_mcp_python.oauth_authorization_url import build_oauth_authorization_url +from gopher_mcp_python.oauth_discovery import ( + OAuthAuthorizationServerMetadata, + OAuthProtectedResourceMetadata, +) + + +METADATA = OAuthAuthorizationServerMetadata( + issuer="https://auth.example.com", + authorization_endpoint="https://auth.example.com/authorize?prompt=consent", + token_endpoint="https://auth.example.com/token", + scopes_supported=["openid", "profile"], + raw_json="{}", +) + + +def _params(url: str): + return parse_qs(urlparse(url).query) + + +def test_includes_all_required_params() -> None: + search = _params( + build_oauth_authorization_url( + metadata=METADATA, + client_id="client-123", + redirect_uri="http://127.0.0.1:49152/callback", + state="state-123", + code_challenge="challenge-123", + ) + ) + + assert search["response_type"] == ["code"] + assert search["client_id"] == ["client-123"] + assert search["redirect_uri"] == ["http://127.0.0.1:49152/callback"] + assert search["state"] == ["state-123"] + assert search["code_challenge"] == ["challenge-123"] + assert search["code_challenge_method"] == ["S256"] + + +def test_scope_defaults_from_options_first() -> None: + search = _params( + build_oauth_authorization_url( + metadata=METADATA, + client_id="client-123", + redirect_uri="http://127.0.0.1:49152/callback", + state="state-123", + code_challenge="challenge-123", + scopes=["email"], + ) + ) + + assert search["scope"] == ["email"] + + +def test_scope_defaults_from_resource_metadata_before_server_metadata() -> None: + resource_metadata = OAuthProtectedResourceMetadata( + resource="https://mcp.example.com/mcp", + authorization_servers=["https://auth.example.com"], + scopes_supported=["mcp:read"], + raw_json="{}", + ) + + search = _params( + build_oauth_authorization_url( + metadata=METADATA, + client_id="client-123", + redirect_uri="http://127.0.0.1:49152/callback", + state="state-123", + code_challenge="challenge-123", + resource_metadata=resource_metadata, + ) + ) + + assert search["scope"] == ["mcp:read"] + + +def test_includes_resource_parameter_when_provided() -> None: + search = _params( + build_oauth_authorization_url( + metadata=METADATA, + client_id="client-123", + redirect_uri="http://127.0.0.1:49152/callback", + state="state-123", + code_challenge="challenge-123", + resource_metadata=OAuthProtectedResourceMetadata( + resource="https://mcp.example.com/mcp", + authorization_servers=["https://auth.example.com"], + scopes_supported=[], + raw_json="{}", + ), + ) + ) + + assert search["resource"] == ["https://mcp.example.com/mcp"] + + +def test_preserves_existing_query_params_on_authorization_endpoint() -> None: + search = _params( + build_oauth_authorization_url( + metadata=METADATA, + client_id="client-123", + redirect_uri="http://127.0.0.1:49152/callback", + state="state-123", + code_challenge="challenge-123", + ) + ) + + assert search["prompt"] == ["consent"] diff --git a/tests/test_oauth_browser.py b/tests/test_oauth_browser.py new file mode 100644 index 00000000..ffecb53d --- /dev/null +++ b/tests/test_oauth_browser.py @@ -0,0 +1,53 @@ +"""Tests for OAuth browser opener helpers.""" + +import pytest + +from gopher_mcp_python.oauth_browser import _command_for_platform, open_authorization_url + + +def test_selects_command_by_platform() -> None: + assert _command_for_platform("darwin") == "open" + assert _command_for_platform("win32") == "cmd" + assert _command_for_platform("linux") == "xdg-open" + + +def test_opens_url_with_injected_opener(monkeypatch) -> None: + opened_urls = [] + monkeypatch.setattr("sys.platform", "darwin") + + result = open_authorization_url( + "https://auth.example.com/authorize", + opener=lambda url: opened_urls.append(url) or True, + ) + + assert result == { + "opened": True, + "url": "https://auth.example.com/authorize", + "command": "open", + } + assert opened_urls == ["https://auth.example.com/authorize"] + + +def test_open_browser_false_does_not_open() -> None: + def opener(url): + raise AssertionError("opener should not run") + + assert open_authorization_url( + "https://auth.example.com/authorize", + open_browser=False, + opener=opener, + ) == { + "opened": False, + "url": "https://auth.example.com/authorize", + } + + +def test_failed_open_includes_authorization_url() -> None: + def opener(url): + raise OSError("missing command") + + with pytest.raises( + RuntimeError, + match="Failed to open OAuth authorization URL https://auth.example.com/authorize", + ): + open_authorization_url("https://auth.example.com/authorize", opener=opener) diff --git a/tests/test_oauth_create_with_url_integration.py b/tests/test_oauth_create_with_url_integration.py new file mode 100644 index 00000000..b828d4a3 --- /dev/null +++ b/tests/test_oauth_create_with_url_integration.py @@ -0,0 +1,219 @@ +"""Local integration test for OAuth-aware create_with_url_async.""" + +import asyncio +import json +import threading +import urllib.parse +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import gopher_mcp_python.agent as agent_module +import gopher_mcp_python.oauth_resolver as oauth_resolver +from gopher_mcp_python import GopherAgent + + +PROVIDER = "AnthropicProvider" +MODEL = "test-model" + + +class FakeLibrary: + def __init__(self) -> None: + self.calls = [] + + def agent_create_by_url(self, provider, model, url, runtime_options=None): + self.calls.append(("url", provider, model, url, runtime_options)) + return 3001 + + def agent_release(self, handle): + self.calls.append(("release", handle)) + + def get_last_error_message(self): + return None + + def clear_error(self): + return None + + +def test_local_oauth_flow_obtains_token_before_url_agent_creation(monkeypatch) -> None: + asyncio.run( + _test_local_oauth_flow_obtains_token_before_url_agent_creation(monkeypatch) + ) + + +async def _test_local_oauth_flow_obtains_token_before_url_agent_creation( + monkeypatch, +) -> None: + server = _FakeOAuthServer.start() + fake = FakeLibrary() + opened_authorization_urls = [] + + def open_authorization_url(url, open_browser=None): + opened_authorization_urls.append(url) + urllib.request.urlopen(url).read() + return {"opened": True, "url": url} + + monkeypatch.setattr(agent_module, "_initialized", True) + monkeypatch.setattr( + agent_module.GopherOrchLibrary, + "get_instance", + staticmethod(lambda: fake), + ) + monkeypatch.setattr(oauth_resolver, "open_authorization_url", open_authorization_url) + + try: + agent = await GopherAgent.create_with_url_async(PROVIDER, MODEL, server.mcp_url) + finally: + oauth_resolver.set_oauth_resolver_hooks_for_test() + oauth_resolver.set_oauth_url_runtime_options_resolver_for_test() + server.close() + + assert len(opened_authorization_urls) == 1 + authorization_url = urllib.parse.urlparse(opened_authorization_urls[0]) + params = urllib.parse.parse_qs(authorization_url.query) + assert authorization_url.path == "/authorize" + assert params["resource"] == [server.mcp_url] + assert params["scope"] == ["openid email"] + + call = fake.calls[0] + assert call[:4] == ("url", PROVIDER, MODEL, server.mcp_url) + assert call[4].access_token == "local-access-token" + agent.dispose() + + +class _FakeOAuthServer: + def __init__(self, server: ThreadingHTTPServer) -> None: + self._server = server + self._thread = threading.Thread(target=server.serve_forever, daemon=True) + self._thread.start() + self.base_url = f"http://127.0.0.1:{server.server_address[1]}" + self.mcp_url = f"{self.base_url}/mcp" + + @classmethod + def start(cls) -> "_FakeOAuthServer": + owner = {"base_url": ""} + + class Handler(BaseHTTPRequestHandler): + def log_message(self, fmt, *args): + return + + def do_GET(self): + _handle_fake_oauth_request(self, owner["base_url"]) + + def do_POST(self): + _handle_fake_oauth_request(self, owner["base_url"]) + + server = cls(ThreadingHTTPServer(("127.0.0.1", 0), Handler)) + owner["base_url"] = server.base_url + return server + + def close(self) -> None: + self._server.shutdown() + self._server.server_close() + self._thread.join(timeout=1) + + +def _handle_fake_oauth_request(handler: BaseHTTPRequestHandler, base_url: str) -> None: + parsed = urllib.parse.urlparse(handler.path) + + if handler.command == "POST" and parsed.path == "/mcp": + handler.send_response(401) + handler.send_header( + "WWW-Authenticate", + f'Bearer realm="mcp", resource_metadata="' + f'{base_url}/.well-known/oauth-protected-resource/mcp"', + ) + handler.end_headers() + return + + if ( + handler.command == "GET" + and parsed.path == "/.well-known/oauth-protected-resource/mcp" + ): + _json( + handler, + { + "resource": f"{base_url}/mcp", + "authorization_servers": [base_url], + "scopes_supported": ["openid", "email"], + }, + ) + return + + if ( + handler.command == "GET" + and parsed.path == "/.well-known/oauth-authorization-server" + ): + _json( + handler, + { + "issuer": base_url, + "authorization_endpoint": f"{base_url}/authorize", + "token_endpoint": f"{base_url}/token", + "registration_endpoint": f"{base_url}/register", + "scopes_supported": ["openid", "email"], + }, + ) + return + + if handler.command == "POST" and parsed.path == "/register": + _json(handler, {"client_id": "local-client"}) + return + + if handler.command == "GET" and parsed.path == "/authorize": + params = urllib.parse.parse_qs(parsed.query) + redirect_uri = params.get("redirect_uri", [None])[0] + state = params.get("state", [None])[0] + if redirect_uri is None or state is None: + _text(handler, 400, "missing redirect_uri or state") + return + + callback = urllib.parse.urlparse(redirect_uri) + callback_query = urllib.parse.urlencode( + {"code": "local-auth-code", "state": state} + ) + location = urllib.parse.urlunparse(callback._replace(query=callback_query)) + handler.send_response(302) + handler.send_header("Location", location) + handler.end_headers() + return + + if handler.command == "POST" and parsed.path == "/token": + body = _read_body(handler) + form = urllib.parse.parse_qs(body) + if form.get("code") != ["local-auth-code"]: + _text(handler, 400, "bad code") + return + _json( + handler, + { + "access_token": "local-access-token", + "token_type": "Bearer", + "expires_in": 3600, + }, + ) + return + + _text(handler, 404, "not found") + + +def _json(handler: BaseHTTPRequestHandler, body) -> None: + data = json.dumps(body).encode("utf-8") + handler.send_response(200) + handler.send_header("Content-Type", "application/json") + handler.send_header("Content-Length", str(len(data))) + handler.end_headers() + handler.wfile.write(data) + + +def _text(handler: BaseHTTPRequestHandler, status: int, body: str) -> None: + data = body.encode("utf-8") + handler.send_response(status) + handler.send_header("Content-Type", "text/plain") + handler.send_header("Content-Length", str(len(data))) + handler.end_headers() + handler.wfile.write(data) + + +def _read_body(handler: BaseHTTPRequestHandler) -> str: + length = int(handler.headers.get("Content-Length", "0")) + return handler.rfile.read(length).decode("utf-8") diff --git a/tests/test_oauth_loopback.py b/tests/test_oauth_loopback.py index 05c7f823..5de380ae 100644 --- a/tests/test_oauth_loopback.py +++ b/tests/test_oauth_loopback.py @@ -44,3 +44,37 @@ async def _test_loopback_rejects_wrong_state() -> None: urllib.request.urlopen(f"{server.redirect_uri}?code=abc&state=wrong").read() with pytest.raises(RuntimeError, match="state mismatch"): await task + + +def test_loopback_captures_oauth_error() -> None: + asyncio.run(_test_loopback_captures_oauth_error()) + + +async def _test_loopback_captures_oauth_error() -> None: + server = await create_oauth_loopback_callback_server( + state="state", + timeout_ms=1000, + ) + task = asyncio.create_task(server.wait_for_callback()) + + with pytest.raises(Exception): + urllib.request.urlopen( + f"{server.redirect_uri}?error=access_denied" + "&error_description=Nope&state=state" + ).read() + with pytest.raises(RuntimeError, match="access_denied: Nope"): + await task + + +def test_loopback_times_out() -> None: + asyncio.run(_test_loopback_times_out()) + + +async def _test_loopback_times_out() -> None: + server = await create_oauth_loopback_callback_server( + state="state", + timeout_ms=10, + ) + + with pytest.raises(asyncio.TimeoutError): + await server.wait_for_callback() diff --git a/tests/test_oauth_runtime_options.py b/tests/test_oauth_runtime_options.py new file mode 100644 index 00000000..56e26ee1 --- /dev/null +++ b/tests/test_oauth_runtime_options.py @@ -0,0 +1,48 @@ +"""Tests for merging OAuth tokens into runtime options.""" + +from gopher_mcp_python.oauth_runtime_options import merge_oauth_token_into_runtime_options +from gopher_mcp_python.runtime_options import ( + GopherAgentRuntimeOptions, + GopherAgentTokenRecord, +) + + +TOKEN = GopherAgentTokenRecord(access_token="oauth-token", token_type="Bearer") + + +def test_explicit_authorization_header_wins() -> None: + result = merge_oauth_token_into_runtime_options( + GopherAgentRuntimeOptions(headers={"Authorization": "Bearer caller-token"}), + TOKEN, + ) + + assert result.headers == {"Authorization": "Bearer caller-token"} + assert result.access_token is None + + +def test_explicit_access_token_wins() -> None: + result = merge_oauth_token_into_runtime_options( + GopherAgentRuntimeOptions(access_token="caller-token"), + TOKEN, + ) + + assert result.access_token == "caller-token" + + +def test_oauth_token_fills_empty_options() -> None: + result = merge_oauth_token_into_runtime_options(None, TOKEN) + + assert result.access_token == "oauth-token" + + +def test_existing_unrelated_headers_are_preserved() -> None: + result = merge_oauth_token_into_runtime_options( + GopherAgentRuntimeOptions(headers={"X-Tenant": "tenant-a"}), + TOKEN, + ) + + assert result.headers == { + "X-Tenant": "tenant-a", + "Authorization": "Bearer oauth-token", + } + assert result.access_token == "oauth-token" From 70602ae887c8dbf881b2c30653e770136dc806e7 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Thu, 13 Aug 2026 21:26:28 +0800 Subject: [PATCH 07/13] Fix agent finalizer fallback (#15) Summary: - Add a weakref finalizer that releases forgotten native agent handles on a best-effort basis. - Keep dispose() deterministic and idempotent by invoking and detaching the finalizer path at most once. - Add lifecycle tests for explicit dispose, context manager cleanup, GC fallback, and no double release. --- gopher_mcp_python/agent.py | 23 +++++++++-- tests/test_agent_lifecycle.py | 77 +++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 tests/test_agent_lifecycle.py diff --git a/gopher_mcp_python/agent.py b/gopher_mcp_python/agent.py index d9c8f329..2a1dcbe6 100644 --- a/gopher_mcp_python/agent.py +++ b/gopher_mcp_python/agent.py @@ -27,6 +27,7 @@ """ import atexit +import weakref from typing import Callable, Optional import gopher_mcp_python.oauth_resolver as oauth_resolver @@ -60,6 +61,7 @@ def __init__(self, handle: GopherOrchHandle) -> None: """ self._handle = handle self._disposed = False + self._finalizer = weakref.finalize(self, _release_handle_best_effort, handle) def __enter__(self) -> "GopherAgent": """Context manager entry.""" @@ -687,14 +689,18 @@ def run_detailed(self, query: str, timeout_ms: int = 60000) -> AgentResult: return AgentResult.error(str(e)) def dispose(self) -> None: - """Dispose of the agent and free resources.""" + """ + Dispose of the agent and free native resources deterministically. + + A best-effort finalizer also releases forgotten agents during garbage + collection, but callers should prefer dispose() or a with block. + """ if self._disposed: return self._disposed = True - lib = GopherOrchLibrary.get_instance() - if lib is not None and self._handle is not None: - lib.agent_release(self._handle) + if self._finalizer.alive: + self._finalizer() def is_disposed(self) -> bool: """Check if agent is disposed.""" @@ -716,6 +722,15 @@ def _setup_cleanup_handler() -> None: atexit.register(GopherAgent.shutdown) +def _release_handle_best_effort(handle: GopherOrchHandle) -> None: + try: + lib = GopherOrchLibrary.get_instance() + if lib is not None and handle is not None: + lib.agent_release(handle) + except Exception: + return + + async def _create_from_api_config_async( provider: str, model: str, diff --git a/tests/test_agent_lifecycle.py b/tests/test_agent_lifecycle.py new file mode 100644 index 00000000..1d772468 --- /dev/null +++ b/tests/test_agent_lifecycle.py @@ -0,0 +1,77 @@ +"""Tests for GopherAgent lifecycle cleanup.""" + +import gc +import weakref + +import gopher_mcp_python.agent as agent_module +from gopher_mcp_python import GopherAgent + + +class FakeLibrary: + def __init__(self) -> None: + self.calls = [] + + def agent_release(self, handle): + self.calls.append(("release", handle)) + + +def _install_fake_library(monkeypatch): + fake = FakeLibrary() + monkeypatch.setattr( + agent_module.GopherOrchLibrary, + "get_instance", + staticmethod(lambda: fake), + ) + return fake + + +def test_dispose_is_idempotent(monkeypatch) -> None: + fake = _install_fake_library(monkeypatch) + agent = GopherAgent(4001) + + agent.dispose() + agent.dispose() + + assert agent.is_disposed() is True + assert fake.calls == [("release", 4001)] + + +def test_context_manager_cleanup_uses_dispose(monkeypatch) -> None: + fake = _install_fake_library(monkeypatch) + + with GopherAgent(4002) as agent: + assert agent.is_disposed() is False + + assert agent.is_disposed() is True + assert fake.calls == [("release", 4002)] + + +def test_finalizer_releases_undisposed_agent(monkeypatch) -> None: + fake = _install_fake_library(monkeypatch) + agent = GopherAgent(4003) + ref = weakref.ref(agent) + + del agent + for _ in range(5): + gc.collect() + if ref() is None: + break + + assert ref() is None + assert fake.calls.count(("release", 4003)) == 1 + + +def test_dispose_detaches_finalizer(monkeypatch) -> None: + fake = _install_fake_library(monkeypatch) + agent = GopherAgent(4004) + ref = weakref.ref(agent) + + agent.dispose() + del agent + for _ in range(5): + gc.collect() + if ref() is None: + break + + assert ref() is None + assert fake.calls.count(("release", 4004)) == 1 From 7149b2307f5b4b10b5a0d9b18ad2cc4c2deca310 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Fri, 14 Aug 2026 11:39:04 +0800 Subject: [PATCH 08/13] Fix hosted MCP OAuth fallback (#15) Summary: Add a Gopher-hosted MCP OAuth fallback for 404 probes, support synthetic protected-resource metadata in the resolver, fix path-based OIDC discovery, and cover the flow with discovery/resolver regression tests. --- gopher_mcp_python/oauth_discovery.py | 51 +++++++++++++++++ gopher_mcp_python/oauth_resolver.py | 34 ++++++++--- tests/test_oauth_discovery.py | 86 ++++++++++++++++++++++++++++ tests/test_oauth_resolver.py | 29 ++++++++++ 4 files changed, 191 insertions(+), 9 deletions(-) diff --git a/gopher_mcp_python/oauth_discovery.py b/gopher_mcp_python/oauth_discovery.py index 6b98326a..9e3ac0f2 100644 --- a/gopher_mcp_python/oauth_discovery.py +++ b/gopher_mcp_python/oauth_discovery.py @@ -8,6 +8,19 @@ from urllib.parse import urlparse, urlunparse +GOPHER_HOSTED_OAUTH_DEFAULT_SCOPES = ["openid", "profile", "email"] +GOPHER_HOSTED_OAUTH_ENDPOINTS = { + "mcp.gopher.security": { + "authorization_server": "https://auth.gopher.security/realms/gopher-mcp", + "registration_endpoint": "https://api.gopher.security/oauth/register", + }, + "mcp-test.gopher.security": { + "authorization_server": "https://auth-test.gopher.security/realms/gopher-mcp", + "registration_endpoint": "https://api-test.gopher.security/oauth/register", + }, +} + + MCP_DISCOVERY_BODY = json.dumps( { "jsonrpc": "2.0", @@ -35,6 +48,7 @@ class McpOAuthChallenge: authorization_server: Optional[str] = None resource: Optional[str] = None scopes: Optional[List[str]] = None + registration_endpoint: Optional[str] = None @dataclass @@ -81,6 +95,9 @@ def probe_mcp_oauth_challenge(url: str, timeout: float = 10.0) -> McpOAuthChalle ) except urllib.error.HTTPError as exc: if exc.code != 401: + fallback = _gopher_hosted_oauth_challenge(url, exc.code) + if fallback is not None: + return fallback raise RuntimeError( f"oauth_metadata_fetch_failed: MCP OAuth probe for {url} " f"received HTTP {exc.code}" @@ -110,6 +127,29 @@ def probe_mcp_oauth_challenge(url: str, timeout: float = 10.0) -> McpOAuthChalle ) +def _gopher_hosted_oauth_challenge( + url: str, + http_status: int, +) -> Optional[McpOAuthChallenge]: + if http_status != 404: + return None + + hostname = urlparse(url).hostname + endpoints = GOPHER_HOSTED_OAUTH_ENDPOINTS.get(hostname or "") + if endpoints is None: + return None + + return McpOAuthChallenge( + url=url, + requires_oauth=True, + http_status=http_status, + authorization_server=endpoints["authorization_server"], + registration_endpoint=endpoints["registration_endpoint"], + resource=url, + scopes=list(GOPHER_HOSTED_OAUTH_DEFAULT_SCOPES), + ) + + def parse_www_authenticate_param(challenge: str, name: str) -> Optional[str]: """Parse a parameter from a Bearer WWW-Authenticate challenge.""" for part in _split_challenge_params(challenge): @@ -247,6 +287,17 @@ def _split_challenge_params(challenge: str) -> List[str]: def _build_well_known_url(issuer: str, well_known_name: str) -> str: parsed = urlparse(issuer) path = "" if parsed.path in ("", "/") else parsed.path.rstrip("/") + if well_known_name == "openid-configuration": + return urlunparse( + ( + parsed.scheme, + parsed.netloc, + f"{path}/.well-known/{well_known_name}", + "", + "", + "", + ) + ) return urlunparse( ( parsed.scheme, diff --git a/gopher_mcp_python/oauth_resolver.py b/gopher_mcp_python/oauth_resolver.py index 7cdd5b86..73e48d9b 100644 --- a/gopher_mcp_python/oauth_resolver.py +++ b/gopher_mcp_python/oauth_resolver.py @@ -4,6 +4,7 @@ import json import os import sys +from dataclasses import replace from typing import Any, Awaitable, Callable, Dict, List, Optional from gopher_mcp_python.oauth_authorization_url import build_oauth_authorization_url @@ -65,19 +66,16 @@ async def _default_acquire_token( oauth: GopherAgentOAuthOptions, ) -> GopherAgentRuntimeOptions: challenge = challenges[0] - if challenge.resource_metadata_url is None: - raise RuntimeError( - f"oauth_metadata_missing: MCP OAuth challenge for {challenge.url} " - "is missing resource_metadata" - ) - - resource_metadata = fetch_oauth_protected_resource_metadata( - challenge.resource_metadata_url - ) + resource_metadata = _resolve_resource_metadata_for_challenge(challenge) authorization_server = _select_authorization_server(challenge, resource_metadata) authorization_metadata = fetch_oauth_authorization_server_metadata( authorization_server ) + if challenge.registration_endpoint is not None: + authorization_metadata = replace( + authorization_metadata, + registration_endpoint=challenge.registration_endpoint, + ) scopes = _select_scopes(oauth, resource_metadata, authorization_metadata) state = create_code_verifier() loopback = await create_oauth_loopback_callback_server(state=state) @@ -312,6 +310,24 @@ def _select_authorization_server( return metadata.authorization_servers[0] +def _resolve_resource_metadata_for_challenge( + challenge: McpOAuthChallenge, +) -> OAuthProtectedResourceMetadata: + if challenge.resource_metadata_url is not None: + return fetch_oauth_protected_resource_metadata(challenge.resource_metadata_url) + if challenge.authorization_server is None: + raise RuntimeError( + f"oauth_metadata_missing: MCP OAuth challenge for {challenge.url} " + "is missing resource_metadata" + ) + return OAuthProtectedResourceMetadata( + resource=challenge.resource or challenge.url, + authorization_servers=[challenge.authorization_server], + scopes_supported=list(challenge.scopes or []), + raw_json="{}", + ) + + def _select_scopes( oauth: GopherAgentOAuthOptions, resource_metadata: OAuthProtectedResourceMetadata, diff --git a/tests/test_oauth_discovery.py b/tests/test_oauth_discovery.py index 86016ffb..c6d42843 100644 --- a/tests/test_oauth_discovery.py +++ b/tests/test_oauth_discovery.py @@ -2,6 +2,7 @@ import json import threading +import urllib.error from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import pytest @@ -53,6 +54,47 @@ def handle(handler): assert result.resource_metadata_url == f"{server.url}/resource" +def test_probe_uses_gopher_hosted_oauth_fallback_for_prod_404(monkeypatch) -> None: + monkeypatch.setattr("urllib.request.urlopen", _raise_http_404) + hosted_url = "https://mcp.gopher.security/v1/mcp/servers/example/mcp" + result = probe_mcp_oauth_challenge(hosted_url) + + assert result.requires_oauth is True + assert result.http_status == 404 + assert ( + result.authorization_server + == "https://auth.gopher.security/realms/gopher-mcp" + ) + assert result.registration_endpoint == "https://api.gopher.security/oauth/register" + assert result.resource == hosted_url + assert result.scopes == ["openid", "profile", "email"] + + +def test_probe_uses_gopher_hosted_oauth_fallback_for_test_404(monkeypatch) -> None: + monkeypatch.setattr("urllib.request.urlopen", _raise_http_404) + hosted_url = "https://mcp-test.gopher.security/v1/mcp/servers/example/mcp" + result = probe_mcp_oauth_challenge(hosted_url) + + assert result.requires_oauth is True + assert result.http_status == 404 + assert ( + result.authorization_server + == "https://auth-test.gopher.security/realms/gopher-mcp" + ) + assert result.registration_endpoint == "https://api-test.gopher.security/oauth/register" + assert result.resource == hosted_url + assert result.scopes == ["openid", "profile", "email"] + + +def test_probe_non_gopher_404_still_fails() -> None: + server = _start_server(lambda handler: _json(handler, 404, {})) + try: + with pytest.raises(RuntimeError, match="received HTTP 404"): + probe_mcp_oauth_challenge(f"{server.url}/mcp") + finally: + server.close() + + def test_probe_missing_resource_metadata_fails() -> None: def handle(handler): handler.send_response(401) @@ -118,6 +160,40 @@ def handle(handler): assert metadata.registration_endpoint == f"{server.url}/register" +def test_fetches_path_based_oidc_metadata() -> None: + issuer = "" + + def handle(handler): + if handler.path.startswith("/.well-known/oauth-authorization-server"): + _json(handler, 404, {}) + return + if handler.path != "/realms/gopher-mcp/.well-known/openid-configuration": + _json(handler, 404, {}) + return + _json( + handler, + 200, + { + "issuer": issuer, + "authorization_endpoint": f"{issuer}/protocol/openid-connect/auth", + "token_endpoint": f"{issuer}/protocol/openid-connect/token", + "registration_endpoint": f"{issuer}/clients-registrations/openid-connect", + "scopes_supported": ["openid"], + }, + ) + + server = _start_server(handle) + issuer = f"{server.url}/realms/gopher-mcp" + try: + metadata = fetch_oauth_authorization_server_metadata(issuer) + finally: + server.close() + + assert metadata.issuer == issuer + assert metadata.authorization_endpoint == f"{issuer}/protocol/openid-connect/auth" + assert metadata.token_endpoint == f"{issuer}/protocol/openid-connect/token" + + class _Server: def __init__(self, server): self._server = server @@ -145,6 +221,16 @@ def do_POST(self): return _Server(ThreadingHTTPServer(("127.0.0.1", 0), Handler)) +def _raise_http_404(request, timeout): + raise urllib.error.HTTPError( + request.full_url, + 404, + "Not Found", + {}, + None, + ) + + def _json(handler, status, body): data = json.dumps(body).encode("utf-8") handler.send_response(status) diff --git a/tests/test_oauth_resolver.py b/tests/test_oauth_resolver.py index 30407509..209b5716 100644 --- a/tests/test_oauth_resolver.py +++ b/tests/test_oauth_resolver.py @@ -6,6 +6,7 @@ from gopher_mcp_python.oauth_discovery import McpOAuthChallenge from gopher_mcp_python.oauth_resolver import ( + _resolve_resource_metadata_for_challenge, resolve_runtime_options_with_oauth, set_oauth_resolver_hooks_for_test, ) @@ -86,6 +87,34 @@ async def acquire(challenges, oauth): assert result.access_token == "token" +def test_synthetic_gopher_challenge_builds_resource_metadata() -> None: + challenge = McpOAuthChallenge( + url="https://mcp.gopher.security/v1/mcp/servers/example/mcp", + requires_oauth=True, + http_status=404, + authorization_server="https://auth.gopher.security/realms/gopher-mcp", + resource="https://mcp.gopher.security/v1/mcp/servers/example/mcp", + scopes=["openid", "profile", "email"], + ) + + metadata = _resolve_resource_metadata_for_challenge(challenge) + + assert metadata.resource == challenge.resource + assert metadata.authorization_servers == [challenge.authorization_server] + assert metadata.scopes_supported == ["openid", "profile", "email"] + + +def test_missing_resource_metadata_without_authorization_server_fails() -> None: + challenge = McpOAuthChallenge( + url="https://mcp.example.com/mcp", + requires_oauth=True, + http_status=401, + ) + + with pytest.raises(RuntimeError, match="missing resource_metadata"): + _resolve_resource_metadata_for_challenge(challenge) + + def test_incompatible_oauth_servers_fail() -> None: asyncio.run(_test_incompatible_oauth_servers_fail()) From 93210cc4692082ab86a4ecb0e9c1f75647140965 Mon Sep 17 00:00:00 2001 From: RahulHere Date: Fri, 14 Aug 2026 11:56:43 +0800 Subject: [PATCH 09/13] Keep OAuth on existing factories (#15) Summary: Remove the new async factory surface, run SDK OAuth resolution from the existing GopherAgent factory names, keep native-path tests explicit with oauth disabled, and update OAuth factory coverage to match the gopher-mcp-js API shape. --- gopher_mcp_python/agent.py | 326 +++++------------- tests/test_agent_create_by.py | 7 +- ...ync.py => test_agent_create_with_oauth.py} | 119 ++----- tests/test_agent_error_message.py | 10 +- tests/test_agent_runtime_options.py | 4 +- tests/test_ffi.py | 5 +- .../test_oauth_create_with_url_integration.py | 13 +- 7 files changed, 144 insertions(+), 340 deletions(-) rename tests/{test_agent_create_with_oauth_async.py => test_agent_create_with_oauth.py} (72%) diff --git a/gopher_mcp_python/agent.py b/gopher_mcp_python/agent.py index 2a1dcbe6..ec76308d 100644 --- a/gopher_mcp_python/agent.py +++ b/gopher_mcp_python/agent.py @@ -27,6 +27,7 @@ """ import atexit +import asyncio import weakref from typing import Callable, Optional @@ -114,7 +115,7 @@ def is_initialized() -> bool: @staticmethod def create(config: GopherAgentConfig) -> "GopherAgent": """ - Create a new GopherAgent instance. + Create a new GopherAgent instance, resolving SDK OAuth credentials if needed. Args: config: Agent configuration @@ -125,58 +126,14 @@ def create(config: GopherAgentConfig) -> "GopherAgent": Raises: AgentError: if agent creation fails """ - if not _initialized: - GopherAgent.init() - - lib = GopherOrchLibrary.get_instance() - if lib is None: - load_error = GopherOrchLibrary.get_load_error_message() - raise AgentError(f"Native library not available.\n{load_error}") - - handle: Optional[GopherOrchHandle] = None - try: - if config.has_api_key(): - handle = lib.agent_create_by_api_key( - config.provider, - config.model, - config.api_key, - config.runtime_options, - ) - else: - handle = lib.agent_create_by_json( - config.provider, - config.model, - config.server_config, - config.runtime_options, - ) - except AgentError: - raise - except Exception as e: - raise AgentError(f"Failed to create agent: {e}") - - if handle is None: - error = lib.get_last_error_message() - lib.clear_error() - raise AgentError(error or _build_create_error_message()) - - return GopherAgent(handle) - - @staticmethod - async def create_async(config: GopherAgentConfig) -> "GopherAgent": - """ - Create a new GopherAgent, resolving SDK OAuth credentials if needed. - - Sync create() remains non-interactive. This async factory is the - OAuth-aware path for local/desktop clients. - """ if config.has_api_key(): - return await GopherAgent.create_with_api_key_async( + return GopherAgent.create_with_api_key( config.provider, config.model, config.api_key, config.runtime_options, ) - return await GopherAgent.create_with_server_config_async( + return GopherAgent.create_with_server_config( config.provider, config.model, config.server_config, @@ -202,35 +159,17 @@ def create_with_api_key( Returns: GopherAgent instance """ - builder = ( - GopherAgentConfig.builder() - .provider(provider) - .model(model) - .api_key(api_key) - ) - if runtime_options is not None: - builder.runtime_options(runtime_options) - return GopherAgent.create(builder.build()) - - @staticmethod - async def create_with_api_key_async( - provider: str, - model: str, - api_key: str, - options: RuntimeOptionsInput = None, - ) -> "GopherAgent": - """ - Create an agent from a Gopher API key with SDK-side OAuth auto-flow. - """ - create_options = normalize_create_options(options) + create_options = normalize_create_options(runtime_options) runtime_options = normalize_runtime_options(create_options) oauth = create_options.oauth if create_options is not None else None if _should_skip_oauth(runtime_options, oauth): - return GopherAgent.create_with_api_key( - provider, model, api_key, runtime_options + return GopherAgent._create_from_ffi( + lambda lib: lib.agent_create_by_api_key( + provider, model, api_key, runtime_options + ) ) - return await _create_from_api_config_async( + return _create_from_api_config( provider, model, api_key, @@ -258,39 +197,23 @@ def create_with_server_config( Returns: GopherAgent instance """ - builder = ( - GopherAgentConfig.builder() - .provider(provider) - .model(model) - .server_config(server_config) - ) - if runtime_options is not None: - builder.runtime_options(runtime_options) - return GopherAgent.create(builder.build()) - - @staticmethod - async def create_with_server_config_async( - provider: str, - model: str, - server_config: str, - options: RuntimeOptionsInput = None, - ) -> "GopherAgent": - """ - Create an agent from server config with SDK-side OAuth auto-flow. - """ - create_options = normalize_create_options(options) + create_options = normalize_create_options(runtime_options) runtime_options = normalize_runtime_options(create_options) oauth = create_options.oauth if create_options is not None else None if _should_skip_oauth(runtime_options, oauth): - return GopherAgent.create_with_server_config( - provider, model, server_config, runtime_options + return GopherAgent._create_from_ffi( + lambda lib: lib.agent_create_by_json( + provider, model, server_config, runtime_options + ) ) - resolved_runtime_options = await oauth_resolver.resolve_runtime_options_with_oauth( - urls=[], - server_config=server_config, - runtime_options=runtime_options, - oauth=oauth, + resolved_runtime_options = _run_oauth_coroutine( + lambda: oauth_resolver.resolve_runtime_options_with_oauth( + urls=[], + server_config=server_config, + runtime_options=runtime_options, + oauth=oauth, + ) ) return GopherAgent._create_from_ffi( lambda lib: lib.agent_create_by_json( @@ -323,38 +246,21 @@ def create_with_server_id( Returns: GopherAgent instance """ - normalized_runtime_options = normalize_runtime_options(runtime_options) - return GopherAgent._create_from_ffi( - lambda lib: lib.agent_create_by_server_id( - provider, model, api_key, server_id, normalized_runtime_options - ) - ) - - @staticmethod - async def create_with_server_id_async( - provider: str, - model: str, - api_key: str, - server_id: str, - options: RuntimeOptionsInput = None, - ) -> "GopherAgent": - """ - Create an agent scoped by MCP server id with SDK-side OAuth auto-flow. - """ - create_options = normalize_create_options(options) - runtime_options = normalize_runtime_options(create_options) + create_options = normalize_create_options(runtime_options) + normalized_runtime_options = normalize_runtime_options(create_options) oauth = create_options.oauth if create_options is not None else None - if _should_skip_oauth(runtime_options, oauth): - return GopherAgent.create_with_server_id( - provider, model, api_key, server_id, runtime_options + if _should_skip_oauth(normalized_runtime_options, oauth): + return GopherAgent._create_from_ffi( + lambda lib: lib.agent_create_by_server_id( + provider, model, api_key, server_id, normalized_runtime_options + ) ) - - return await _create_from_api_config_async( + return _create_from_api_config( provider, model, api_key, route=ServerConfigRoute("serverId", server_id), - runtime_options=runtime_options, + runtime_options=normalized_runtime_options, oauth=oauth, ) @@ -383,38 +289,21 @@ def create_with_server_name( Returns: GopherAgent instance """ - normalized_runtime_options = normalize_runtime_options(runtime_options) - return GopherAgent._create_from_ffi( - lambda lib: lib.agent_create_by_server_name( - provider, model, api_key, server_name, normalized_runtime_options - ) - ) - - @staticmethod - async def create_with_server_name_async( - provider: str, - model: str, - api_key: str, - server_name: str, - options: RuntimeOptionsInput = None, - ) -> "GopherAgent": - """ - Create an agent scoped by MCP server name with SDK-side OAuth auto-flow. - """ - create_options = normalize_create_options(options) - runtime_options = normalize_runtime_options(create_options) + create_options = normalize_create_options(runtime_options) + normalized_runtime_options = normalize_runtime_options(create_options) oauth = create_options.oauth if create_options is not None else None - if _should_skip_oauth(runtime_options, oauth): - return GopherAgent.create_with_server_name( - provider, model, api_key, server_name, runtime_options + if _should_skip_oauth(normalized_runtime_options, oauth): + return GopherAgent._create_from_ffi( + lambda lib: lib.agent_create_by_server_name( + provider, model, api_key, server_name, normalized_runtime_options + ) ) - - return await _create_from_api_config_async( + return _create_from_api_config( provider, model, api_key, route=ServerConfigRoute("serverName", server_name), - runtime_options=runtime_options, + runtime_options=normalized_runtime_options, oauth=oauth, ) @@ -443,38 +332,21 @@ def create_with_gateway_id( Returns: GopherAgent instance """ - normalized_runtime_options = normalize_runtime_options(runtime_options) - return GopherAgent._create_from_ffi( - lambda lib: lib.agent_create_by_gateway_id( - provider, model, api_key, gateway_id, normalized_runtime_options - ) - ) - - @staticmethod - async def create_with_gateway_id_async( - provider: str, - model: str, - api_key: str, - gateway_id: str, - options: RuntimeOptionsInput = None, - ) -> "GopherAgent": - """ - Create an agent scoped by MCP gateway id with SDK-side OAuth auto-flow. - """ - create_options = normalize_create_options(options) - runtime_options = normalize_runtime_options(create_options) + create_options = normalize_create_options(runtime_options) + normalized_runtime_options = normalize_runtime_options(create_options) oauth = create_options.oauth if create_options is not None else None - if _should_skip_oauth(runtime_options, oauth): - return GopherAgent.create_with_gateway_id( - provider, model, api_key, gateway_id, runtime_options + if _should_skip_oauth(normalized_runtime_options, oauth): + return GopherAgent._create_from_ffi( + lambda lib: lib.agent_create_by_gateway_id( + provider, model, api_key, gateway_id, normalized_runtime_options + ) ) - - return await _create_from_api_config_async( + return _create_from_api_config( provider, model, api_key, route=ServerConfigRoute("gatewayId", gateway_id), - runtime_options=runtime_options, + runtime_options=normalized_runtime_options, oauth=oauth, ) @@ -503,38 +375,21 @@ def create_with_gateway_name( Returns: GopherAgent instance """ - normalized_runtime_options = normalize_runtime_options(runtime_options) - return GopherAgent._create_from_ffi( - lambda lib: lib.agent_create_by_gateway_name( - provider, model, api_key, gateway_name, normalized_runtime_options - ) - ) - - @staticmethod - async def create_with_gateway_name_async( - provider: str, - model: str, - api_key: str, - gateway_name: str, - options: RuntimeOptionsInput = None, - ) -> "GopherAgent": - """ - Create an agent scoped by MCP gateway name with SDK-side OAuth auto-flow. - """ - create_options = normalize_create_options(options) - runtime_options = normalize_runtime_options(create_options) + create_options = normalize_create_options(runtime_options) + normalized_runtime_options = normalize_runtime_options(create_options) oauth = create_options.oauth if create_options is not None else None - if _should_skip_oauth(runtime_options, oauth): - return GopherAgent.create_with_gateway_name( - provider, model, api_key, gateway_name, runtime_options + if _should_skip_oauth(normalized_runtime_options, oauth): + return GopherAgent._create_from_ffi( + lambda lib: lib.agent_create_by_gateway_name( + provider, model, api_key, gateway_name, normalized_runtime_options + ) ) - - return await _create_from_api_config_async( + return _create_from_api_config( provider, model, api_key, route=ServerConfigRoute("gatewayName", gateway_name), - runtime_options=runtime_options, + runtime_options=normalized_runtime_options, oauth=oauth, ) @@ -562,37 +417,20 @@ def create_with_url( Returns: GopherAgent instance """ - normalized_runtime_options = normalize_runtime_options(runtime_options) - return GopherAgent._create_from_ffi( - lambda lib: lib.agent_create_by_url( - provider, model, url, normalized_runtime_options - ) - ) - - @staticmethod - async def create_with_url_async( - provider: str, - model: str, - url: str, - options: RuntimeOptionsInput = None, - ) -> "GopherAgent": - """ - Create an agent for a direct MCP URL with SDK-side OAuth auto-flow. - """ - create_options = normalize_create_options(options) - runtime_options = normalize_runtime_options(create_options) + create_options = normalize_create_options(runtime_options) + normalized_runtime_options = normalize_runtime_options(create_options) oauth = create_options.oauth if create_options is not None else None - if _should_skip_oauth(runtime_options, oauth): - return GopherAgent.create_with_url(provider, model, url, runtime_options) - - resolved_runtime_options = await oauth_resolver.resolve_url_runtime_options_with_oauth( - url, - runtime_options=runtime_options, - oauth=oauth, - ) + if url != "" and not _should_skip_oauth(normalized_runtime_options, oauth): + normalized_runtime_options = _run_oauth_coroutine( + lambda: oauth_resolver.resolve_url_runtime_options_with_oauth( + url, + runtime_options=normalized_runtime_options, + oauth=oauth, + ) + ) return GopherAgent._create_from_ffi( lambda lib: lib.agent_create_by_url( - provider, model, url, resolved_runtime_options + provider, model, url, normalized_runtime_options ) ) @@ -731,7 +569,7 @@ def _release_handle_best_effort(handle: GopherOrchHandle) -> None: return -async def _create_from_api_config_async( +def _create_from_api_config( provider: str, model: str, api_key: str, @@ -740,11 +578,13 @@ async def _create_from_api_config_async( oauth: Optional[GopherAgentOAuthOptions], ) -> GopherAgent: server_config = ServerConfig.fetch(api_key, route=route) - resolved_runtime_options = await oauth_resolver.resolve_runtime_options_with_oauth( - urls=[], - server_config=server_config, - runtime_options=runtime_options, - oauth=oauth, + resolved_runtime_options = _run_oauth_coroutine( + lambda: oauth_resolver.resolve_runtime_options_with_oauth( + urls=[], + server_config=server_config, + runtime_options=runtime_options, + oauth=oauth, + ) ) return GopherAgent._create_from_ffi( lambda lib: lib.agent_create_by_json( @@ -753,6 +593,18 @@ async def _create_from_api_config_async( ) +def _run_oauth_coroutine(create_coroutine): + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(create_coroutine()) + raise AgentError( + "SDK OAuth auto-flow cannot run inside an active asyncio event loop. " + "Provide runtime_options with access_token/Authorization, or set " + 'oauth.mode to "disabled".' + ) + + def _should_skip_oauth( runtime_options: Optional[GopherAgentRuntimeOptions], oauth: Optional[GopherAgentOAuthOptions], diff --git a/tests/test_agent_create_by.py b/tests/test_agent_create_by.py index d1a5aa69..27e71f1c 100644 --- a/tests/test_agent_create_by.py +++ b/tests/test_agent_create_by.py @@ -88,7 +88,12 @@ def test_create_with_url_rejects_empty_url(self) -> None: def test_create_with_url_rejects_unknown_provider(self) -> None: with pytest.raises(AgentError): - GopherAgent.create_with_url(BAD_PROVIDER, MODEL, URL) + GopherAgent.create_with_url( + BAD_PROVIDER, + MODEL, + URL, + {"oauth": {"mode": "disabled"}}, + ) # ---------------------------------------------------------------- # AgentError surfaces a non-empty message so SDK consumers can log diff --git a/tests/test_agent_create_with_oauth_async.py b/tests/test_agent_create_with_oauth.py similarity index 72% rename from tests/test_agent_create_with_oauth_async.py rename to tests/test_agent_create_with_oauth.py index 2715ae58..8377761d 100644 --- a/tests/test_agent_create_with_oauth_async.py +++ b/tests/test_agent_create_with_oauth.py @@ -1,9 +1,9 @@ -"""Tests for OAuth-aware async GopherAgent factories.""" +"""Tests for OAuth-aware GopherAgent factories.""" -import asyncio +import pytest import gopher_mcp_python.agent as agent_module -from gopher_mcp_python import GopherAgent +from gopher_mcp_python import AgentError, GopherAgent from gopher_mcp_python.runtime_options import GopherAgentRuntimeOptions @@ -76,11 +76,7 @@ def _install_fake_library(monkeypatch): return fake -def test_create_with_url_async_resolves_oauth_token(monkeypatch) -> None: - asyncio.run(_test_create_with_url_async_resolves_oauth_token(monkeypatch)) - - -async def _test_create_with_url_async_resolves_oauth_token(monkeypatch) -> None: +def test_create_with_url_resolves_oauth_token(monkeypatch) -> None: fake = _install_fake_library(monkeypatch) resolver_calls = [] @@ -94,7 +90,7 @@ async def resolver(url, runtime_options=None, oauth=None): resolver, ) - agent = await GopherAgent.create_with_url_async( + agent = GopherAgent.create_with_url( "Provider", "model", "https://mcp.example.com/mcp", @@ -107,11 +103,7 @@ async def resolver(url, runtime_options=None, oauth=None): agent.dispose() -def test_create_with_url_async_disabled_oauth_skips_resolver(monkeypatch) -> None: - asyncio.run(_test_create_with_url_async_disabled_oauth_skips_resolver(monkeypatch)) - - -async def _test_create_with_url_async_disabled_oauth_skips_resolver(monkeypatch) -> None: +def test_create_with_url_disabled_oauth_skips_resolver(monkeypatch) -> None: fake = _install_fake_library(monkeypatch) async def resolver(*args, **kwargs): @@ -123,7 +115,7 @@ async def resolver(*args, **kwargs): resolver, ) - agent = await GopherAgent.create_with_url_async( + agent = GopherAgent.create_with_url( "Provider", "model", "https://mcp.example.com/mcp", @@ -140,11 +132,7 @@ async def resolver(*args, **kwargs): agent.dispose() -def test_create_with_url_async_explicit_token_skips_resolver(monkeypatch) -> None: - asyncio.run(_test_create_with_url_async_explicit_token_skips_resolver(monkeypatch)) - - -async def _test_create_with_url_async_explicit_token_skips_resolver(monkeypatch) -> None: +def test_create_with_url_explicit_token_skips_resolver(monkeypatch) -> None: fake = _install_fake_library(monkeypatch) async def resolver(*args, **kwargs): @@ -156,7 +144,7 @@ async def resolver(*args, **kwargs): resolver, ) - agent = await GopherAgent.create_with_url_async( + agent = GopherAgent.create_with_url( "Provider", "model", "https://mcp.example.com/mcp", @@ -169,11 +157,7 @@ async def resolver(*args, **kwargs): agent.dispose() -def test_create_with_server_config_async_uses_resolved_options(monkeypatch) -> None: - asyncio.run(_test_create_with_server_config_async_uses_resolved_options(monkeypatch)) - - -async def _test_create_with_server_config_async_uses_resolved_options(monkeypatch) -> None: +def test_create_with_server_config_uses_resolved_options(monkeypatch) -> None: fake = _install_fake_library(monkeypatch) resolver_calls = [] @@ -187,7 +171,7 @@ async def resolver(urls=None, server_config=None, runtime_options=None, oauth=No resolver, ) - agent = await GopherAgent.create_with_server_config_async( + agent = GopherAgent.create_with_server_config( "Provider", "model", '{"servers":[]}', @@ -200,11 +184,7 @@ async def resolver(urls=None, server_config=None, runtime_options=None, oauth=No agent.dispose() -def test_create_with_api_key_async_fetches_config_before_oauth(monkeypatch) -> None: - asyncio.run(_test_create_with_api_key_async_fetches_config_before_oauth(monkeypatch)) - - -async def _test_create_with_api_key_async_fetches_config_before_oauth(monkeypatch) -> None: +def test_create_with_api_key_fetches_config_before_oauth(monkeypatch) -> None: fake = _install_fake_library(monkeypatch) server_config = '{"servers":[{"config":{"url":"https://mcp.example.com/mcp"}}]}' @@ -223,11 +203,7 @@ async def resolver(urls=None, server_config=None, runtime_options=None, oauth=No resolver, ) - agent = await GopherAgent.create_with_api_key_async( - "Provider", - "model", - "api-key", - ) + agent = GopherAgent.create_with_api_key("Provider", "model", "api-key") call = fake.calls[0] assert call[:4] == ("json", "Provider", "model", server_config) @@ -235,11 +211,7 @@ async def resolver(urls=None, server_config=None, runtime_options=None, oauth=No agent.dispose() -def test_create_with_server_id_async_fetches_routed_config(monkeypatch) -> None: - asyncio.run(_test_create_with_server_id_async_fetches_routed_config(monkeypatch)) - - -async def _test_create_with_server_id_async_fetches_routed_config(monkeypatch) -> None: +def test_create_with_server_id_fetches_routed_config(monkeypatch) -> None: fake = _install_fake_library(monkeypatch) server_config = '{"servers":[{"id":"srv-1"}]}' fetch_calls = [] @@ -259,12 +231,7 @@ async def resolver(urls=None, server_config=None, runtime_options=None, oauth=No resolver, ) - agent = await GopherAgent.create_with_server_id_async( - "Provider", - "model", - "api-key", - "srv-1", - ) + agent = GopherAgent.create_with_server_id("Provider", "model", "api-key", "srv-1") assert fetch_calls[0][0] == "api-key" assert fetch_calls[0][1].key == "serverId" @@ -275,13 +242,7 @@ async def resolver(urls=None, server_config=None, runtime_options=None, oauth=No agent.dispose() -def test_create_with_gateway_name_async_fetches_routed_config(monkeypatch) -> None: - asyncio.run(_test_create_with_gateway_name_async_fetches_routed_config(monkeypatch)) - - -async def _test_create_with_gateway_name_async_fetches_routed_config( - monkeypatch, -) -> None: +def test_create_with_gateway_name_fetches_routed_config(monkeypatch) -> None: fake = _install_fake_library(monkeypatch) server_config = '{"servers":[{"name":"gateway"}]}' fetch_calls = [] @@ -301,7 +262,7 @@ async def resolver(urls=None, server_config=None, runtime_options=None, oauth=No resolver, ) - agent = await GopherAgent.create_with_gateway_name_async( + agent = GopherAgent.create_with_gateway_name( "Provider", "model", "api-key", @@ -317,17 +278,7 @@ async def resolver(urls=None, server_config=None, runtime_options=None, oauth=No agent.dispose() -def test_create_with_server_name_async_explicit_token_uses_sync_selector( - monkeypatch, -) -> None: - asyncio.run( - _test_create_with_server_name_async_explicit_token_uses_sync_selector( - monkeypatch - ) - ) - - -async def _test_create_with_server_name_async_explicit_token_uses_sync_selector( +def test_create_with_server_name_explicit_token_uses_native_selector( monkeypatch, ) -> None: fake = _install_fake_library(monkeypatch) @@ -341,7 +292,7 @@ async def resolver(*args, **kwargs): resolver, ) - agent = await GopherAgent.create_with_server_name_async( + agent = GopherAgent.create_with_server_name( "Provider", "model", "api-key", @@ -355,17 +306,7 @@ async def resolver(*args, **kwargs): agent.dispose() -def test_create_with_gateway_id_async_disabled_oauth_uses_sync_selector( - monkeypatch, -) -> None: - asyncio.run( - _test_create_with_gateway_id_async_disabled_oauth_uses_sync_selector( - monkeypatch - ) - ) - - -async def _test_create_with_gateway_id_async_disabled_oauth_uses_sync_selector( +def test_create_with_gateway_id_disabled_oauth_uses_native_selector( monkeypatch, ) -> None: fake = _install_fake_library(monkeypatch) @@ -379,7 +320,7 @@ async def resolver(*args, **kwargs): resolver, ) - agent = await GopherAgent.create_with_gateway_id_async( + agent = GopherAgent.create_with_gateway_id( "Provider", "model", "api-key", @@ -398,11 +339,7 @@ async def resolver(*args, **kwargs): agent.dispose() -def test_create_async_uses_server_config_async_path(monkeypatch) -> None: - asyncio.run(_test_create_async_uses_server_config_async_path(monkeypatch)) - - -async def _test_create_async_uses_server_config_async_path(monkeypatch) -> None: +def test_create_uses_server_config_oauth_path(monkeypatch) -> None: fake = _install_fake_library(monkeypatch) async def resolver(urls=None, server_config=None, runtime_options=None, oauth=None): @@ -422,8 +359,18 @@ async def resolver(urls=None, server_config=None, runtime_options=None, oauth=No .build() ) - agent = await GopherAgent.create_async(config) + agent = GopherAgent.create(config) assert fake.calls[0][0] == "json" assert fake.calls[0][4].access_token == "oauth-token" agent.dispose() + + +def test_oauth_inside_running_event_loop_fails_clearly(monkeypatch) -> None: + _install_fake_library(monkeypatch) + + async def create_inside_loop(): + with pytest.raises(AgentError, match="active asyncio event loop"): + GopherAgent.create_with_url("Provider", "model", "https://mcp.example.com/mcp") + + agent_module.asyncio.run(create_inside_loop()) diff --git a/tests/test_agent_error_message.py b/tests/test_agent_error_message.py index 45aa5698..f3a226f3 100644 --- a/tests/test_agent_error_message.py +++ b/tests/test_agent_error_message.py @@ -50,7 +50,10 @@ def test_create_failure_uses_actionable_fallback(monkeypatch) -> None: with pytest.raises(AgentError) as exc_info: GopherAgent.create_with_url( - "Provider", "model", "http://127.0.0.1:5001/mcp" + "Provider", + "model", + "http://127.0.0.1:5001/mcp", + {"oauth": {"mode": "disabled"}}, ) assert "native library returned null without a specific error" in str( @@ -71,7 +74,10 @@ def test_create_failure_keeps_native_error_message(monkeypatch) -> None: with pytest.raises(AgentError) as exc_info: GopherAgent.create_with_url( - "Provider", "model", "http://127.0.0.1:5001/mcp" + "Provider", + "model", + "http://127.0.0.1:5001/mcp", + {"oauth": {"mode": "disabled"}}, ) assert str(exc_info.value) == "Failed to create agent from MCP server URL: Timeout" diff --git a/tests/test_agent_runtime_options.py b/tests/test_agent_runtime_options.py index e525ba70..77b699d2 100644 --- a/tests/test_agent_runtime_options.py +++ b/tests/test_agent_runtime_options.py @@ -130,7 +130,7 @@ def test_create_with_api_key_accepts_runtime_options(fake_library) -> None: "Provider", "model", "api-key", - {"headers": {"X-Trace": "trace-1"}}, + {"headers": {"X-Trace": "trace-1"}, "oauth": {"mode": "disabled"}}, ) call = fake_library.calls[0] @@ -190,7 +190,7 @@ def test_direct_factory_normalizes_empty_runtime_options(fake_library) -> None: "Provider", "model", "http://127.0.0.1:5001/mcp", - {"access_token": ""}, + {"access_token": "", "oauth": {"mode": "disabled"}}, ) call = fake_library.calls[0] diff --git a/tests/test_ffi.py b/tests/test_ffi.py index e85a9c1c..b7266bb5 100644 --- a/tests/test_ffi.py +++ b/tests/test_ffi.py @@ -185,7 +185,10 @@ def agent_create_by_url(self, provider, model, url, runtime_options=None): with pytest.raises(AgentError) as exc_info: GopherAgent.create_with_url( - "AnthropicProvider", "claude-3-haiku-20240307", "http://x/mcp" + "AnthropicProvider", + "claude-3-haiku-20240307", + "http://x/mcp", + {"oauth": {"mode": "disabled"}}, ) assert "predates the routing factories" in str(exc_info.value) diff --git a/tests/test_oauth_create_with_url_integration.py b/tests/test_oauth_create_with_url_integration.py index b828d4a3..f05a462d 100644 --- a/tests/test_oauth_create_with_url_integration.py +++ b/tests/test_oauth_create_with_url_integration.py @@ -1,6 +1,5 @@ -"""Local integration test for OAuth-aware create_with_url_async.""" +"""Local integration test for OAuth-aware create_with_url.""" -import asyncio import json import threading import urllib.parse @@ -35,14 +34,6 @@ def clear_error(self): def test_local_oauth_flow_obtains_token_before_url_agent_creation(monkeypatch) -> None: - asyncio.run( - _test_local_oauth_flow_obtains_token_before_url_agent_creation(monkeypatch) - ) - - -async def _test_local_oauth_flow_obtains_token_before_url_agent_creation( - monkeypatch, -) -> None: server = _FakeOAuthServer.start() fake = FakeLibrary() opened_authorization_urls = [] @@ -61,7 +52,7 @@ def open_authorization_url(url, open_browser=None): monkeypatch.setattr(oauth_resolver, "open_authorization_url", open_authorization_url) try: - agent = await GopherAgent.create_with_url_async(PROVIDER, MODEL, server.mcp_url) + agent = GopherAgent.create_with_url(PROVIDER, MODEL, server.mcp_url) finally: oauth_resolver.set_oauth_resolver_hooks_for_test() oauth_resolver.set_oauth_url_runtime_options_resolver_for_test() From dd1b0f44aabcc98c617ae1b4a8e593ab4c95f48e Mon Sep 17 00:00:00 2001 From: RahulHere Date: Sun, 16 Aug 2026 17:38:02 +0800 Subject: [PATCH 10/13] Use PyPI libs for create by URL example Summary: Install gopher-mcp-python and the matching native package through the shared PyPI example runner. Keep create_with_url using the synchronous API while passing optional access token or OAuth runtime options. Warn when OAuth is disabled without a bearer token for protected MCP URLs. --- examples/api/create_by_url.py | 38 +++++++++++++++++++++++++++++-- examples/api/create_by_url_run.sh | 11 +++++---- 2 files changed, 43 insertions(+), 6 deletions(-) diff --git a/examples/api/create_by_url.py b/examples/api/create_by_url.py index b8b19530..1ec54027 100644 --- a/examples/api/create_by_url.py +++ b/examples/api/create_by_url.py @@ -19,6 +19,9 @@ Configuration (env vars): GOPHER_MCP_URL Full URL of the MCP server (e.g. http://127.0.0.1:8080/mcp) + GOPHER_ACCESS_TOKEN Optional. Bearer token for protected MCP runtime traffic. + GOPHER_MCP_OAUTH Optional. Set to "disabled" to skip SDK OAuth discovery. + GOPHER_MCP_OAUTH_SCOPES Optional. Space/comma separated OAuth scopes. LLM_PROVIDER Optional. Defaults to "AnthropicProvider". LLM_MODEL Required. Model identifier the provider accepts. DEBUG When set, ctypes prints library-resolution diagnostics. @@ -48,7 +51,10 @@ def env_or(name: str, fallback: str) -> str: def main() -> None: print("=== GopherAgent.create_with_url example ===") print(f"Usage: python3 {sys.argv[0]} [query1] [query2] ...") - print("Env: GOPHER_MCP_URL LLM_PROVIDER LLM_MODEL DEBUG") + print( + "Env: GOPHER_MCP_URL GOPHER_ACCESS_TOKEN GOPHER_MCP_OAUTH " + "GOPHER_MCP_OAUTH_SCOPES LLM_PROVIDER LLM_MODEL DEBUG" + ) print("") queries = sys.argv[1:] if len(sys.argv) > 1 else ["What time is it in Tokyo?"] @@ -56,12 +62,25 @@ def main() -> None: provider = env_or("LLM_PROVIDER", "AnthropicProvider") model = env_or("LLM_MODEL", MODEL_PLACEHOLDER) url = env_or("GOPHER_MCP_URL", URL_PLACEHOLDER) + access_token = env_or("GOPHER_ACCESS_TOKEN", "") + oauth_mode = env_or("GOPHER_MCP_OAUTH", "auto") + oauth_scopes = parse_oauth_scopes(env_or("GOPHER_MCP_OAUTH_SCOPES", "")) print(f"Provider: {provider}") model_label = f"{model} (set LLM_MODEL)" if model == MODEL_PLACEHOLDER else model print(f"Model: {model_label}") url_label = f"{url} (set GOPHER_MCP_URL)" if url == URL_PLACEHOLDER else url print(f"MCP URL: {url_label}") + print( + "Access: " + + ( + "" + if access_token == "" + else "" + ) + ) + print(f"OAuth: {oauth_mode}") + print("Scopes: " + (" ".join(oauth_scopes) if oauth_scopes else "")) print(f"Queries: {len(queries)}") if model == MODEL_PLACEHOLDER or url == URL_PLACEHOLDER: @@ -73,7 +92,8 @@ def main() -> None: sys.exit(1) print("\nCreating agent via GopherAgent.create_with_url...") - agent = GopherAgent.create_with_url(provider, model, url) + runtime_options = create_runtime_options(access_token, oauth_mode, oauth_scopes) + agent = GopherAgent.create_with_url(provider, model, url, runtime_options) print("Agent created successfully!") try: @@ -88,6 +108,20 @@ def main() -> None: agent.dispose() +def parse_oauth_scopes(value: str): + return [scope for scope in value.replace(",", " ").split() if scope] + + +def create_runtime_options(access_token: str, oauth_mode: str, oauth_scopes): + if access_token != "": + return {"access_token": access_token} + if oauth_mode == "disabled": + return {"oauth": {"mode": "disabled"}} + if oauth_scopes: + return {"oauth": {"scopes": oauth_scopes}} + return None + + if __name__ == "__main__": try: main() diff --git a/examples/api/create_by_url_run.sh b/examples/api/create_by_url_run.sh index 0aae9406..7308c853 100755 --- a/examples/api/create_by_url_run.sh +++ b/examples/api/create_by_url_run.sh @@ -1,11 +1,10 @@ #!/bin/bash -# Run the Python SDK example for GopherAgent.create_with_url against the -# PyPI-published gopher-mcp-python package. +# Run the Python SDK example for GopherAgent.create_with_url +# against the PyPI-published gopher-mcp-python package. # # Set SDK_VERSION to pin to a specific release; otherwise the latest -# published version is installed. create_with_url requires a release -# that includes the native routing factory symbols. +# published version is installed. set -e @@ -20,4 +19,8 @@ warn_if_empty "GOPHER_MCP_URL" "Set it with: export GOPHER_MCP_URL=http://127.0. warn_if_empty "LLM_MODEL" "Set it with: export LLM_MODEL=" warn_if_empty "ANTHROPIC_API_KEY" "(Required for the default AnthropicProvider.)" +if [ -z "${GOPHER_ACCESS_TOKEN:-}" ] && [ "${GOPHER_MCP_OAUTH:-auto}" = "disabled" ]; then + echo -e "${YELLOW}Warning: GOPHER_ACCESS_TOKEN is empty and GOPHER_MCP_OAUTH=disabled; protected MCP URLs may fail.${NC}" +fi + run_api_example "test-project-create-by-url" "create_by_url.py" "$@" From c71cddec8dee803d1111efc2c3e6ee7c15b5d19f Mon Sep 17 00:00:00 2001 From: RahulHere Date: Sun, 16 Aug 2026 19:59:00 +0800 Subject: [PATCH 11/13] Release version 0.1.34 Prepare release v0.1.34: - Update pyproject.toml to version 0.1.34 - Update platform packages to version 0.1.34 - Update CHANGELOG.md: [Unreleased] -> [0.1.34] - 2026-08-16 gopher-orch version: 0.1.34 Changes in this release: ### Changed - Pin `gopher-orch` native library to [v0.1.34](https://github.com/GopherSecurity/gopher-orch/releases/tag/v0.1.34). #### SDK changes since v0.1.30 - Use PyPI libs for create by URL example - Keep OAuth on existing factories (#15) - Fix hosted MCP OAuth fallback (#15) - Fix agent finalizer fallback (#15) - Fix OAuth parity coverage (#15) - Fix OAuth async agent factories (#15) - Fix OAuth helper modules (#15) --- .github/workflows/publish-packages.yml | 9 +- .github/workflows/verify-examples.yml | 10 +- CHANGELOG.md | 233 +++++++++++++++++- gopher_mcp_python/__init__.py | 2 +- .../__init__.py | 2 +- packages/darwin-arm64/pyproject.toml | 2 +- .../__init__.py | 2 +- packages/darwin-x64/pyproject.toml | 2 +- .../__init__.py | 2 +- packages/linux-arm64/pyproject.toml | 2 +- .../__init__.py | 2 +- packages/linux-x64/pyproject.toml | 2 +- .../__init__.py | 2 +- packages/win32-arm64/pyproject.toml | 2 +- .../__init__.py | 2 +- packages/win32-x64/pyproject.toml | 2 +- pyproject.toml | 2 +- tests/test_linux_native_packaging.py | 15 +- 18 files changed, 271 insertions(+), 24 deletions(-) diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index 3a13867d..d46b9f47 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -11,7 +11,7 @@ env: # Version is read from pyproject.toml - update with: python scripts/update_version.py DRY_RUN: 'false' # gopher-orch version to download native binaries from (may differ from SDK version) - GOPHER_ORCH_VERSION: 'v0.1.14' + GOPHER_ORCH_VERSION: 'v0.1.34' jobs: download-binaries: @@ -150,6 +150,13 @@ jobs: # Check flat structure cp artifacts/${{ matrix.platform }}/${{ matrix.lib_pattern }} "$PKG_DIR/" 2>/dev/null || true + # Linux wheels use distro OpenSSL so system security updates apply. + # Some gopher-orch release archives may include these transitive libs; + # keep them out of the Python native packages. + if [[ "${{ matrix.platform }}" == linux-* ]]; then + find "$PKG_DIR" -maxdepth 1 -type f \( -name 'libssl.so*' -o -name 'libcrypto.so*' \) -delete + fi + # List what we copied echo "=== Package contents for ${{ matrix.platform }} ===" ls -la "$PKG_DIR/" diff --git a/.github/workflows/verify-examples.yml b/.github/workflows/verify-examples.yml index ec40ab4e..5425c059 100644 --- a/.github/workflows/verify-examples.yml +++ b/.github/workflows/verify-examples.yml @@ -185,12 +185,12 @@ jobs: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} GOPHER_API_KEY: ${{ secrets.GOPHER_API_KEY }} GOPHER_MCP_URL: ${{ secrets.GOPHER_MCP_URL }} - GOPHER_SDK_TEST: true SDK_INSTALL_SPEC: ${{ github.event_name == 'pull_request' && github.workspace || '' }} run: | - VERIFY_LIVE_PROMPT="list my draft mails" \ - VERIFY_EXPECTED_ANSWER="r-2553040815323886578" \ + unset GOPHER_SDK_TEST + VERIFY_LIVE_PROMPT="Get my mail profile" \ + VERIFY_EXPECTED_ANSWER="james.lu@gopher.security" \ scripts/verify-examples.sh --mode "$VERIFY_EXAMPLES_MODE" --only create_by_api_key - VERIFY_LIVE_PROMPT="list my draft mails" \ - VERIFY_EXPECTED_ANSWER="r-2553040815323886578" \ + VERIFY_LIVE_PROMPT="Get my mail profile" \ + VERIFY_EXPECTED_ANSWER="james.lu@gopher.security" \ scripts/verify-examples.sh --mode "$VERIFY_EXAMPLES_MODE" --only create_by_url diff --git a/CHANGELOG.md b/CHANGELOG.md index 37f07dfb..42e7d26a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,239 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] + +## [0.1.34] - 2026-08-16 + +### Changed + +- Pin `gopher-orch` native library to [v0.1.34](https://github.com/GopherSecurity/gopher-orch/releases/tag/v0.1.34). + +#### SDK changes since v0.1.30 + +- Use PyPI libs for create by URL example +- Keep OAuth on existing factories (#15) +- Fix hosted MCP OAuth fallback (#15) +- Fix agent finalizer fallback (#15) +- Fix OAuth parity coverage (#15) +- Fix OAuth async agent factories (#15) +- Fix OAuth helper modules (#15) +- Fix OAuth create option types (#15) +- Fix agent options ABI (#15) +- Fix native owned string cleanup (#15) +- Fail PR example verification on bundled OpenSSL +- Relax Linux example native rpath checks +- Use stable draft id for Python live verification +- Stabilize Python example missing-env checks +- Clean up failed Python example verification projects +- Redact live example verifier output +- Verify Linux native dependencies in examples +- Strengthen Python example live verification +- Relax Python example verification on PRs +- Verify Python examples against PR checkout +- Clean up Python native verifier probe +- Fix Python example verifier workflow syntax +- Add Python SDK example verification +- Add Python native platform search paths +- Pin Linux builder base image +- Clarify auth error exports +- Fix versioned native library resolution +- Verify Linux native package dependencies +- Avoid bundling OpenSSL in Linux wheels +- Document run null-response behavior +- Fix Python debug hint in agent errors +- Clarify Python packaging requirements +- Support Linux native builds for Python SDK +- Improve Python build script parity +- Expand Python auth public exports +- Add Python native platform search paths +- Improve Python native loader diagnostics +- Raise Python agent errors for null runs +- Improve Python agent create errors +- Remove checked-in header example binaries +- Cover header example empty token behavior +- Harden header example verification script +- Keep build submodules pinned by default +- Fix runtime options layering + +#### gopher-orch v0.1.34 highlights + + +### Added +- Add structured OAuth discovery errors (https://github.com/GopherSecurity/gopher-orch/pull/159) +- Add per-server runtime credentials (https://github.com/GopherSecurity/gopher-orch/pull/159) +- Add MCP OAuth challenge probe (https://github.com/GopherSecurity/gopher-orch/pull/159) +### Changed +- make format +- Document SDK OAuth native usage (https://github.com/GopherSecurity/gopher-orch/pull/159) +- Extend FFI agent runtime options (https://github.com/GopherSecurity/gopher-orch/pull/159) +- Preserve per-server credentials for tool calls (https://github.com/GopherSecurity/gopher-orch/pull/159) +- Apply per-server credentials during discovery (https://github.com/GopherSecurity/gopher-orch/pull/159) +- Complete OAuth client SDK accessors (https://github.com/GopherSecurity/gopher-orch/pull/159) +- Expose MCP OAuth discovery over C API (https://github.com/GopherSecurity/gopher-orch/pull/159) +- Fetch OAuth authorization metadata (https://github.com/GopherSecurity/gopher-orch/pull/159) +- Fetch OAuth protected resource metadata (https://github.com/GopherSecurity/gopher-orch/pull/159) +- Lock down SDK runtime auth headers (https://github.com/GopherSecurity/gopher-orch/pull/159) +- make format +- Cover gateway OAuth token proxy workaround +- Guard gateway auto OAuth metadata adoption +- Isolate gateway passthrough backend routes +- Clarify gateway backend auth failures +- Fail fast on unsupported gateway backend auth (https://github.com/GopherSecurity/gopher-orch/pull/147) +- Pin Presidio to GHCR 2.2.362 instead of tracking mcr :latest +- Keep the backend manifest and audit token out of the pod spec +### Fixed +- Fix Windows ARM64 OAuth discovery build +- Fix gateway OAuth token exchange for Postman +- Fix gateway OAuth passthrough discovery +- Fix gateway streamable HTTP curl stop handling + +## [0.1.30] - 2026-07-05 + +### Changed + +- Pin `gopher-orch` native library to [v0.1.30](https://github.com/GopherSecurity/gopher-orch/releases/tag/v0.1.30). + +#### SDK changes since v0.1.23 + +- Improve release notes validation and CI extraction +- Clarify Python packaging requirements +- Support Linux native builds for Python SDK +- Improve Python build script parity +- Expand Python auth public exports +- Add Python native platform search paths +- Improve Python native loader diagnostics +- Raise Python agent errors for null runs +- Improve Python agent create errors +- Add dynamic header verification runner (#11) +- Add dynamic header create_by_url example (#11) +- Pass runtime options through agents (#11) +- Bind agent runtime options FFI (#11) +- Add agent runtime options API (#11) +- Build latest local native libs (#11) +- Update gopher-orch submodule (#11) +- Format code +- Switch examples/api/ to resolve against PyPI fix (#9) +- Auto-populate CHANGELOG.md in dump-version.sh (#9) +- Add examples/api/README.md fix (#9) +- Add seven *_run.sh wrappers for the examples/api/ create_by_* set fix (#9) +- Add Python example for create_with_url fix (#9) +- Add Python example for create_with_gateway_name fix (#9) +- Add Python example for create_with_gateway_id fix (#9) +- Add Python example for create_with_server_name fix (#9) +- Add Python example for create_with_server_id fix (#9) +- Add Python example for create_with_server_config fix (#9) +- Add Python example for create_with_api_key fix (#9) +- Add contract tests for the five new routing factories fix (#9) +- Surface the FFI handle type at the package root fix (#9) +- Document the builder gap for the new routing factories fix (#9) +- Expose five new ReActAgent factories on GopherAgent fix (#9) +- Bind five new ReActAgent factories in ctypes FFI layer fix (#9) +- Track gopher-orch br_release branch and bump submodule to 0.1.23 (#9) + +#### gopher-orch v0.1.30 highlights + + +### Added +- Add MCP tools discovery fallback +- Add access token gateway verification example ### Changed +- Prefer direct discovery for API HTTP gateways +- Prefer Streamable HTTP MCP transport +- Improve agent creation FFI errors + +## [0.1.23] - 2026-06-18 + +### Changed + +- Pin `gopher-orch` native library to [v0.1.23](https://github.com/GopherSecurity/gopher-orch/releases/tag/v0.1.23). + +#### SDK changes since v0.1.21 + +- Improve release notes validation and CI extraction +- Auto-populate CHANGELOG.md in dump-version.sh +- Add examples/api/README.md fix (#9) +- Add seven *_run.sh wrappers for the examples/api/ create_by_* set fix (#9) +- Add Python example for create_with_url fix (#9) +- Add Python example for create_with_gateway_name fix (#9) +- Add Python example for create_with_gateway_id fix (#9) +- Add Python example for create_with_server_name fix (#9) +- Add Python example for create_with_server_id fix (#9) +- Add Python example for create_with_server_config fix (#9) +- Add Python example for create_with_api_key fix (#9) +- Add contract tests for the five new routing factories fix (#9) +- Surface the FFI handle type at the package root fix (#9) +- Document the builder gap for the new routing factories fix (#9) +- Expose five new ReActAgent factories on GopherAgent fix (#9) +- Bind five new ReActAgent factories in ctypes FFI layer fix (#9) +- Track gopher-orch br_release branch and bump submodule to 0.1.23 (#9) + +#### gopher-orch v0.1.23 highlights + -- `GopherAgent.run()` now raises `AgentError` when the native library returns a null response instead of returning a `"No response for query"` string. +### Added +- Add SDK examples for the six remaining create_by_* factories (#116) +- Add SDK example for ReActAgent::createByApiKey (#116) +- Add FFI unit tests for the five new agent create_by_* entry points (#116) +- Add CHANGELOG entry for the five new ReActAgent factories (#116) +- Add unit tests for the five new ReActAgent factories (#116) +- Implement createByUrl: synthesize http_sse config and delegate (#116) +- Implement createByServerId / createByServerName / createByGatewayId / createByGatewayName (#116) +- Add query-string overload to ApiEngine::fetchMcpServers (#116) +### Changed +- Hardcode provider and model in createByApiKey example (#116) +- Expose five new agent factories through the C FFI (#116) +- Document the five new ReActAgent simple-creation factories (#116) +- Declare five new ReActAgent factory methods (#116) +- Unstaged changes: CMakeLists.txt,third_party/gopher-mcp + +## [0.1.21] - 2026-04-29 + +### Added + +- Add bundling libs for macOS and Linux + +## [0.1.16] - 2026-04-24 + +### Changed + +- Keep the same version as native library + +## [0.1.15] - 2026-04-22 + +### Added + +- Add PyPI package URL and install command to GitHub release notes +- Auto-update GOPHER_ORCH_VERSION in CI via dump-version.sh + +### Changed + +- Switch auth example to always use PyPI packages instead of local build +- Update GOPHER_ORCH_VERSION from v0.1.2 to v0.1.14 so native package includes auth config C API + +### Fixed + +- Fix auth example not working with PyPI package due to missing native auth config symbols (was using gopher-orch v0.1.2 binaries) + +## [0.1.14] - 2026-04-21 + +### Added + +- Add `/oauth/token` proxy route to forward token exchange to IdP, injecting client_id/client_secret — required for MCP clients like claude.ai (#6) +- Add token validation in McpAuthMiddleware (was only checking Bearer presence, not validating JWT) (#6) +- Add RequestLoggingMiddleware for debugging MCP and OAuth flows (#6) +- Add empty release notes validation in dump-version.sh — errors out if all Added/Changed/Fixed sections are empty + +### Changed + +- Improve CI release notes extraction: try versioned section `[X.Y.Z]` first, then `[Unreleased]`, with proper fallbacks +- Update gopher-orch submodule to `main` branch +- Fix example pyproject.toml dependencies: replace Flask with Starlette/uvicorn, add httpx + +### Fixed +- Fix OAuth flow for claude.ai: MCP clients need `/oauth/token` proxy to exchange auth codes for tokens (#6) +- Fix CI release notes showing empty What's Changed section +- Fix release notes fallback regex for extracting versioned sections from CHANGELOG.md ## [0.1.2] - 2026-03-12 @@ -72,5 +301,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- [Unreleased]: https://github.com/GopherSecurity/gopher-mcp-python/compare/v0.1.0-20260227-124047...HEAD -[0.1.2]: https://github.com/GopherSecurity/gopher-mcp-python/compare/v0.1.0-20260227-124047...v0.1.2[0.1.1]: https://github.com/GopherSecurity/gopher-mcp-python/compare/v0.1.0-20260227-124047...v0.1.1[0.1.0-20260227-124047]: https://github.com/GopherSecurity/gopher-mcp-python/compare/v0.1.0...v0.1.0-20260227-124047 +[0.1.34]: https://github.com/GopherSecurity/gopher-mcp-python/compare/v0.1.0-20260227-124047...v0.1.34[0.1.30]: https://github.com/GopherSecurity/gopher-mcp-python/compare/v0.1.0-20260227-124047...v0.1.30[0.1.23]: https://github.com/GopherSecurity/gopher-mcp-python/compare/v0.1.0-20260227-124047...v0.1.23[0.1.21]: https://github.com/GopherSecurity/gopher-mcp-python/compare/v0.1.0-20260227-124047...v0.1.21[0.1.16]: https://github.com/GopherSecurity/gopher-mcp-python/compare/v0.1.0-20260227-124047...v0.1.16[0.1.15]: https://github.com/GopherSecurity/gopher-mcp-python/compare/v0.1.0-20260227-124047...v0.1.15[0.1.14]: https://github.com/GopherSecurity/gopher-mcp-python/compare/v0.1.0-20260227-124047...v0.1.14[0.1.2]: https://github.com/GopherSecurity/gopher-mcp-python/compare/v0.1.0-20260227-124047...v0.1.2[0.1.1]: https://github.com/GopherSecurity/gopher-mcp-python/compare/v0.1.0-20260227-124047...v0.1.1[0.1.0-20260227-124047]: https://github.com/GopherSecurity/gopher-mcp-python/compare/v0.1.0...v0.1.0-20260227-124047 [0.1.0]: https://github.com/GopherSecurity/gopher-mcp-python/releases/tag/v0.1.0 diff --git a/gopher_mcp_python/__init__.py b/gopher_mcp_python/__init__.py index 2bef1bcd..ddc58cac 100644 --- a/gopher_mcp_python/__init__.py +++ b/gopher_mcp_python/__init__.py @@ -46,7 +46,7 @@ from gopher_mcp_python.server_config import ServerConfig, ServerConfigRoute from gopher_mcp_python.ffi import GopherOrchLibrary, GopherOrchHandle -__version__ = "0.1.2" +__version__ = "0.1.34" _AUTH_EXPORTS = { "GopherAuth", diff --git a/packages/darwin-arm64/gopher_mcp_python_native_darwin_arm64/__init__.py b/packages/darwin-arm64/gopher_mcp_python_native_darwin_arm64/__init__.py index 2e9569c1..03a96db1 100644 --- a/packages/darwin-arm64/gopher_mcp_python_native_darwin_arm64/__init__.py +++ b/packages/darwin-arm64/gopher_mcp_python_native_darwin_arm64/__init__.py @@ -7,7 +7,7 @@ import os from pathlib import Path -__version__ = "0.1.2" +__version__ = "0.1.34" # Platform identifier PLATFORM = "darwin" diff --git a/packages/darwin-arm64/pyproject.toml b/packages/darwin-arm64/pyproject.toml index 9415f5af..74697b66 100644 --- a/packages/darwin-arm64/pyproject.toml +++ b/packages/darwin-arm64/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "gopher-mcp-python-native-darwin-arm64" -version = "0.1.2" +version = "0.1.34" description = "Native library for gopher-mcp-python (macOS ARM64)" readme = "README.md" license = {text = "Apache-2.0"} diff --git a/packages/darwin-x64/gopher_mcp_python_native_darwin_x64/__init__.py b/packages/darwin-x64/gopher_mcp_python_native_darwin_x64/__init__.py index 8ecb7914..73f359ba 100644 --- a/packages/darwin-x64/gopher_mcp_python_native_darwin_x64/__init__.py +++ b/packages/darwin-x64/gopher_mcp_python_native_darwin_x64/__init__.py @@ -7,7 +7,7 @@ import os from pathlib import Path -__version__ = "0.1.2" +__version__ = "0.1.34" # Platform identifier PLATFORM = "darwin" diff --git a/packages/darwin-x64/pyproject.toml b/packages/darwin-x64/pyproject.toml index d7ba46f1..c51cf22a 100644 --- a/packages/darwin-x64/pyproject.toml +++ b/packages/darwin-x64/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "gopher-mcp-python-native-darwin-x64" -version = "0.1.2" +version = "0.1.34" description = "Native library for gopher-mcp-python (macOS Intel)" readme = "README.md" license = {text = "Apache-2.0"} diff --git a/packages/linux-arm64/gopher_mcp_python_native_linux_arm64/__init__.py b/packages/linux-arm64/gopher_mcp_python_native_linux_arm64/__init__.py index f03a7715..ab2556de 100644 --- a/packages/linux-arm64/gopher_mcp_python_native_linux_arm64/__init__.py +++ b/packages/linux-arm64/gopher_mcp_python_native_linux_arm64/__init__.py @@ -7,7 +7,7 @@ import os from pathlib import Path -__version__ = "0.1.2" +__version__ = "0.1.34" # Platform identifier PLATFORM = "linux" diff --git a/packages/linux-arm64/pyproject.toml b/packages/linux-arm64/pyproject.toml index 6926c3af..025a364e 100644 --- a/packages/linux-arm64/pyproject.toml +++ b/packages/linux-arm64/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "gopher-mcp-python-native-linux-arm64" -version = "0.1.2" +version = "0.1.34" description = "Native library for gopher-mcp-python (Linux ARM64)" readme = "README.md" license = {text = "Apache-2.0"} diff --git a/packages/linux-x64/gopher_mcp_python_native_linux_x64/__init__.py b/packages/linux-x64/gopher_mcp_python_native_linux_x64/__init__.py index 679cd027..6cd8e1e5 100644 --- a/packages/linux-x64/gopher_mcp_python_native_linux_x64/__init__.py +++ b/packages/linux-x64/gopher_mcp_python_native_linux_x64/__init__.py @@ -7,7 +7,7 @@ import os from pathlib import Path -__version__ = "0.1.2" +__version__ = "0.1.34" # Platform identifier PLATFORM = "linux" diff --git a/packages/linux-x64/pyproject.toml b/packages/linux-x64/pyproject.toml index db60a7e9..3870133a 100644 --- a/packages/linux-x64/pyproject.toml +++ b/packages/linux-x64/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "gopher-mcp-python-native-linux-x64" -version = "0.1.2" +version = "0.1.34" description = "Native library for gopher-mcp-python (Linux x64)" readme = "README.md" license = {text = "Apache-2.0"} diff --git a/packages/win32-arm64/gopher_mcp_python_native_win32_arm64/__init__.py b/packages/win32-arm64/gopher_mcp_python_native_win32_arm64/__init__.py index 7aa87233..2d479377 100644 --- a/packages/win32-arm64/gopher_mcp_python_native_win32_arm64/__init__.py +++ b/packages/win32-arm64/gopher_mcp_python_native_win32_arm64/__init__.py @@ -7,7 +7,7 @@ import os from pathlib import Path -__version__ = "0.1.2" +__version__ = "0.1.34" # Platform identifier PLATFORM = "win32" diff --git a/packages/win32-arm64/pyproject.toml b/packages/win32-arm64/pyproject.toml index d078ba08..b218b460 100644 --- a/packages/win32-arm64/pyproject.toml +++ b/packages/win32-arm64/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "gopher-mcp-python-native-win32-arm64" -version = "0.1.2" +version = "0.1.34" description = "Native library for gopher-mcp-python (Windows ARM64)" readme = "README.md" license = {text = "Apache-2.0"} diff --git a/packages/win32-x64/gopher_mcp_python_native_win32_x64/__init__.py b/packages/win32-x64/gopher_mcp_python_native_win32_x64/__init__.py index bf9cb6dc..e91b5118 100644 --- a/packages/win32-x64/gopher_mcp_python_native_win32_x64/__init__.py +++ b/packages/win32-x64/gopher_mcp_python_native_win32_x64/__init__.py @@ -7,7 +7,7 @@ import os from pathlib import Path -__version__ = "0.1.2" +__version__ = "0.1.34" # Platform identifier PLATFORM = "win32" diff --git a/packages/win32-x64/pyproject.toml b/packages/win32-x64/pyproject.toml index 944c75bd..ab17520c 100644 --- a/packages/win32-x64/pyproject.toml +++ b/packages/win32-x64/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "gopher-mcp-python-native-win32-x64" -version = "0.1.2" +version = "0.1.34" description = "Native library for gopher-mcp-python (Windows x64)" readme = "README.md" license = {text = "Apache-2.0"} diff --git a/pyproject.toml b/pyproject.toml index fb33bf68..4eaf1454 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "gopher-mcp-python" -version = "0.1.2.1" +version = "0.1.34" description = "Python SDK for Gopher MCP - AI Agent orchestration framework with native performance" readme = "README.md" license = {text = "Apache-2.0"} diff --git a/tests/test_linux_native_packaging.py b/tests/test_linux_native_packaging.py index e40848b0..018e273d 100644 --- a/tests/test_linux_native_packaging.py +++ b/tests/test_linux_native_packaging.py @@ -100,8 +100,9 @@ def test_verify_examples_live_checks_only_agent_response_body() -> None: assert "VERIFY_EXPECTED_ANSWER_TERMS" in script assert 'validate_expected_answer_terms "$answer_body"' in script assert "agent response contains an error" in script - assert 'VERIFY_LIVE_PROMPT="list my draft mails"' in workflow - assert 'VERIFY_EXPECTED_ANSWER="r-2553040815323886578"' in workflow + assert "unset GOPHER_SDK_TEST" in workflow + assert 'VERIFY_LIVE_PROMPT="Get my mail profile"' in workflow + assert 'VERIFY_EXPECTED_ANSWER="james.lu@gopher.security"' in workflow assert "Draft ID,Message ID,Thread ID" not in workflow @@ -183,3 +184,13 @@ def test_publish_workflow_checks_linux_x64_dependencies() -> None: assert "ldd \"$sofile\"" in workflow assert "grep -Ev '^(libssl\\.so|libcrypto\\.so)'" in workflow assert "OpenSSL libraries must remain system-provided" in workflow + + +def test_publish_workflow_removes_bundled_linux_openssl_after_copy() -> None: + workflow = (ROOT / ".github" / "workflows" / "publish-packages.yml").read_text() + copy_step = workflow[workflow.index("- name: Copy binaries to package") :] + + assert 'if [[ "${{ matrix.platform }}" == linux-* ]]; then' in copy_step + assert "-name 'libssl.so*'" in copy_step + assert "-name 'libcrypto.so*'" in copy_step + assert "-delete" in copy_step From 021ae56ca7fc32383a995244b7a0352e3210c86f Mon Sep 17 00:00:00 2001 From: RahulHere Date: Sun, 16 Aug 2026 20:35:03 +0800 Subject: [PATCH 12/13] Relax OpenSSL preflight for example verification Summary: Keep Linux native dependency and RPATH checks in verify-examples while treating bundled OpenSSL in preflight-installed packages as a warning. Avoid failing PR example verification against already-published native wheels that still bundle OpenSSL. Update packaging workflow regression coverage for the verify-examples policy. --- .github/workflows/verify-examples.yml | 4 ---- tests/test_linux_native_packaging.py | 3 +-- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/verify-examples.yml b/.github/workflows/verify-examples.yml index 5425c059..4f815295 100644 --- a/.github/workflows/verify-examples.yml +++ b/.github/workflows/verify-examples.yml @@ -159,10 +159,6 @@ jobs: done if find "$native_lib_dir" -maxdepth 1 -type f \( -name 'libssl.so*' -o -name 'libcrypto.so*' \) | grep .; then - if [ "${{ github.event_name }}" = "pull_request" ]; then - echo "OpenSSL libraries must remain system-provided in PR-built packages." - exit 1 - fi echo "WARNING: OpenSSL libraries should remain system-provided in newly published packages." fi diff --git a/tests/test_linux_native_packaging.py b/tests/test_linux_native_packaging.py index 018e273d..20583b72 100644 --- a/tests/test_linux_native_packaging.py +++ b/tests/test_linux_native_packaging.py @@ -83,10 +83,9 @@ def test_verify_examples_workflow_checks_linux_native_dependencies() -> None: assert "RPATH/RUNPATH without \\$ORIGIN" in workflow assert "ldd \"$sofile\"" in workflow assert "grep -Ev '^(libssl\\.so|libcrypto\\.so)'" in workflow - assert 'if [ "${{ github.event_name }}" = "pull_request" ]; then' in workflow - assert "OpenSSL libraries must remain system-provided in PR-built packages." in workflow assert "exit 1" in workflow assert "WARNING: OpenSSL libraries should remain system-provided" in workflow + assert "OpenSSL libraries must remain system-provided in PR-built packages." not in workflow assert "No Linux shared libraries found" in workflow From d5a3d8fc5457d25b0c2989a4f4a363b0f8e4a80b Mon Sep 17 00:00:00 2001 From: RahulHere Date: Sun, 16 Aug 2026 20:42:50 +0800 Subject: [PATCH 13/13] Fix release notes changelog extraction Summary: Generate GitHub release notes from the promoted versioned CHANGELOG.md section instead of empty Unreleased notes. Fall back to recent commits only when the matching version section is missing. Add workflow regression coverage for versioned release-note extraction. --- .github/workflows/publish-packages.yml | 25 ++++++++++++++----------- tests/test_linux_native_packaging.py | 13 +++++++++++++ 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index d46b9f47..cae84710 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -318,18 +318,21 @@ jobs: echo "" >> RELEASE_NOTES.md if [ -f "CHANGELOG.md" ]; then - # Extract content from [Unreleased] section (between ## [Unreleased] and next ## [) - sed -n '/^## \[Unreleased\]/,/^## \[/p' CHANGELOG.md | \ - grep -v "^## \[" | \ - sed '/^$/d' >> RELEASE_NOTES.md || true - - # If Unreleased section is empty, try to get the latest version section - if [ ! -s RELEASE_NOTES.md ] || [ $(wc -l < RELEASE_NOTES.md) -le 10 ]; then + # dump-version.sh promotes [Unreleased] into [${VERSION}] before + # this workflow runs, so release notes must come from that versioned + # section instead of the now-empty [Unreleased] section. + awk -v version="$VERSION" ' + $0 ~ "^## \\[" version "\\]" { capture = 1; next } + capture && /^## \[/ { exit } + capture { print } + ' CHANGELOG.md | sed '/^$/d' > changes.tmp + + if [ -s changes.tmp ]; then + cat changes.tmp >> RELEASE_NOTES.md + else + echo "No CHANGELOG.md entry found for ${VERSION}; listing recent commits..." >> RELEASE_NOTES.md echo "" >> RELEASE_NOTES.md - # Get content from the first versioned section - sed -n '/^## \[0-9\]/,/^## \[/p' CHANGELOG.md | \ - head -50 | \ - grep -v "^## \[" >> RELEASE_NOTES.md || true + git log --pretty=format:"* %s (%h)" --no-merges -20 >> RELEASE_NOTES.md || true fi else echo "No CHANGELOG.md found, listing recent commits..." >> RELEASE_NOTES.md diff --git a/tests/test_linux_native_packaging.py b/tests/test_linux_native_packaging.py index 20583b72..cb429464 100644 --- a/tests/test_linux_native_packaging.py +++ b/tests/test_linux_native_packaging.py @@ -193,3 +193,16 @@ def test_publish_workflow_removes_bundled_linux_openssl_after_copy() -> None: assert "-name 'libssl.so*'" in copy_step assert "-name 'libcrypto.so*'" in copy_step assert "-delete" in copy_step + + +def test_publish_workflow_extracts_release_notes_from_versioned_changelog() -> None: + workflow = (ROOT / ".github" / "workflows" / "publish-packages.yml").read_text() + release_notes_step = workflow[workflow.index("- name: Generate release notes") :] + + assert 'awk -v version="$VERSION"' in release_notes_step + assert '"^## \\\\[\" version "\\\\]"' in release_notes_step + assert "sed '/^$/d' > changes.tmp" in release_notes_step + assert "cat changes.tmp >> RELEASE_NOTES.md" in release_notes_step + assert "No CHANGELOG.md entry found for ${VERSION}" in release_notes_step + assert "## \\[Unreleased\\]" not in release_notes_step + assert "wc -l < RELEASE_NOTES.md" not in release_notes_step