diff --git a/build.sh b/build.sh index a8a936b8..e2f2c64a 100755 --- a/build.sh +++ b/build.sh @@ -12,6 +12,7 @@ NC='\033[0m' # No Color SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" NATIVE_DIR="${SCRIPT_DIR}/third_party/gopher-orch" BUILD_DIR="${NATIVE_DIR}/build" +SOURCE_STAMP_FILE="${SCRIPT_DIR}/native/.gopher-orch-source" # Handle --clean flag (cleans CMake cache but preserves _deps) if [ "$1" = "--clean" ]; then @@ -47,10 +48,12 @@ fi # Configure SSH URL rewrite for GopherSecurity repos # Clear any existing rewrites first to avoid conflicts +git config --local --unset-all url."git@github.com:GopherSecurity/".insteadOf 2>/dev/null || true git config --local --unset-all url."git@${SSH_HOST}:GopherSecurity/".insteadOf 2>/dev/null || true # Set up URL rewrites - both https and default git@github.com should map to custom SSH host git config --local --add url."git@${SSH_HOST}:GopherSecurity/".insteadOf "https://github.com/GopherSecurity/" git config --local --add url."git@${SSH_HOST}:GopherSecurity/".insteadOf "git@github.com:GopherSecurity/" +git submodule sync -- third_party/gopher-orch git config --local submodule.third_party/gopher-orch.url "git@${SSH_HOST}:GopherSecurity/gopher-orch.git" # Check if submodule directory exists but is empty/broken (missing CMakeLists.txt) @@ -62,30 +65,37 @@ if [ -d "${NATIVE_DIR}" ] && [ ! -f "${NATIVE_DIR}/CMakeLists.txt" ]; then rm -rf .git/modules/third_party/gopher-orch 2>/dev/null || true fi -# Update main submodule -# First try with recorded commit, if that fails (commit doesn't exist), use --remote to get latest -if ! git submodule update --init third_party/gopher-orch 2>/dev/null; then - echo -e "${YELLOW} Recorded commit not found, fetching latest from remote...${NC}" - if ! git submodule update --init --remote third_party/gopher-orch 2>/dev/null; then - echo -e "${RED}Error: Failed to clone gopher-orch submodule${NC}" - echo -e "${YELLOW}If you have multiple GitHub accounts, use:${NC}" - echo -e " GITHUB_SSH_HOST=your-ssh-alias ./build.sh" - exit 1 - fi +# Update main submodule to the latest commit on the branch configured in +# .gitmodules (currently br_release), not just the parent-recorded SHA. +if ! git submodule update --init --remote --checkout third_party/gopher-orch; then + echo -e "${RED}Error: Failed to update gopher-orch submodule to latest remote branch${NC}" + echo -e "${YELLOW}If you have multiple GitHub accounts, use:${NC}" + echo -e " GITHUB_SSH_HOST=your-ssh-alias ./build.sh" + exit 1 fi # Update nested submodule (gopher-mcp inside gopher-orch) -# Note: gopher-orch/.gitmodules has 'update = none' so we must explicitly update if [ -d "${NATIVE_DIR}" ]; then cd "${NATIVE_DIR}" + git config --local --unset-all url."git@github.com:GopherSecurity/".insteadOf 2>/dev/null || true + git config --local --unset-all url."git@${SSH_HOST}:GopherSecurity/".insteadOf 2>/dev/null || true git config --local url."git@${SSH_HOST}:GopherSecurity/".insteadOf "https://github.com/GopherSecurity/" - # Override 'update = none' by using --checkout - git submodule update --init --checkout third_party/gopher-mcp 2>/dev/null || true + git submodule sync -- third_party/gopher-mcp + git config --local submodule.third_party/gopher-mcp.url "git@${SSH_HOST}:GopherSecurity/gopher-mcp.git" + # Keep gopher-mcp on the latest commit from its configured release branch. + if ! git submodule update --init --remote --checkout third_party/gopher-mcp; then + echo -e "${RED}Error: Failed to update gopher-mcp submodule to latest remote branch${NC}" + echo -e "${YELLOW}If you have multiple GitHub accounts, use:${NC}" + echo -e " GITHUB_SSH_HOST=your-ssh-alias ./build.sh" + exit 1 + fi # Also update gopher-mcp's nested submodules recursively if [ -d "third_party/gopher-mcp" ]; then cd third_party/gopher-mcp + git config --local --unset-all url."git@github.com:GopherSecurity/".insteadOf 2>/dev/null || true + git config --local --unset-all url."git@${SSH_HOST}:GopherSecurity/".insteadOf 2>/dev/null || true git config --local url."git@${SSH_HOST}:GopherSecurity/".insteadOf "https://github.com/GopherSecurity/" - git submodule update --init --recursive 2>/dev/null || true + git submodule update --init --recursive fi cd "${SCRIPT_DIR}" fi @@ -101,18 +111,29 @@ if [ ! -d "${NATIVE_DIR}" ]; then fi # Step 3: Build gopher-orch native library -# Skip build if native lib already has the required auth symbols +# Skip build only when the installed native lib matches the current submodule +# revisions and has the required auth symbols. SKIP_NATIVE_BUILD=false EXISTING_LIB="${SCRIPT_DIR}/native/lib/libgopher-orch.dylib" if [ ! -f "${EXISTING_LIB}" ]; then EXISTING_LIB="${SCRIPT_DIR}/native/lib/libgopher-orch.so" fi +SOURCE_STAMP="gopher-orch=$(git -C "${NATIVE_DIR}" rev-parse HEAD)" +if [ -d "${NATIVE_DIR}/third_party/gopher-mcp" ]; then + SOURCE_STAMP="${SOURCE_STAMP} +gopher-mcp=$(git -C "${NATIVE_DIR}/third_party/gopher-mcp" rev-parse HEAD)" +fi + if [ -f "${EXISTING_LIB}" ]; then - if nm -gU "${EXISTING_LIB}" 2>/dev/null | grep -q "gopher_auth_config_create"; then - echo -e "${GREEN}✓ Native library already has auth symbols — skipping rebuild${NC}" - echo -e " (To force rebuild, delete native/lib/ first)" - SKIP_NATIVE_BUILD=true + if [ -f "${SOURCE_STAMP_FILE}" ] && [ "$(cat "${SOURCE_STAMP_FILE}")" = "${SOURCE_STAMP}" ]; then + if nm -gU "${EXISTING_LIB}" 2>/dev/null | grep -q "gopher_auth_config_create"; then + echo -e "${GREEN}✓ Native library already matches latest submodule revisions — skipping rebuild${NC}" + echo -e " (To force rebuild, delete native/lib/ first)" + SKIP_NATIVE_BUILD=true + fi + else + echo -e "${YELLOW} Native source revision changed; rebuilding libgopher-orch${NC}" fi fi @@ -167,6 +188,7 @@ cp -P "${BUILD_DIR}"/_deps/fmt-build/libfmt*.a "${NATIVE_LIB}/" 2>/dev/null || t cd "${SCRIPT_DIR}" echo -e "${GREEN}✓ Native library built successfully${NC}" +printf "%s\n" "${SOURCE_STAMP}" > "${SOURCE_STAMP_FILE}" echo "" fi # end SKIP_NATIVE_BUILD @@ -238,11 +260,14 @@ echo -e "${YELLOW}Step 5: Running tests...${NC}" export PYTHONPATH="${SCRIPT_DIR}:${PYTHONPATH}" # Use the freshly built native library (not a stale pip-installed version) -export GOPHER_ORCH_LIBRARY_PATH="${NATIVE_LIB_DIR}/libgopher-orch.dylib" -if [ ! -f "${GOPHER_ORCH_LIBRARY_PATH}" ]; then +export GOPHER_MCP_PYTHON_LIBRARY_PATH="${NATIVE_LIB_DIR}/libgopher-orch.dylib" +if [ ! -f "${GOPHER_MCP_PYTHON_LIBRARY_PATH}" ]; then # Try .so for Linux - export GOPHER_ORCH_LIBRARY_PATH="${NATIVE_LIB_DIR}/libgopher-orch.so" + export GOPHER_MCP_PYTHON_LIBRARY_PATH="${NATIVE_LIB_DIR}/libgopher-orch.so" fi +export GOPHER_ORCH_LIBRARY_PATH="${GOPHER_MCP_PYTHON_LIBRARY_PATH}" +export DYLD_LIBRARY_PATH="${NATIVE_LIB_DIR}:${DYLD_LIBRARY_PATH}" +export LD_LIBRARY_PATH="${NATIVE_LIB_DIR}:${LD_LIBRARY_PATH}" # Try to run pytest, handling different installation scenarios if python3 -c "import pytest" 2>/dev/null; then diff --git a/examples/header/create_by_url.py b/examples/header/create_by_url.py new file mode 100644 index 00000000..6b0b5a26 --- /dev/null +++ b/examples/header/create_by_url.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +""" +SDK example for GopherAgent.create_with_url with dynamic MCP headers. + +Python port of gopher-mcp-js/examples/header/create_by_url.ts, which +itself ports gopher-orch/examples/sdk/header/access_token_create_by_url.cc. + +Shows the runtime options object: + + GopherAgent.create_with_url( + provider, + model, + mcp_url, + runtime_options={"access_token": user_access_token}, + ) + +or: + + GopherAgent.create_with_url( + provider, + model, + mcp_url, + runtime_options={"headers": {"x-trace-id": "..."}}, + ) + +access_token is a convenience alias for Authorization: Bearer +on MCP runtime traffic only. Explicit headers["Authorization"] takes +precedence on the native side. +""" + +import os +import sys +import traceback + +from gopher_mcp_python import GopherAgent + + +def env_or(name: str, fallback: str) -> str: + """Return os.environ[name] if non-empty, otherwise fallback.""" + value = os.environ.get(name, "") + return value if value else fallback + + +def queries_from_args() -> list[str]: + """Return command-line queries or a default query.""" + return sys.argv[1:] if len(sys.argv) > 1 else ["What is the weather in Tokyo?"] + + +def runtime_options_from_env() -> dict[str, object]: + """ + Build dynamic MCP runtime options for the example. + + Set GOPHER_HEADER_MODE=headers to demonstrate the headers-only form. + The default token mode demonstrates the access_token convenience form. + """ + mode = env_or("GOPHER_HEADER_MODE", "token") + access_token = env_or("GOPHER_ACCESS_TOKEN", "") + trace_id = env_or("GOPHER_TRACE_ID", "python-header-create-by-url") + + if mode == "headers": + headers = { + "x-gopher-example": "header-create-by-url", + "x-trace-id": trace_id, + } + if access_token: + headers["Authorization"] = f"Bearer {access_token}" + return {"headers": headers} + + return { + "access_token": access_token, + "headers": { + "x-gopher-example": "header-create-by-url", + "x-trace-id": trace_id, + }, + } + + +def main() -> None: + print("=== GopherAgent.create_with_url dynamic header example ===") + print(f"Usage: python3 {sys.argv[0]} [query1] [query2] ...") + print( + "Env: GOPHER_MCP_URL GOPHER_ACCESS_TOKEN GOPHER_HEADER_MODE " + "GOPHER_TRACE_ID LLM_PROVIDER LLM_MODEL" + ) + print("") + + provider = env_or("LLM_PROVIDER", "AnthropicProvider") + model = env_or("LLM_MODEL", "{YOUR_LLM_MODEL}") + mcp_url = env_or("GOPHER_MCP_URL", "http://127.0.0.1:5001/mcp") + access_token = env_or("GOPHER_ACCESS_TOKEN", "") + queries = queries_from_args() + runtime_options = runtime_options_from_env() + + print(f"Provider: {provider}") + model_label = f"{model} (set LLM_MODEL)" if model == "{YOUR_LLM_MODEL}" else model + print(f"Model: {model_label}") + print(f"MCP URL: {mcp_url}") + token_label = ( + "" + if not access_token + else "" + ) + print(f"Access token: {token_label}") + print(f"Header mode: {env_or('GOPHER_HEADER_MODE', 'token')}") + print(f"Queries: {len(queries)}") + + if model == "{YOUR_LLM_MODEL}": + print("\nError: LLM_MODEL must be set.", file=sys.stderr) + sys.exit(1) + + print("\nCreating agent via GopherAgent.create_with_url(..., runtime_options=...)") + agent = GopherAgent.create_with_url( + provider, + model, + mcp_url, + runtime_options=runtime_options, + ) + print("Agent created successfully!") + + try: + for i, query in enumerate(queries): + print(f"\nQuery {i + 1}: {query}") + answer = agent.run(query) + print(f"\nAgent Response {i + 1}:") + print("--------------------------------") + print(answer) + print("--------------------------------") + finally: + agent.dispose() + + +if __name__ == "__main__": + try: + main() + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + traceback.print_exc() + sys.exit(1) diff --git a/examples/header/create_by_url_gateway b/examples/header/create_by_url_gateway new file mode 100755 index 00000000..53805a17 Binary files /dev/null and b/examples/header/create_by_url_gateway differ diff --git a/examples/header/create_by_url_run.sh b/examples/header/create_by_url_run.sh new file mode 100755 index 00000000..928ca456 --- /dev/null +++ b/examples/header/create_by_url_run.sh @@ -0,0 +1,211 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +SERVER_BIN="${SCRIPT_DIR}/create_by_url_server" +GATEWAY_BIN="${SCRIPT_DIR}/create_by_url_gateway" +CLIENT_PY="${SCRIPT_DIR}/create_by_url.py" + +SERVER_PORT="${GOPHER_MCP_SERVER_PORT:-5000}" +GATEWAY_PORT="${GOPHER_GATEWAY_PORT:-5001}" +BACKEND_URL="http://127.0.0.1:${SERVER_PORT}/mcp" +GATEWAY_URL="http://127.0.0.1:${GATEWAY_PORT}/mcp" + +TOKEN="${GOPHER_ACCESS_TOKEN:-abc123456789xyz}" +QUERY="${1:-What is the weather in Tokyo?}" +SESSION_ID="gopher-mcp-python-header-create-by-url-run" + +LOG_DIR="${TMPDIR:-/tmp}/gopher-mcp-python-header-verify.$$" +SERVER_LOG="${LOG_DIR}/server.log" +GATEWAY_LOG="${LOG_DIR}/gateway.log" +CLIENT_LOG="${LOG_DIR}/client.log" +CURL_LOG="${LOG_DIR}/curl.log" + +SERVER_PID="" +GATEWAY_PID="" + +redacted_token() { + local token="$1" + local len=${#token} + if [[ ${len} -eq 0 ]]; then + printf '' + elif [[ ${len} -le 6 ]]; then + printf '***' + else + printf '%s...%s' "${token:0:3}" "${token: -3}" + fi +} + +wait_for_url() { + local url="$1" + local name="$2" + local deadline=$((SECONDS + 20)) + + while ((SECONDS < deadline)); do + if curl -fsS "${url}" >/dev/null 2>&1; then + return 0 + fi + sleep 0.5 + done + + echo "ERROR: ${name} did not become ready at ${url}" >&2 + return 1 +} + +release_port() { + local port="$1" + local pids + pids="$(lsof -tiTCP:"${port}" -sTCP:LISTEN 2>/dev/null || true)" + if [[ -z "${pids}" ]]; then + return 0 + fi + + echo "Releasing TCP port ${port}: ${pids}" + kill ${pids} 2>/dev/null || true + + local deadline=$((SECONDS + 5)) + while ((SECONDS < deadline)); do + pids="$(lsof -tiTCP:"${port}" -sTCP:LISTEN 2>/dev/null || true)" + if [[ -z "${pids}" ]]; then + return 0 + fi + sleep 0.2 + done + + pids="$(lsof -tiTCP:"${port}" -sTCP:LISTEN 2>/dev/null || true)" + if [[ -n "${pids}" ]]; then + echo "Force releasing TCP port ${port}: ${pids}" + kill -9 ${pids} 2>/dev/null || true + fi +} + +cleanup() { + local status=$? + if [[ -n "${GATEWAY_PID}" ]] && kill -0 "${GATEWAY_PID}" 2>/dev/null; then + kill "${GATEWAY_PID}" 2>/dev/null || true + wait "${GATEWAY_PID}" 2>/dev/null || true + fi + if [[ -n "${SERVER_PID}" ]] && kill -0 "${SERVER_PID}" 2>/dev/null; then + kill "${SERVER_PID}" 2>/dev/null || true + wait "${SERVER_PID}" 2>/dev/null || true + fi + + if [[ ${status} -ne 0 ]]; then + echo + echo "Verification failed. Logs are in: ${LOG_DIR}" >&2 + echo " server: ${SERVER_LOG}" >&2 + echo " gateway: ${GATEWAY_LOG}" >&2 + echo " client: ${CLIENT_LOG}" >&2 + echo " curl: ${CURL_LOG}" >&2 + else + echo + echo "Verification passed. Logs are in: ${LOG_DIR}" + fi + exit "${status}" +} +trap cleanup EXIT + +mkdir -p "${LOG_DIR}" + +for bin in "${SERVER_BIN}" "${GATEWAY_BIN}"; do + if [[ ! -x "${bin}" ]]; then + echo "ERROR: required binary not found or not executable: ${bin}" >&2 + exit 1 + fi +done + +if [[ ! -f "${CLIENT_PY}" ]]; then + echo "ERROR: Python client not found: ${CLIENT_PY}" >&2 + exit 1 +fi + +release_port "${SERVER_PORT}" +release_port "${GATEWAY_PORT}" + +echo "Starting local MCP server on ${BACKEND_URL}..." +GOPHER_SDK_TEST=1 \ +GOPHER_MCP_LOG_FLOW=1 \ +GOPHER_MCP_SERVER_PORT="${SERVER_PORT}" \ +"${SERVER_BIN}" >"${SERVER_LOG}" 2>&1 & +SERVER_PID=$! +wait_for_url "http://127.0.0.1:${SERVER_PORT}/health" "MCP server" + +echo "Starting local gateway on ${GATEWAY_URL}..." +GOPHER_SDK_TEST=1 \ +GOPHER_MCP_LOG_FLOW=1 \ +GOPHER_GATEWAY_PORT="${GATEWAY_PORT}" \ +GOPHER_BACKEND_MCP_URL="${BACKEND_URL}" \ +"${GATEWAY_BIN}" >"${GATEWAY_LOG}" 2>&1 & +GATEWAY_PID=$! +wait_for_url "http://127.0.0.1:${GATEWAY_PORT}/health" "gateway" + +echo "Running deterministic MCP calls through gateway..." +{ + echo "=== initialize ===" + curl -fsS -X POST "${GATEWAY_URL}" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "mcp-session-id: ${SESSION_ID}" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"gopher-mcp-python-header-verify","version":"1.0.0"}}}' + echo + echo "=== tools/list ===" + curl -fsS -X POST "${GATEWAY_URL}" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "mcp-session-id: ${SESSION_ID}" \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' + echo + echo "=== tools/call ===" + curl -fsS -X POST "${GATEWAY_URL}" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "mcp-session-id: ${SESSION_ID}" \ + -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get-weather","arguments":{"city":"Tokyo"}}}' + echo +} >"${CURL_LOG}" 2>&1 + +if [[ -n "${ANTHROPIC_API_KEY:-}" ]]; then + echo "Running Python SDK agent client through gateway..." + ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY}" \ + GOPHER_SDK_TEST=1 \ + GOPHER_MCP_LOG_FLOW=1 \ + GOPHER_MCP_URL="${GATEWAY_URL}" \ + GOPHER_ACCESS_TOKEN="${TOKEN}" \ + python3 "${CLIENT_PY}" "${QUERY}" >"${CLIENT_LOG}" 2>&1 || { + echo "SDK client returned non-zero; continuing with transport verification." + } +else + echo "Skipping Python SDK agent client because ANTHROPIC_API_KEY is not set." +fi + +EXPECTED="Bearer $(redacted_token "${TOKEN}") len=${#TOKEN}" + +echo "Checking token trace: ${EXPECTED}" +grep -F "token=${EXPECTED}" "${SERVER_LOG}" >/dev/null +grep -F "[access-token-server] get-weather" "${SERVER_LOG}" >/dev/null +grep -F "Current weather in Tokyo" "${CURL_LOG}" >/dev/null +if grep -F "mcp auth:" "${GATEWAY_LOG}" >/dev/null; then + grep -F "token=${EXPECTED}" "${GATEWAY_LOG}" >/dev/null +fi +if [[ -s "${CLIENT_LOG}" ]]; then + if grep -F "mcp auth:" "${CLIENT_LOG}" >/dev/null; then + grep -F "token=${EXPECTED}" "${CLIENT_LOG}" >/dev/null + fi +fi + +echo +echo "Matched token logs:" +if [[ -s "${CLIENT_LOG}" ]]; then + grep -F "token=${EXPECTED}" "${CLIENT_LOG}" | head -n 3 || true +else + echo "(Python SDK client skipped; no client log)" +fi +grep -F "token=${EXPECTED}" "${GATEWAY_LOG}" | head -n 5 || true +grep -F "token=${EXPECTED}" "${SERVER_LOG}" | head -n 3 +echo +echo "MCP tools/call response:" +grep -F "Current weather in Tokyo" "${CURL_LOG}" | head -n 1 diff --git a/examples/header/create_by_url_server b/examples/header/create_by_url_server new file mode 100755 index 00000000..b9650ac5 Binary files /dev/null and b/examples/header/create_by_url_server differ diff --git a/gopher_mcp_python/__init__.py b/gopher_mcp_python/__init__.py index 235e51e8..0cdfbdd4 100644 --- a/gopher_mcp_python/__init__.py +++ b/gopher_mcp_python/__init__.py @@ -23,7 +23,11 @@ """ from gopher_mcp_python.agent import GopherAgent -from gopher_mcp_python.config import GopherAgentConfig, GopherAgentConfigBuilder +from gopher_mcp_python.config import ( + GopherAgentConfig, + GopherAgentConfigBuilder, + GopherAgentRuntimeOptions, +) from gopher_mcp_python.result import AgentResult, AgentResultStatus, AgentResultBuilder from gopher_mcp_python.errors import ( AgentError, @@ -57,6 +61,7 @@ "GopherAgent", "GopherAgentConfig", "GopherAgentConfigBuilder", + "GopherAgentRuntimeOptions", "AgentResult", "AgentResultStatus", "AgentResultBuilder", diff --git a/gopher_mcp_python/agent.py b/gopher_mcp_python/agent.py index 3e74b486..4b596eb5 100644 --- a/gopher_mcp_python/agent.py +++ b/gopher_mcp_python/agent.py @@ -122,11 +122,17 @@ def create(config: GopherAgentConfig) -> "GopherAgent": try: if config.has_api_key(): handle = lib.agent_create_by_api_key( - config.provider, config.model, config.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.provider, + config.model, + config.server_config, + config.runtime_options, ) except Exception as e: raise AgentError(f"Failed to create agent: {e}") @@ -139,7 +145,12 @@ def create(config: GopherAgentConfig) -> "GopherAgent": return GopherAgent(handle) @staticmethod - def create_with_api_key(provider: str, model: str, api_key: str) -> "GopherAgent": + def create_with_api_key( + provider: str, + model: str, + api_key: str, + runtime_options: Optional[object] = None, + ) -> "GopherAgent": """ Create a new GopherAgent with API key. @@ -147,21 +158,27 @@ def create_with_api_key(provider: str, model: str, api_key: str) -> "GopherAgent provider: Provider name (e.g., "AnthropicProvider") model: Model name (e.g., "claude-3-haiku-20240307") api_key: API key for fetching remote server config + runtime_options: Dynamic MCP runtime headers/access token Returns: GopherAgent instance """ - return GopherAgent.create( + builder = ( GopherAgentConfig.builder() .provider(provider) .model(model) .api_key(api_key) - .build() ) + if runtime_options is not None: + builder.runtime_options(runtime_options) + return GopherAgent.create(builder.build()) @staticmethod def create_with_server_config( - provider: str, model: str, server_config: str + provider: str, + model: str, + server_config: str, + runtime_options: Optional[object] = None, ) -> "GopherAgent": """ Create a new GopherAgent with JSON server config. @@ -170,21 +187,28 @@ def create_with_server_config( provider: Provider name (e.g., "AnthropicProvider") model: Model name (e.g., "claude-3-haiku-20240307") server_config: JSON server configuration + runtime_options: Dynamic MCP runtime headers/access token Returns: GopherAgent instance """ - return GopherAgent.create( + builder = ( GopherAgentConfig.builder() .provider(provider) .model(model) .server_config(server_config) - .build() ) + if runtime_options is not None: + builder.runtime_options(runtime_options) + return GopherAgent.create(builder.build()) @staticmethod def create_with_server_id( - provider: str, model: str, api_key: str, server_id: str + provider: str, + model: str, + api_key: str, + server_id: str, + runtime_options: Optional[object] = None, ) -> "GopherAgent": """ Create a new GopherAgent scoped to a single MCP server by id. @@ -198,19 +222,24 @@ def create_with_server_id( model: Model identifier accepted by the chosen provider api_key: Gopher API key server_id: MCP server id to scope the agent to + runtime_options: Dynamic MCP runtime headers/access token Returns: GopherAgent instance """ return GopherAgent._create_from_ffi( lambda lib: lib.agent_create_by_server_id( - provider, model, api_key, server_id + provider, model, api_key, server_id, runtime_options ) ) @staticmethod def create_with_server_name( - provider: str, model: str, api_key: str, server_name: str + provider: str, + model: str, + api_key: str, + server_name: str, + runtime_options: Optional[object] = None, ) -> "GopherAgent": """ Create a new GopherAgent scoped to a single MCP server by name. @@ -224,19 +253,24 @@ def create_with_server_name( model: Model identifier accepted by the chosen provider api_key: Gopher API key server_name: MCP server name to scope the agent to + runtime_options: Dynamic MCP runtime headers/access token Returns: GopherAgent instance """ return GopherAgent._create_from_ffi( lambda lib: lib.agent_create_by_server_name( - provider, model, api_key, server_name + provider, model, api_key, server_name, runtime_options ) ) @staticmethod def create_with_gateway_id( - provider: str, model: str, api_key: str, gateway_id: str + provider: str, + model: str, + api_key: str, + gateway_id: str, + runtime_options: Optional[object] = None, ) -> "GopherAgent": """ Create a new GopherAgent scoped to a single MCP gateway by id. @@ -250,19 +284,24 @@ def create_with_gateway_id( model: Model identifier accepted by the chosen provider api_key: Gopher API key gateway_id: MCP gateway id to scope the agent to + runtime_options: Dynamic MCP runtime headers/access token Returns: GopherAgent instance """ return GopherAgent._create_from_ffi( lambda lib: lib.agent_create_by_gateway_id( - provider, model, api_key, gateway_id + provider, model, api_key, gateway_id, runtime_options ) ) @staticmethod def create_with_gateway_name( - provider: str, model: str, api_key: str, gateway_name: str + provider: str, + model: str, + api_key: str, + gateway_name: str, + runtime_options: Optional[object] = None, ) -> "GopherAgent": """ Create a new GopherAgent scoped to a single MCP gateway by name. @@ -276,18 +315,24 @@ def create_with_gateway_name( model: Model identifier accepted by the chosen provider api_key: Gopher API key gateway_name: MCP gateway name to scope the agent to + runtime_options: Dynamic MCP runtime headers/access token Returns: GopherAgent instance """ return GopherAgent._create_from_ffi( lambda lib: lib.agent_create_by_gateway_name( - provider, model, api_key, gateway_name + provider, model, api_key, gateway_name, runtime_options ) ) @staticmethod - def create_with_url(provider: str, model: str, url: str) -> "GopherAgent": + def create_with_url( + provider: str, + model: str, + url: str, + runtime_options: Optional[object] = None, + ) -> "GopherAgent": """ Create a new GopherAgent for a single MCP server reachable at a URL. @@ -300,12 +345,13 @@ def create_with_url(provider: str, model: str, url: str) -> "GopherAgent": provider: Provider name (e.g., "AnthropicProvider") model: Model identifier accepted by the chosen provider url: Full URL of the MCP server (e.g., "http://127.0.0.1:8080/mcp") + runtime_options: Dynamic MCP runtime headers/access token Returns: GopherAgent instance """ return GopherAgent._create_from_ffi( - lambda lib: lib.agent_create_by_url(provider, model, url) + lambda lib: lib.agent_create_by_url(provider, model, url, runtime_options) ) @staticmethod diff --git a/gopher_mcp_python/config.py b/gopher_mcp_python/config.py index 89e6a362..e2b1df44 100644 --- a/gopher_mcp_python/config.py +++ b/gopher_mcp_python/config.py @@ -4,7 +4,86 @@ Provides a builder pattern for creating agent configurations with validation. """ -from typing import Optional +from typing import Dict, Mapping, Optional + + +class GopherAgentRuntimeOptions: + """ + Runtime options applied when the native agent connects to MCP servers. + + access_token is a convenience for Authorization: Bearer . If an + explicit Authorization header is supplied in headers, that header wins. + """ + + def __init__( + self, + access_token: Optional[str] = None, + headers: Optional[Mapping[str, str]] = None, + ) -> None: + self._access_token = access_token + self._headers = _normalize_headers(access_token, headers) + + @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) + + +def normalize_runtime_options( + options: Optional[object] = None, +) -> Optional[GopherAgentRuntimeOptions]: + """ + Normalize runtime options into a GopherAgentRuntimeOptions instance. + + Accepts None, GopherAgentRuntimeOptions, or a dict-like object with + access_token and/or headers keys. Empty options normalize to None. + """ + if options is None: + return None + + if isinstance(options, GopherAgentRuntimeOptions): + if options.access_token is None and len(options.headers) == 0: + return None + return options + + if isinstance(options, Mapping): + access_token = options.get("access_token") + headers = options.get("headers") + if access_token is None and (headers is None or len(headers) == 0): + return None + return GopherAgentRuntimeOptions(access_token=access_token, headers=headers) + + raise ValueError( + "runtime_options must be a GopherAgentRuntimeOptions instance or mapping" + ) + + +def _normalize_headers( + access_token: Optional[str], + headers: Optional[Mapping[str, str]], +) -> Dict[str, str]: + normalized: Dict[str, str] = {} + + if headers is not None: + if not isinstance(headers, Mapping): + raise ValueError("runtime option headers must be a string mapping") + for name, value in headers.items(): + if not isinstance(name, str) or not isinstance(value, str): + raise ValueError("runtime option headers must be a string mapping") + normalized[name] = value + + if access_token is not None: + if not isinstance(access_token, str): + raise ValueError("runtime option access_token must be a string") + if "Authorization" not in normalized: + normalized["Authorization"] = f"Bearer {access_token}" + + return normalized class GopherAgentConfig: @@ -37,6 +116,7 @@ def __init__( model: str, api_key: Optional[str] = None, server_config: Optional[str] = None, + runtime_options: Optional[object] = None, ) -> None: """ Initialize a GopherAgentConfig. @@ -46,11 +126,13 @@ def __init__( model: The model name (e.g., "claude-3-haiku-20240307") api_key: API key for fetching remote server config server_config: JSON server configuration string + runtime_options: Dynamic MCP runtime headers/access token """ self._provider = provider self._model = model self._api_key = api_key self._server_config = server_config + self._runtime_options = normalize_runtime_options(runtime_options) @property def provider(self) -> str: @@ -72,6 +154,11 @@ def server_config(self) -> Optional[str]: """Get the server configuration JSON.""" return self._server_config + @property + def runtime_options(self) -> Optional[GopherAgentRuntimeOptions]: + """Get dynamic MCP runtime options.""" + return self._runtime_options + def has_api_key(self) -> bool: """Check if configuration uses API key.""" return self._api_key is not None @@ -98,6 +185,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 def provider(self, provider: str) -> "GopherAgentConfigBuilder": """ @@ -155,6 +243,57 @@ def server_config(self, server_config: str) -> "GopherAgentConfigBuilder": self._server_config = server_config return self + def runtime_options(self, options: object) -> "GopherAgentConfigBuilder": + """ + Set dynamic MCP runtime options. + + Args: + options: GopherAgentRuntimeOptions or mapping with access_token/headers + + Returns: + self for chaining + """ + self._runtime_options = normalize_runtime_options(options) + return self + + def access_token(self, access_token: str) -> "GopherAgentConfigBuilder": + """ + Set the MCP runtime bearer token. + + Args: + access_token: Token sent as Authorization: Bearer + + Returns: + self for chaining + """ + 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} + ) + return self + + def headers(self, headers: Mapping[str, str]) -> "GopherAgentConfigBuilder": + """ + Set dynamic MCP runtime headers. + + Args: + headers: Header name/value mapping + + Returns: + self for chaining + """ + current_token = ( + self._runtime_options.access_token + if self._runtime_options is not None + else None + ) + self._runtime_options = normalize_runtime_options( + {"access_token": current_token, "headers": headers} + ) + return self + def build(self) -> GopherAgentConfig: """ Build the configuration. @@ -179,4 +318,5 @@ def build(self) -> GopherAgentConfig: model=self._model, api_key=self._api_key, server_config=self._server_config, + runtime_options=self._runtime_options, ) diff --git a/gopher_mcp_python/ffi/auth/loader.py b/gopher_mcp_python/ffi/auth/loader.py index b3217c4c..150cff05 100644 --- a/gopher_mcp_python/ffi/auth/loader.py +++ b/gopher_mcp_python/ffi/auth/loader.py @@ -117,15 +117,11 @@ def _get_search_paths() -> List[str]: """ paths: List[str] = [] - # Platform-specific optional dependency package - platform_package_path = _get_platform_package_path() - if platform_package_path: - paths.append(platform_package_path) - # Get the directory containing this module module_dir = Path(__file__).parent.parent.parent.parent - # Development paths + # Development paths. Prefer these so local runs use the library produced by + # ./build.sh before any installed platform package. paths.extend( [ str(Path.cwd() / "native" / "lib"), @@ -135,6 +131,11 @@ def _get_search_paths() -> List[str]: ] ) + # Platform-specific optional dependency package + platform_package_path = _get_platform_package_path() + if platform_package_path: + paths.append(platform_package_path) + # System paths system = platform.system().lower() if system == "darwin": @@ -149,9 +150,9 @@ def load_library() -> bool: Load the gopher-orch native library. Searches for the library in the following order: - 1. Path specified by GOPHER_ORCH_LIBRARY_PATH environment variable - 2. Platform-specific pip package - 3. Development paths (native/lib, lib) + 1. Path specified by GOPHER_MCP_PYTHON_LIBRARY_PATH environment variable + 2. Development paths (native/lib, lib) + 3. Platform-specific pip package 4. System paths (/usr/local/lib, /usr/lib, etc.) Returns: @@ -167,8 +168,10 @@ def load_library() -> bool: search_paths = _get_search_paths() # Try environment variable path first - env_path = os.environ.get("GOPHER_ORCH_LIBRARY_PATH") or os.environ.get( - "GOPHER_AUTH_LIBRARY_PATH" + env_path = ( + os.environ.get("GOPHER_MCP_PYTHON_LIBRARY_PATH") + or os.environ.get("GOPHER_ORCH_LIBRARY_PATH") + or os.environ.get("GOPHER_AUTH_LIBRARY_PATH") ) if env_path and os.path.exists(env_path): try: diff --git a/gopher_mcp_python/ffi/library.py b/gopher_mcp_python/ffi/library.py index 3aa0aee6..027e7a74 100644 --- a/gopher_mcp_python/ffi/library.py +++ b/gopher_mcp_python/ffi/library.py @@ -5,10 +5,12 @@ import ctypes import os import sys -from ctypes import c_char_p, c_void_p, c_int32, c_int64, POINTER, Structure +from ctypes import c_char_p, c_void_p, c_int32, c_int64, c_size_t, POINTER, Structure from pathlib import Path from typing import Optional, Any +from gopher_mcp_python.config import GopherAgentRuntimeOptions, normalize_runtime_options + # Type alias for opaque handle GopherOrchHandle = c_void_p @@ -34,6 +36,76 @@ class GopherOrchErrorInfo(Structure): ] +class GopherOrchHeader(Structure): + """ + Header pair structure matching C: + typedef struct { + const char* name; + const char* value; + } gopher_orch_header_t; + """ + + _fields_ = [ + ("name", c_char_p), + ("value", c_char_p), + ] + + +class GopherOrchAgentOptions(Structure): + """ + Agent runtime options structure matching C: + typedef struct { + const char* access_token; + const gopher_orch_header_t* headers; + gopher_orch_size_t header_count; + } gopher_orch_agent_options_t; + """ + + _fields_ = [ + ("access_token", c_char_p), + ("headers", POINTER(GopherOrchHeader)), + ("header_count", c_size_t), + ] + + +class _AgentOptionsStorage: + """Owns ctypes memory for a gopher_orch_agent_options_t call.""" + + def __init__(self, options: GopherAgentRuntimeOptions) -> None: + self._bytes = [] + + access_token = options.access_token + if access_token is not None: + access_token_bytes = access_token.encode("utf-8") + self._bytes.append(access_token_bytes) + else: + access_token_bytes = None + + header_items = list(options.headers.items()) + self.header_count = len(header_items) + if header_items: + self.header_array = (GopherOrchHeader * len(header_items))() + for i, (name, value) in enumerate(header_items): + name_bytes = name.encode("utf-8") + value_bytes = value.encode("utf-8") + self._bytes.extend([name_bytes, value_bytes]) + self.header_array[i] = GopherOrchHeader(name_bytes, value_bytes) + headers_ptr = self.header_array + else: + self.header_array = None + headers_ptr = None + + self.options = GopherOrchAgentOptions( + access_token_bytes, + headers_ptr, + self.header_count, + ) + + @property + def pointer(self): + return ctypes.byref(self.options) + + class GopherOrchLibrary: """ Wrapper for the gopher-mcp-python native library using ctypes. @@ -71,7 +143,9 @@ def _load_library(self) -> None: search_paths = self._get_search_paths() # Try custom path from environment variable - env_path = os.environ.get("GOPHER_MCP_PYTHON_LIBRARY_PATH") + env_path = os.environ.get("GOPHER_MCP_PYTHON_LIBRARY_PATH") or os.environ.get( + "GOPHER_ORCH_LIBRARY_PATH" + ) if env_path and os.path.exists(env_path): try: self._lib = ctypes.CDLL(env_path) @@ -81,7 +155,7 @@ def _load_library(self) -> None: except OSError as e: if self._debug: print( - f"Failed to load from GOPHER_MCP_PYTHON_LIBRARY_PATH: {e}", + f"Failed to load from environment library path: {e}", file=sys.stderr, ) @@ -126,6 +200,10 @@ def _setup_functions(self) -> None: c_char_p, ] self._lib.gopher_orch_agent_create_by_json.restype = c_void_p + self._bind_optional_agent_options_symbol( + "gopher_orch_agent_create_by_json_with_options", + [c_char_p, c_char_p, c_char_p, POINTER(GopherOrchAgentOptions)], + ) self._lib.gopher_orch_agent_create_by_api_key.argtypes = [ c_char_p, @@ -133,6 +211,10 @@ def _setup_functions(self) -> None: c_char_p, ] self._lib.gopher_orch_agent_create_by_api_key.restype = c_void_p + self._bind_optional_agent_options_symbol( + "gopher_orch_agent_create_by_api_key_with_options", + [c_char_p, c_char_p, c_char_p, POINTER(GopherOrchAgentOptions)], + ) # Routing factories: scope the agent to a single MCP server or gateway # selected by id / name, or to a known MCP URL. These C symbols landed @@ -147,6 +229,16 @@ def _setup_functions(self) -> None: c_char_p, ] self._lib.gopher_orch_agent_create_by_server_id.restype = c_void_p + self._bind_optional_agent_options_symbol( + "gopher_orch_agent_create_by_server_id_with_options", + [ + c_char_p, + c_char_p, + c_char_p, + c_char_p, + POINTER(GopherOrchAgentOptions), + ], + ) self._lib.gopher_orch_agent_create_by_server_name.argtypes = [ c_char_p, @@ -155,6 +247,16 @@ def _setup_functions(self) -> None: c_char_p, ] self._lib.gopher_orch_agent_create_by_server_name.restype = c_void_p + self._bind_optional_agent_options_symbol( + "gopher_orch_agent_create_by_server_name_with_options", + [ + c_char_p, + c_char_p, + c_char_p, + c_char_p, + POINTER(GopherOrchAgentOptions), + ], + ) self._lib.gopher_orch_agent_create_by_gateway_id.argtypes = [ c_char_p, @@ -163,6 +265,16 @@ def _setup_functions(self) -> None: c_char_p, ] self._lib.gopher_orch_agent_create_by_gateway_id.restype = c_void_p + self._bind_optional_agent_options_symbol( + "gopher_orch_agent_create_by_gateway_id_with_options", + [ + c_char_p, + c_char_p, + c_char_p, + c_char_p, + POINTER(GopherOrchAgentOptions), + ], + ) self._lib.gopher_orch_agent_create_by_gateway_name.argtypes = [ c_char_p, @@ -171,6 +283,16 @@ def _setup_functions(self) -> None: c_char_p, ] self._lib.gopher_orch_agent_create_by_gateway_name.restype = c_void_p + self._bind_optional_agent_options_symbol( + "gopher_orch_agent_create_by_gateway_name_with_options", + [ + c_char_p, + c_char_p, + c_char_p, + c_char_p, + POINTER(GopherOrchAgentOptions), + ], + ) self._lib.gopher_orch_agent_create_by_url.argtypes = [ c_char_p, @@ -178,6 +300,10 @@ def _setup_functions(self) -> None: c_char_p, ] self._lib.gopher_orch_agent_create_by_url.restype = c_void_p + self._bind_optional_agent_options_symbol( + "gopher_orch_agent_create_by_url_with_options", + [c_char_p, c_char_p, c_char_p, POINTER(GopherOrchAgentOptions)], + ) except AttributeError: # Older libgopher-orch builds (< 0.1.23) lack these symbols. pass @@ -216,6 +342,16 @@ def _setup_functions(self) -> None: # Function not available in this version of the library pass + def _bind_optional_agent_options_symbol(self, name: str, argtypes: list) -> None: + if self._lib is None: + return + try: + fn = getattr(self._lib, name) + fn.argtypes = argtypes + fn.restype = c_void_p + except AttributeError: + pass + def _get_library_name(self) -> str: if sys.platform == "darwin": return "libgopher-orch.dylib" @@ -284,15 +420,11 @@ def _get_platform_package_path(self) -> Optional[str]: def _get_search_paths(self) -> list: paths = [] - # 1. Try platform-specific package first (pip distribution) - platform_path = self._get_platform_package_path() - if platform_path: - paths.append(platform_path) - - # 2. Get the directory containing this module for development fallbacks + # 1. Get the directory containing this module for development fallbacks module_dir = Path(__file__).parent.parent.parent - # Development paths (native/lib in various locations) + # Development paths (native/lib in various locations). Prefer these so + # examples and tests use the library produced by ./build.sh. paths.extend( [ # Project root native/lib @@ -303,6 +435,11 @@ def _get_search_paths(self) -> list: ] ) + # 2. Try platform-specific package (pip distribution) + platform_path = self._get_platform_package_path() + if platform_path: + paths.append(platform_path) + # 3. System paths as last resort if sys.platform == "darwin": paths.extend(["/usr/local/lib", "/opt/homebrew/lib"]) @@ -312,11 +449,28 @@ def _get_search_paths(self) -> list: # Agent functions def agent_create_by_json( - self, provider: str, model: str, server_json: str + self, + provider: str, + model: str, + server_json: str, + runtime_options: Optional[object] = None, ) -> Optional[GopherOrchHandle]: """Create an agent using JSON server configuration.""" if not self._available or self._lib is None: return None + options = self._build_agent_options(runtime_options) + if options is not None: + fn = getattr( + self._lib, "gopher_orch_agent_create_by_json_with_options", None + ) + if fn is None: + raise RuntimeError(self._missing_options_symbol_message()) + return fn( + provider.encode("utf-8"), + model.encode("utf-8"), + server_json.encode("utf-8"), + options.pointer, + ) return self._lib.gopher_orch_agent_create_by_json( provider.encode("utf-8"), model.encode("utf-8"), @@ -324,11 +478,28 @@ def agent_create_by_json( ) def agent_create_by_api_key( - self, provider: str, model: str, api_key: str + self, + provider: str, + model: str, + api_key: str, + runtime_options: Optional[object] = None, ) -> Optional[GopherOrchHandle]: """Create an agent using API key.""" if not self._available or self._lib is None: return None + options = self._build_agent_options(runtime_options) + if options is not None: + fn = getattr( + self._lib, "gopher_orch_agent_create_by_api_key_with_options", None + ) + if fn is None: + raise RuntimeError(self._missing_options_symbol_message()) + return fn( + provider.encode("utf-8"), + model.encode("utf-8"), + api_key.encode("utf-8"), + options.pointer, + ) return self._lib.gopher_orch_agent_create_by_api_key( provider.encode("utf-8"), model.encode("utf-8"), @@ -336,7 +507,12 @@ def agent_create_by_api_key( ) def agent_create_by_server_id( - self, provider: str, model: str, api_key: str, server_id: str + self, + provider: str, + model: str, + api_key: str, + server_id: str, + runtime_options: Optional[object] = None, ) -> Optional[GopherOrchHandle]: """Create an agent scoped to a single MCP server by id. @@ -346,6 +522,20 @@ def agent_create_by_server_id( """ if not self._available or self._lib is None: return None + options = self._build_agent_options(runtime_options) + if options is not None: + fn = getattr( + self._lib, "gopher_orch_agent_create_by_server_id_with_options", None + ) + if fn is None: + raise RuntimeError(self._missing_options_symbol_message()) + return fn( + provider.encode("utf-8"), + model.encode("utf-8"), + api_key.encode("utf-8"), + server_id.encode("utf-8"), + options.pointer, + ) fn = getattr(self._lib, "gopher_orch_agent_create_by_server_id", None) if fn is None: return None @@ -357,7 +547,12 @@ def agent_create_by_server_id( ) def agent_create_by_server_name( - self, provider: str, model: str, api_key: str, server_name: str + self, + provider: str, + model: str, + api_key: str, + server_name: str, + runtime_options: Optional[object] = None, ) -> Optional[GopherOrchHandle]: """Create an agent scoped to a single MCP server by name. @@ -365,6 +560,20 @@ def agent_create_by_server_name( """ if not self._available or self._lib is None: return None + options = self._build_agent_options(runtime_options) + if options is not None: + fn = getattr( + self._lib, "gopher_orch_agent_create_by_server_name_with_options", None + ) + if fn is None: + raise RuntimeError(self._missing_options_symbol_message()) + return fn( + provider.encode("utf-8"), + model.encode("utf-8"), + api_key.encode("utf-8"), + server_name.encode("utf-8"), + options.pointer, + ) fn = getattr(self._lib, "gopher_orch_agent_create_by_server_name", None) if fn is None: return None @@ -376,7 +585,12 @@ def agent_create_by_server_name( ) def agent_create_by_gateway_id( - self, provider: str, model: str, api_key: str, gateway_id: str + self, + provider: str, + model: str, + api_key: str, + gateway_id: str, + runtime_options: Optional[object] = None, ) -> Optional[GopherOrchHandle]: """Create an agent scoped to a single MCP gateway by id. @@ -386,6 +600,20 @@ def agent_create_by_gateway_id( """ if not self._available or self._lib is None: return None + options = self._build_agent_options(runtime_options) + if options is not None: + fn = getattr( + self._lib, "gopher_orch_agent_create_by_gateway_id_with_options", None + ) + if fn is None: + raise RuntimeError(self._missing_options_symbol_message()) + return fn( + provider.encode("utf-8"), + model.encode("utf-8"), + api_key.encode("utf-8"), + gateway_id.encode("utf-8"), + options.pointer, + ) fn = getattr(self._lib, "gopher_orch_agent_create_by_gateway_id", None) if fn is None: return None @@ -397,7 +625,12 @@ def agent_create_by_gateway_id( ) def agent_create_by_gateway_name( - self, provider: str, model: str, api_key: str, gateway_name: str + self, + provider: str, + model: str, + api_key: str, + gateway_name: str, + runtime_options: Optional[object] = None, ) -> Optional[GopherOrchHandle]: """Create an agent scoped to a single MCP gateway by name. @@ -405,6 +638,22 @@ def agent_create_by_gateway_name( """ if not self._available or self._lib is None: return None + options = self._build_agent_options(runtime_options) + if options is not None: + fn = getattr( + self._lib, + "gopher_orch_agent_create_by_gateway_name_with_options", + None, + ) + if fn is None: + raise RuntimeError(self._missing_options_symbol_message()) + return fn( + provider.encode("utf-8"), + model.encode("utf-8"), + api_key.encode("utf-8"), + gateway_name.encode("utf-8"), + options.pointer, + ) fn = getattr(self._lib, "gopher_orch_agent_create_by_gateway_name", None) if fn is None: return None @@ -416,7 +665,11 @@ def agent_create_by_gateway_name( ) def agent_create_by_url( - self, provider: str, model: str, url: str + self, + provider: str, + model: str, + url: str, + runtime_options: Optional[object] = None, ) -> Optional[GopherOrchHandle]: """Create an agent for a single MCP server reachable at a URL. @@ -426,6 +679,17 @@ def agent_create_by_url( """ if not self._available or self._lib is None: return None + options = self._build_agent_options(runtime_options) + if options is not None: + fn = getattr(self._lib, "gopher_orch_agent_create_by_url_with_options", None) + if fn is None: + raise RuntimeError(self._missing_options_symbol_message()) + return fn( + provider.encode("utf-8"), + model.encode("utf-8"), + url.encode("utf-8"), + options.pointer, + ) fn = getattr(self._lib, "gopher_orch_agent_create_by_url", None) if fn is None: return None @@ -435,6 +699,21 @@ def agent_create_by_url( url.encode("utf-8"), ) + def _build_agent_options( + self, runtime_options: Optional[object] + ) -> Optional[_AgentOptionsStorage]: + options = normalize_runtime_options(runtime_options) + if options is None: + return None + return _AgentOptionsStorage(options) + + def _missing_options_symbol_message(self) -> str: + return ( + "The loaded gopher-orch native library does not expose agent runtime " + "options. Rebuild or update gopher-orch before passing access_token " + "or headers." + ) + def agent_run( self, agent: GopherOrchHandle, query: str, timeout_ms: int ) -> Optional[str]: diff --git a/tests/ffi/auth/test_loader.py b/tests/ffi/auth/test_loader.py index 795262d5..279ce747 100644 --- a/tests/ffi/auth/test_loader.py +++ b/tests/ffi/auth/test_loader.py @@ -17,6 +17,7 @@ GopherAuthOptionsPtr, GopherAuthValidationResult, ) +import gopher_mcp_python.ffi.auth.loader as auth_loader class TestGetLibraryName: @@ -70,6 +71,18 @@ def test_includes_native_lib(self): native_lib_found = any("native" in p and "lib" in p for p in paths) assert native_lib_found + def test_prefers_local_native_lib_before_platform_package(self, monkeypatch): + """Test local native/lib is searched before installed native packages.""" + monkeypatch.setattr( + auth_loader, + "_get_platform_package_path", + lambda: "/tmp/gopher-platform-native/lib", + ) + paths = _get_search_paths() + local_path = str(auth_loader.Path.cwd() / "native" / "lib") + platform_path = "/tmp/gopher-platform-native/lib" + assert paths.index(local_path) < paths.index(platform_path) + @patch("platform.system") def test_darwin_includes_homebrew(self, mock_system): """Test macOS includes homebrew paths.""" diff --git a/tests/test_agent_runtime_options.py b/tests/test_agent_runtime_options.py new file mode 100644 index 00000000..00f2c9de --- /dev/null +++ b/tests/test_agent_runtime_options.py @@ -0,0 +1,200 @@ +""" +Tests for public GopherAgent runtime option passthrough. +""" + +import pytest + +import gopher_mcp_python.agent as agent_module +from gopher_mcp_python import AgentError, GopherAgent, GopherAgentConfig + + +class FakeLibrary: + def __init__(self) -> None: + self.calls = [] + + 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 1001 + + def agent_create_by_json( + self, provider, model, server_config, runtime_options=None + ): + self.calls.append( + ("json", provider, model, server_config, runtime_options) + ) + return 1002 + + 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 1003 + + 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 1004 + + 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 1005 + + 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 1006 + + def agent_create_by_url(self, provider, model, url, runtime_options=None): + self.calls.append(("url", provider, model, url, runtime_options)) + return 1007 + + 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",)) + + +@pytest.fixture() +def fake_library(monkeypatch): + fake = FakeLibrary() + monkeypatch.setattr(agent_module, "_initialized", True) + monkeypatch.setattr( + agent_module.GopherOrchLibrary, + "get_instance", + staticmethod(lambda: fake), + ) + return fake + + +def _assert_normalized_options(options, expected_headers): + assert options is not None + assert options.headers == expected_headers + + +def test_create_passes_runtime_options_for_api_key_config(fake_library) -> None: + config = ( + GopherAgentConfig.builder() + .provider("Provider") + .model("model") + .api_key("api-key") + .access_token("abc123") + .build() + ) + + agent = GopherAgent.create(config) + + call = fake_library.calls[0] + assert call[:4] == ("api_key", "Provider", "model", "api-key") + _assert_normalized_options(call[4], {"Authorization": "Bearer abc123"}) + agent.dispose() + + +def test_create_passes_runtime_options_for_server_config(fake_library) -> None: + config = ( + GopherAgentConfig.builder() + .provider("Provider") + .model("model") + .server_config("{}") + .headers({"X-Trace": "trace-1"}) + .build() + ) + + agent = GopherAgent.create(config) + + call = fake_library.calls[0] + assert call[:4] == ("json", "Provider", "model", "{}") + _assert_normalized_options(call[4], {"X-Trace": "trace-1"}) + agent.dispose() + + +def test_create_with_api_key_accepts_runtime_options(fake_library) -> None: + agent = GopherAgent.create_with_api_key( + "Provider", + "model", + "api-key", + {"headers": {"X-Trace": "trace-1"}}, + ) + + call = fake_library.calls[0] + assert call[:4] == ("api_key", "Provider", "model", "api-key") + _assert_normalized_options(call[4], {"X-Trace": "trace-1"}) + agent.dispose() + + +@pytest.mark.parametrize( + "factory, expected", + [ + ( + lambda options: GopherAgent.create_with_server_id( + "Provider", "model", "api-key", "srv-1", options + ), + ("server_id", "Provider", "model", "api-key", "srv-1"), + ), + ( + lambda options: GopherAgent.create_with_server_name( + "Provider", "model", "api-key", "weather", options + ), + ("server_name", "Provider", "model", "api-key", "weather"), + ), + ( + lambda options: GopherAgent.create_with_gateway_id( + "Provider", "model", "api-key", "gw-1", options + ), + ("gateway_id", "Provider", "model", "api-key", "gw-1"), + ), + ( + lambda options: GopherAgent.create_with_gateway_name( + "Provider", "model", "api-key", "main", options + ), + ("gateway_name", "Provider", "model", "api-key", "main"), + ), + ( + lambda options: GopherAgent.create_with_url( + "Provider", "model", "http://127.0.0.1:5001/mcp", options + ), + ("url", "Provider", "model", "http://127.0.0.1:5001/mcp"), + ), + ], +) +def test_direct_factories_pass_runtime_options(fake_library, factory, expected) -> None: + runtime_options = {"access_token": "abc123"} + + agent = factory(runtime_options) + + call = fake_library.calls[0] + assert call[:-1] == expected + assert call[-1] is runtime_options + agent.dispose() + + +def test_runtime_options_native_error_surfaces_as_agent_error(fake_library) -> None: + def fail(provider, model, url, runtime_options=None): + raise RuntimeError("native options symbol missing") + + fake_library.agent_create_by_url = fail + + with pytest.raises(AgentError, match="native options symbol missing"): + GopherAgent.create_with_url( + "Provider", + "model", + "http://127.0.0.1:5001/mcp", + {"access_token": "abc123"}, + ) diff --git a/tests/test_config.py b/tests/test_config.py index de04b914..84c81162 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,7 +1,7 @@ """Tests for GopherAgentConfig.""" import pytest -from gopher_mcp_python.config import GopherAgentConfig +from gopher_mcp_python.config import GopherAgentConfig, GopherAgentRuntimeOptions class TestGopherAgentConfig: @@ -77,3 +77,97 @@ def test_should_not_allow_both_api_key_and_server_config(self): .server_config('{"servers": []}') .build() ) + + def test_should_normalize_empty_runtime_options_to_none(self): + """Test empty runtime options are omitted.""" + config = ( + GopherAgentConfig.builder() + .provider("AnthropicProvider") + .model("claude-3-haiku-20240307") + .api_key("test-key") + .runtime_options({}) + .build() + ) + + assert config.runtime_options is None + + def test_should_create_runtime_options_with_access_token(self): + """Test access_token maps to Authorization bearer header.""" + config = ( + GopherAgentConfig.builder() + .provider("AnthropicProvider") + .model("claude-3-haiku-20240307") + .api_key("test-key") + .access_token("abc123") + .build() + ) + + assert config.runtime_options is not None + assert config.runtime_options.access_token == "abc123" + assert config.runtime_options.headers == {"Authorization": "Bearer abc123"} + + def test_should_create_runtime_options_with_headers(self): + """Test dynamic headers are copied into runtime options.""" + headers = {"X-Test": "one"} + config = ( + GopherAgentConfig.builder() + .provider("AnthropicProvider") + .model("claude-3-haiku-20240307") + .api_key("test-key") + .headers(headers) + .build() + ) + headers["X-Test"] = "mutated" + + assert config.runtime_options is not None + assert config.runtime_options.access_token is None + assert config.runtime_options.headers == {"X-Test": "one"} + + def test_should_merge_access_token_and_headers(self): + """Test access_token and headers can be combined.""" + config = ( + GopherAgentConfig.builder() + .provider("AnthropicProvider") + .model("claude-3-haiku-20240307") + .api_key("test-key") + .runtime_options( + {"access_token": "abc123", "headers": {"X-Test": "one"}} + ) + .build() + ) + + assert config.runtime_options is not None + assert config.runtime_options.headers == { + "Authorization": "Bearer abc123", + "X-Test": "one", + } + + def test_should_prefer_explicit_authorization_header(self): + """Test explicit Authorization header overrides access_token.""" + options = GopherAgentRuntimeOptions( + access_token="abc123", + headers={"Authorization": "Bearer override", "X-Test": "one"}, + ) + config = ( + GopherAgentConfig.builder() + .provider("AnthropicProvider") + .model("claude-3-haiku-20240307") + .api_key("test-key") + .runtime_options(options) + .build() + ) + + assert config.runtime_options is not None + assert config.runtime_options.access_token == "abc123" + assert config.runtime_options.headers["Authorization"] == "Bearer override" + assert config.runtime_options.headers["X-Test"] == "one" + + def test_should_reject_invalid_runtime_header_values(self): + """Test runtime headers must be strings.""" + with pytest.raises(ValueError, match="headers must be a string mapping"): + GopherAgentRuntimeOptions(headers={"X-Test": 1}) + + 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) diff --git a/tests/test_ffi_runtime_options.py b/tests/test_ffi_runtime_options.py new file mode 100644 index 00000000..7b1353d4 --- /dev/null +++ b/tests/test_ffi_runtime_options.py @@ -0,0 +1,119 @@ +""" +Tests for dynamic MCP runtime options at the ctypes FFI boundary. +""" + +import ctypes + +import pytest + +from gopher_mcp_python.ffi.library import ( + GopherOrchAgentOptions, + GopherOrchLibrary, +) + + +def _new_library(fake_lib): + lib = object.__new__(GopherOrchLibrary) + lib._available = True + lib._lib = fake_lib + return lib + + +def _read_options(options_ptr): + options = ctypes.cast( + options_ptr, ctypes.POINTER(GopherOrchAgentOptions) + ).contents + headers = { + options.headers[i].name.decode("utf-8"): options.headers[i].value.decode( + "utf-8" + ) + for i in range(options.header_count) + } + access_token = ( + options.access_token.decode("utf-8") if options.access_token else None + ) + return access_token, headers + + +def test_build_agent_options_maps_access_token_and_headers() -> None: + lib = object.__new__(GopherOrchLibrary) + + storage = lib._build_agent_options( + {"access_token": "abc123", "headers": {"X-Trace": "trace-1"}} + ) + + assert storage is not None + access_token, headers = _read_options(storage.pointer) + assert access_token == "abc123" + assert headers == { + "X-Trace": "trace-1", + "Authorization": "Bearer abc123", + } + + +def test_build_agent_options_normalizes_empty_options_to_none() -> None: + lib = object.__new__(GopherOrchLibrary) + + assert lib._build_agent_options({"headers": {}}) is None + + +def test_agent_create_by_url_uses_existing_symbol_without_runtime_options() -> None: + calls = [] + + class FakeNativeLibrary: + def gopher_orch_agent_create_by_url(self, provider, model, url): + calls.append((provider, model, url)) + return 123 + + lib = _new_library(FakeNativeLibrary()) + + handle = lib.agent_create_by_url("Provider", "model", "http://127.0.0.1/mcp") + + assert handle == 123 + assert calls == [ + (b"Provider", b"model", b"http://127.0.0.1/mcp"), + ] + + +def test_agent_create_by_url_uses_options_symbol_with_runtime_options() -> None: + captured = {} + + class FakeNativeLibrary: + def gopher_orch_agent_create_by_url_with_options( + self, provider, model, url, options_ptr + ): + captured["args"] = (provider, model, url) + captured["options"] = _read_options(options_ptr) + return 456 + + lib = _new_library(FakeNativeLibrary()) + + handle = lib.agent_create_by_url( + "Provider", + "model", + "http://127.0.0.1/mcp", + {"headers": {"Authorization": "Bearer explicit"}}, + ) + + assert handle == 456 + assert captured["args"] == (b"Provider", b"model", b"http://127.0.0.1/mcp") + assert captured["options"] == ( + None, + {"Authorization": "Bearer explicit"}, + ) + + +def test_agent_create_by_url_requires_options_symbol_for_runtime_options() -> None: + class FakeNativeLibrary: + def gopher_orch_agent_create_by_url(self, provider, model, url): + return 123 + + lib = _new_library(FakeNativeLibrary()) + + with pytest.raises(RuntimeError, match="does not expose agent runtime options"): + lib.agent_create_by_url( + "Provider", + "model", + "http://127.0.0.1/mcp", + {"access_token": "abc123"}, + ) diff --git a/tests/test_library_search_paths.py b/tests/test_library_search_paths.py new file mode 100644 index 00000000..a4642a31 --- /dev/null +++ b/tests/test_library_search_paths.py @@ -0,0 +1,21 @@ +"""Tests for the main ctypes native library search order.""" + +import os + +from gopher_mcp_python.ffi.library import GopherOrchLibrary + + +def test_prefers_local_native_lib_before_platform_package(monkeypatch): + """Local builds from ./build.sh should win over installed native packages.""" + lib = object.__new__(GopherOrchLibrary) + monkeypatch.setattr( + lib, + "_get_platform_package_path", + lambda: "/tmp/gopher-platform-native/lib", + ) + + paths = lib._get_search_paths() + + assert paths.index(os.path.join(os.getcwd(), "native", "lib")) < paths.index( + "/tmp/gopher-platform-native/lib" + ) diff --git a/third_party/gopher-orch b/third_party/gopher-orch index 05667fdf..c78ca8cc 160000 --- a/third_party/gopher-orch +++ b/third_party/gopher-orch @@ -1 +1 @@ -Subproject commit 05667fdf7c05d51ccd0aa6abc33545e0a022cf00 +Subproject commit c78ca8cc1fb787d7ba45dbf52b67931cb69f1986