diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index 3a13867d..cae84710 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/" @@ -311,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/.github/workflows/verify-examples.yml b/.github/workflows/verify-examples.yml index ec40ab4e..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 @@ -185,12 +181,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/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" "$@" diff --git a/gopher_mcp_python/__init__.py b/gopher_mcp_python/__init__.py index 34c3da15..ddc58cac 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 ( @@ -39,10 +43,10 @@ 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" +__version__ = "0.1.34" _AUTH_EXPORTS = { "GopherAuth", @@ -123,11 +127,16 @@ def __getattr__(name: str): "GopherAgent", "GopherAgentConfig", "GopherAgentConfigBuilder", + "GopherAgentCreateOptions", + "GopherAgentOAuthOptions", "GopherAgentRuntimeOptions", + "GopherAgentTokenRecord", + "GopherAgentTokenStore", "AgentResult", "AgentResultStatus", "AgentResultBuilder", "ServerConfig", + "ServerConfigRoute", # Errors "AgentError", "ApiKeyError", diff --git a/gopher_mcp_python/agent.py b/gopher_mcp_python/agent.py index 9f43664c..ec76308d 100644 --- a/gopher_mcp_python/agent.py +++ b/gopher_mcp_python/agent.py @@ -27,14 +27,21 @@ """ import atexit +import asyncio +import weakref 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 @@ -55,6 +62,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.""" @@ -107,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 @@ -118,41 +126,19 @@ 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) + if config.has_api_key(): + return GopherAgent.create_with_api_key( + config.provider, + config.model, + config.api_key, + config.runtime_options, + ) + return GopherAgent.create_with_server_config( + config.provider, + config.model, + config.server_config, + config.runtime_options, + ) @staticmethod def create_with_api_key( @@ -173,15 +159,24 @@ def create_with_api_key( Returns: GopherAgent instance """ - builder = ( - GopherAgentConfig.builder() - .provider(provider) - .model(model) - .api_key(api_key) + 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_from_ffi( + lambda lib: lib.agent_create_by_api_key( + provider, model, api_key, runtime_options + ) + ) + + return _create_from_api_config( + provider, + model, + api_key, + route=None, + runtime_options=runtime_options, + oauth=oauth, ) - if runtime_options is not None: - builder.runtime_options(runtime_options) - return GopherAgent.create(builder.build()) @staticmethod def create_with_server_config( @@ -202,15 +197,29 @@ def create_with_server_config( Returns: GopherAgent instance """ - builder = ( - GopherAgentConfig.builder() - .provider(provider) - .model(model) - .server_config(server_config) + 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_from_ffi( + lambda lib: lib.agent_create_by_json( + provider, model, server_config, runtime_options + ) + ) + + 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( + provider, model, server_config, resolved_runtime_options + ) ) - if runtime_options is not None: - builder.runtime_options(runtime_options) - return GopherAgent.create(builder.build()) @staticmethod def create_with_server_id( @@ -237,11 +246,22 @@ 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 + 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(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 _create_from_api_config( + provider, + model, + api_key, + route=ServerConfigRoute("serverId", server_id), + runtime_options=normalized_runtime_options, + oauth=oauth, ) @staticmethod @@ -269,11 +289,22 @@ 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 + 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(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 _create_from_api_config( + provider, + model, + api_key, + route=ServerConfigRoute("serverName", server_name), + runtime_options=normalized_runtime_options, + oauth=oauth, ) @staticmethod @@ -301,11 +332,22 @@ 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 + 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(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 _create_from_api_config( + provider, + model, + api_key, + route=ServerConfigRoute("gatewayId", gateway_id), + runtime_options=normalized_runtime_options, + oauth=oauth, ) @staticmethod @@ -333,11 +375,22 @@ 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 + 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(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 _create_from_api_config( + provider, + model, + api_key, + route=ServerConfigRoute("gatewayName", gateway_name), + runtime_options=normalized_runtime_options, + oauth=oauth, ) @staticmethod @@ -364,7 +417,17 @@ def create_with_url( Returns: GopherAgent instance """ - normalized_runtime_options = normalize_runtime_options(runtime_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 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, normalized_runtime_options @@ -464,14 +527,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.""" @@ -493,6 +560,64 @@ 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 + + +def _create_from_api_config( + 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 = _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( + provider, model, server_config, resolved_runtime_options + ) + ) + + +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], +) -> 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/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/ffi/library.py b/gopher_mcp_python/ffi/library.py index c17dc6ef..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 @@ -353,7 +359,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 +369,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 +800,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 +818,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 +851,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/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..25746aee --- /dev/null +++ b/gopher_mcp_python/oauth_browser.py @@ -0,0 +1,33 @@ +"""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 + 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) + 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..9e3ac0f2 --- /dev/null +++ b/gopher_mcp_python/oauth_discovery.py @@ -0,0 +1,341 @@ +"""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 + + +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", + "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 + registration_endpoint: Optional[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: + 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}" + ) + 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 _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): + 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("/") + if well_known_name == "openid-configuration": + return urlunparse( + ( + parsed.scheme, + parsed.netloc, + f"{path}/.well-known/{well_known_name}", + "", + "", + "", + ) + ) + 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..73e48d9b --- /dev/null +++ b/gopher_mcp_python/oauth_resolver.py @@ -0,0 +1,362 @@ +"""OAuth runtime-option resolver for SDK-side agent creation.""" + +import base64 +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 +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] + 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) + + 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 _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, + 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/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/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/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_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.py b/tests/test_agent_create_with_oauth.py new file mode 100644 index 00000000..8377761d --- /dev/null +++ b/tests/test_agent_create_with_oauth.py @@ -0,0 +1,376 @@ +"""Tests for OAuth-aware GopherAgent factories.""" + +import pytest + +import gopher_mcp_python.agent as agent_module +from gopher_mcp_python import AgentError, 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_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 = GopherAgent.create_with_url( + "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_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 = GopherAgent.create_with_url( + "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_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 = GopherAgent.create_with_url( + "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_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 = GopherAgent.create_with_server_config( + "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_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 = GopherAgent.create_with_api_key("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_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 = 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" + 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_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 = GopherAgent.create_with_gateway_name( + "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_explicit_token_uses_native_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 = GopherAgent.create_with_server_name( + "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_disabled_oauth_uses_native_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 = GopherAgent.create_with_gateway_id( + "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_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): + 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 = 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_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 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_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 diff --git a/tests/test_ffi.py b/tests/test_ffi.py index c78b00bb..b7266bb5 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.""" @@ -118,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_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: diff --git a/tests/test_linux_native_packaging.py b/tests/test_linux_native_packaging.py index e40848b0..cb429464 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 @@ -100,8 +99,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 +183,26 @@ 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 + + +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 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..f05a462d --- /dev/null +++ b/tests/test_oauth_create_with_url_integration.py @@ -0,0 +1,210 @@ +"""Local integration test for OAuth-aware create_with_url.""" + +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: + 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 = 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() + 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_discovery.py b/tests/test_oauth_discovery.py new file mode 100644 index 00000000..c6d42843 --- /dev/null +++ b/tests/test_oauth_discovery.py @@ -0,0 +1,240 @@ +"""Tests for OAuth discovery helpers.""" + +import json +import threading +import urllib.error +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_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) + 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" + + +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 + 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 _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) + 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..5de380ae --- /dev/null +++ b/tests/test_oauth_loopback.py @@ -0,0 +1,80 @@ +"""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 + + +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_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..209b5716 --- /dev/null +++ b/tests/test_oauth_resolver.py @@ -0,0 +1,136 @@ +"""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_resource_metadata_for_challenge, + 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_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()) + + +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_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" 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" 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"))