diff --git a/.gitignore b/.gitignore index 00450467..7313472f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ dist/ # Native library directories native/ test-project-api/ +examples/api/test-project-*/ # Python cache __pycache__/ diff --git a/dump-version.sh b/dump-version.sh index 3756d7b8..e84f6467 100755 --- a/dump-version.sh +++ b/dump-version.sh @@ -14,9 +14,13 @@ # 1. Fetch latest version from gopher-orch releases # 2. Validate and determine the target version # 3. Update pyproject.toml (main and platform packages) -# 4. Update __init__.py files -# 5. Update CHANGELOG.md ([Unreleased] -> [X.Y.Z] - date) -# 6. Commit the changes +# 4. Auto-populate CHANGELOG.md [Unreleased] section from: +# - git log of this repo since the previous tag +# - the gopher-orch GitHub release notes for the new version +# (manual entries already in [Unreleased] are preserved and shown first) +# 5. Update __init__.py files and platform packages +# 6. Promote [Unreleased] -> [X.Y.Z] - date +# 7. Commit the changes # # After running this script: # 1. Review the changes: git diff HEAD~1 @@ -148,34 +152,181 @@ if [ "$CURRENT_VERSION" = "$TARGET_VERSION" ]; then fi # ----------------------------------------------------------------------------- -# Step 4: Check [Unreleased] section has content +# Step 4: Build release notes from git log + gopher-orch release notes # ----------------------------------------------------------------------------- echo "" -echo -e "${YELLOW}Step 4: Checking [Unreleased] section...${NC}" +echo -e "${YELLOW}Step 4: Building release notes...${NC}" if [ ! -f "$CHANGELOG_FILE" ]; then echo -e "${RED}Error: $CHANGELOG_FILE not found${NC}" exit 1 fi -# Extract content between [Unreleased] and next ## section -UNRELEASED_CONTENT=$(sed -n '/^## \[Unreleased\]/,/^## \[/p' "$CHANGELOG_FILE" | \ - grep -v "^## \[" | grep -v "^$" | head -20) +# Fetch tags so PREV_TAG resolution is accurate even on shallow clones +git fetch --tags --quiet 2>/dev/null || true -if [ -z "$UNRELEASED_CONTENT" ]; then - echo -e "${YELLOW}Warning: [Unreleased] section in CHANGELOG.md appears empty${NC}" - echo "You may want to add release notes before continuing." - read -p "Continue anyway? (y/N) " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - exit 1 +# Previous Python tag reachable from HEAD. Prefer the highest version tag +# directly on HEAD when release tags are stacked on one commit; otherwise use +# git describe to keep the range anchored to this branch. +PREV_TAG=$(git tag --points-at HEAD --list 'v*' --sort=-v:refname | head -1) +if [ -z "$PREV_TAG" ]; then + PREV_TAG=$(git describe --tags --abbrev=0 --match 'v*' HEAD 2>/dev/null || true) +fi +if [ -n "$PREV_TAG" ]; then + echo -e " Previous Python tag: ${CYAN}$PREV_TAG${NC}" + PY_RANGE="$PREV_TAG..HEAD" +else + echo -e " Previous Python tag: ${YELLOW}none (first release)${NC}" + PY_RANGE="HEAD" +fi + +# Previous gopher-orch version from the package version recorded at the +# previous tag. Extended Python versions are X.Y.Z.E, where X.Y.Z tracks +# gopher-orch. +PREV_GOPHER_ORCH_VERSION="" +if [ -n "$PREV_TAG" ]; then + PREV_PY_VERSION=$(git show "$PREV_TAG:$PYPROJECT_TOML" 2>/dev/null | \ + grep -E '^version\s*=' | head -1 | sed -E 's/.*"([^"]+)".*/\1/') + if echo "$PREV_PY_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?$'; then + PREV_GOPHER_ORCH_VERSION=$(echo "$PREV_PY_VERSION" | \ + sed -E 's/^([0-9]+\.[0-9]+\.[0-9]+)(\.[0-9]+)?$/\1/') + PREV_TAG_BASE=$(echo "${PREV_TAG#v}" | sed -E 's/^([0-9]+\.[0-9]+\.[0-9]+).*/\1/') + if [ "$PREV_GOPHER_ORCH_VERSION" != "$PREV_TAG_BASE" ]; then + echo -e " ${YELLOW}Warning: $PREV_TAG records package version $PREV_PY_VERSION; tag name and recorded version disagree${NC}" + PREV_GOPHER_ORCH_VERSION="" + fi + elif [ -n "$PREV_PY_VERSION" ]; then + echo -e " ${YELLOW}Warning: could not derive previous gopher-orch version from $PREV_TAG:$PYPROJECT_TOML version '$PREV_PY_VERSION'${NC}" + else + echo -e " ${YELLOW}Warning: could not read previous package version from $PREV_TAG:$PYPROJECT_TOML${NC}" fi +fi +if [ -n "$PREV_GOPHER_ORCH_VERSION" ]; then + echo -e " Previous gopher-orch: ${CYAN}v$PREV_GOPHER_ORCH_VERSION${NC}" +else + echo -e " Previous gopher-orch: ${YELLOW}unknown${NC}" +fi +echo -e " New gopher-orch: ${GREEN}v$GOPHER_ORCH_VERSION${NC}" + +if ! grep -q '^## \[Unreleased\]' "$CHANGELOG_FILE"; then + echo -e "${RED}Error: [Unreleased] section not found in $CHANGELOG_FILE${NC}" + exit 1 +fi + +# Preserve any manually-authored entries already under [Unreleased] +MANUAL_CONTENT=$(awk ' + /^## \[Unreleased\]/ { capture = 1; next } + /^## \[/ && capture { capture = 0 } + capture { print } +' "$CHANGELOG_FILE") + +# Collect Python repo commits since previous tag (skip merges + prior release commits) +PY_COMMITS=$(git log --no-merges --pretty=format:'- %s' \ + --invert-grep --grep='^Release version' --grep='^\[release\]' \ + $PY_RANGE 2>/dev/null || true) + +PY_COMMIT_COUNT=0 +if [ -n "$PY_COMMITS" ]; then + PY_COMMIT_COUNT=$(printf '%s\n' "$PY_COMMITS" | wc -l | tr -d ' ') +fi +echo -e " Python commits in range:${GREEN} $PY_COMMIT_COUNT${NC}" +if [ -n "$PREV_TAG" ] && [ "$PY_COMMIT_COUNT" -eq 0 ]; then + echo -e " ${YELLOW}Warning: no Python SDK commits found in $PY_RANGE; SDK changes section will be omitted.${NC}" +fi + +# Extract the "What's Changed" block from the gopher-orch release notes, +# stripping the Build Information preamble and the trailing Full Changelog link. +# Rewrite PR refs and user mentions so this repo's rendered changelog does not +# point #NNN at gopher-mcp-python or ping users from the upstream release. +GOPHER_ORCH_NOTES=$(gh release view "v$GOPHER_ORCH_VERSION" \ + --repo GopherSecurity/gopher-orch \ + --json body -q '.body' 2>/dev/null | \ + awk ' + /^## What.s Changed/ { capture = 1; next } + /^\*\*Full Changelog\*\*/ { capture = 0 } + capture { print } + ' | sed -E \ + -e '/^---$/d' \ + -e 's/#([0-9]+)/https:\/\/github.com\/GopherSecurity\/gopher-orch\/pull\/\1/g' \ + -e 's/@([A-Za-z0-9][A-Za-z0-9-]*)/github.com\/\1/g') + +if [ -n "$GOPHER_ORCH_NOTES" ]; then + echo -e " gopher-orch notes: ${GREEN}fetched${NC}" else - echo -e " ${GREEN}[Unreleased] section has content${NC}" - echo " Preview:" - echo "$UNRELEASED_CONTENT" | head -5 | sed 's/^/ /' + echo -e " gopher-orch notes: ${YELLOW}empty (using link only)${NC}" fi +# Build the new [Unreleased] body +RELEASE_NOTES_FILE=$(mktemp) +CHANGELOG_TMP="${CHANGELOG_FILE}.gen" +trap 'rm -f "$RELEASE_NOTES_FILE" "$CHANGELOG_TMP"' EXIT +{ + if printf '%s' "$MANUAL_CONTENT" | grep -q '[^[:space:]]'; then + printf '%s\n\n' "$MANUAL_CONTENT" + fi + + echo "### Changed" + echo "" + if [ -n "$PREV_GOPHER_ORCH_VERSION" ] && \ + [ "$PREV_GOPHER_ORCH_VERSION" != "$GOPHER_ORCH_VERSION" ]; then + echo "- Bump \`gopher-orch\` native library from v$PREV_GOPHER_ORCH_VERSION to [v$GOPHER_ORCH_VERSION](https://github.com/GopherSecurity/gopher-orch/releases/tag/v$GOPHER_ORCH_VERSION)." + else + echo "- Pin \`gopher-orch\` native library to [v$GOPHER_ORCH_VERSION](https://github.com/GopherSecurity/gopher-orch/releases/tag/v$GOPHER_ORCH_VERSION)." + fi + echo "" + + if [ -n "$PY_COMMITS" ]; then + if [ -n "$PREV_TAG" ]; then + echo "#### SDK changes since $PREV_TAG" + else + echo "#### SDK changes" + fi + echo "" + printf '%s\n' "$PY_COMMITS" + echo "" + fi + + if [ -n "$GOPHER_ORCH_NOTES" ]; then + echo "#### gopher-orch v$GOPHER_ORCH_VERSION highlights" + echo "" + printf '%s\n' "$GOPHER_ORCH_NOTES" + fi +} > "$RELEASE_NOTES_FILE" + +# Splice the generated body in: replace everything between +# "## [Unreleased]" and the next "## [" with the new content. +awk -v notes_file="$RELEASE_NOTES_FILE" ' + BEGIN { + while ((getline line < notes_file) > 0) { + notes = notes (notes ? "\n" : "") line + } + close(notes_file) + } + /^## \[Unreleased\]/ { + print + print "" + print notes + print "" + skipping = 1 + next + } + /^## \[/ && skipping { skipping = 0 } + skipping { next } + { print } +' "$CHANGELOG_FILE" > "$CHANGELOG_TMP" +mv "$CHANGELOG_TMP" "$CHANGELOG_FILE" + +# Recompute UNRELEASED_CONTENT for the eventual commit message +UNRELEASED_CONTENT=$(awk ' + /^## \[Unreleased\]/ { capture = 1; next } + /^## \[/ && capture { capture = 0 } + capture { print } +' "$CHANGELOG_FILE" | sed -e '/^[[:space:]]*$/d') + +echo -e " ${GREEN}[Unreleased] section populated${NC}" +echo " Preview:" +printf '%s\n' "$UNRELEASED_CONTENT" | head -12 | sed 's/^/ /' + # ----------------------------------------------------------------------------- # Step 5: Update version files # ----------------------------------------------------------------------------- diff --git a/examples/api/README.md b/examples/api/README.md new file mode 100644 index 00000000..69c22ae8 --- /dev/null +++ b/examples/api/README.md @@ -0,0 +1,243 @@ +# examples/api — Python SDK examples for the seven `create_by_*` factories + +This directory holds the Python siblings of the C++ SDK examples under +[`gopher-orch/examples/sdk/api/`](../../third_party/gopher-orch/examples/sdk/api/) +and the TypeScript siblings under +[`gopher-mcp-js/examples/api/`](https://github.com/GopherSecurity/gopher-mcp-js/tree/main/examples/api). +Each `.py` file mirrors its `.cc` and `.ts` counterparts one-to-one +and exercises exactly one of the seven `create_by_*` factories the +SDK exposes through `GopherAgent`. + +All examples in this directory resolve their dependencies from the +**PyPI-published** [`gopher-mcp-python`](https://pypi.org/project/gopher-mcp-python/) +package and its matching platform-specific native package — they do +not use the in-tree `gopher_mcp_python/` source or the locally-built +`native/lib/` directory. To work against the in-tree source instead, +see the existing `examples/client_example_json*` pair at the +`examples/` root. + +## File-to-factory mapping + +| C++ reference | Python port | TypeScript port | `GopherAgent` factory | +| ------------------------------ | ------------------------------ | ------------------------------ | ------------------------------ | +| `create_by_api_key.cc` | `create_by_api_key.py` | `create_by_api_key.ts` | `create_with_api_key` | +| `create_by_json.cc` | `create_by_json.py` | `create_by_json.ts` | `create_with_server_config` | +| `create_by_server_id.cc` | `create_by_server_id.py` | `create_by_server_id.ts` | `create_with_server_id` | +| `create_by_server_name.cc` | `create_by_server_name.py` | `create_by_server_name.ts` | `create_with_server_name` | +| `create_by_gateway_id.cc` | `create_by_gateway_id.py` | `create_by_gateway_id.ts` | `create_with_gateway_id` | +| `create_by_gateway_name.cc` | `create_by_gateway_name.py` | `create_by_gateway_name.ts` | `create_with_gateway_name` | +| `create_by_url.cc` | `create_by_url.py` | `create_by_url.ts` | `create_with_url` | + +Each `.py` file ships with a `*_run.sh` wrapper that bootstraps a +fresh virtual environment, installs `gopher-mcp-python` plus the +matching platform native package from PyPI, and forwards positional +arguments to the example as queries. + +## Quick start + +1. Set the env vars your chosen example needs (see the matrix below). + At minimum every example needs `LLM_MODEL` and the LLM + provider's own credentials (`ANTHROPIC_API_KEY` for the default + `AnthropicProvider`): + + ```sh + export LLM_MODEL= + export ANTHROPIC_API_KEY=... + export GOPHER_API_KEY=... # only if your variant needs it + ``` + +2. Run the wrapper. It will detect your platform, create + `examples/api/test-project-/` with a fresh venv inside, + `pip install gopher-mcp-python` plus the matching native package + from PyPI, then run the `.py`: + + ```sh + ./examples/api/create_by_api_key_run.sh "What time is it in Tokyo?" + ``` + + Positional arguments to the wrapper become queries; with no + arguments each example runs a canned query so a first invocation + produces visible output. + +3. To pin a specific SDK version, set `SDK_VERSION` before invoking + a wrapper. Otherwise the latest published version is installed: + + ```sh + SDK_VERSION= ./examples/api/create_by_server_id_run.sh + ``` + +The wrappers are idempotent: each run nukes its `test-project-*` +directory and rebuilds the venv from scratch so a stale install +cannot mask a problem. The `test-project-*` directories are +intentionally ignored by `.gitignore` at the repo root. + +## Manual run (no wrapper) + +If you would rather drive the venv yourself, the `.py` files are +self-contained and run against any environment that has +`gopher-mcp-python` and the matching native package installed: + +```sh +python3 -m venv venv +source venv/bin/activate +pip install gopher-mcp-python gopher-mcp-python-native-darwin-arm64 +export LLM_MODEL= +export ANTHROPIC_API_KEY=... +python examples/api/create_by_api_key.py "What time is it in Tokyo?" +``` + +Substitute the right native package name for your platform; see the +list at [pypi.org/project/gopher-mcp-python](https://pypi.org/project/gopher-mcp-python/). + +## Environment variables per example + +| Example | Required | Optional | +| ------------------------ | ---------------------------------------------------------- | ----------------------- | +| `create_by_api_key` | `GOPHER_API_KEY`, `LLM_MODEL` | `LLM_PROVIDER`, `DEBUG` | +| `create_by_json` | `LLM_MODEL` | `LLM_PROVIDER`, `DEBUG` | +| `create_by_server_id` | `GOPHER_API_KEY`, `GOPHER_MCP_SERVER_ID`, `LLM_MODEL` | `LLM_PROVIDER`, `DEBUG` | +| `create_by_server_name` | `GOPHER_API_KEY`, `GOPHER_MCP_SERVER_NAME`, `LLM_MODEL` | `LLM_PROVIDER`, `DEBUG` | +| `create_by_gateway_id` | `GOPHER_API_KEY`, `GOPHER_MCP_GATEWAY_ID`, `LLM_MODEL` | `LLM_PROVIDER`, `DEBUG` | +| `create_by_gateway_name` | `GOPHER_API_KEY`, `GOPHER_MCP_GATEWAY_NAME`, `LLM_MODEL` | `LLM_PROVIDER`, `DEBUG` | +| `create_by_url` | `GOPHER_MCP_URL`, `LLM_MODEL` | `LLM_PROVIDER`, `DEBUG` | + +The wrappers also recognise: + +- `SDK_VERSION` — pin `gopher-mcp-python` and the platform native + package to a specific PyPI version. Defaults to the latest. +- `ANTHROPIC_API_KEY` — required by the default `AnthropicProvider`; + the wrappers warn if unset but do not fail. + +Notes: + +- `LLM_PROVIDER` defaults to `AnthropicProvider` in every example. +- `LLM_MODEL` has no default; each example refuses to start until + the variable is set rather than calling into the FFI with a + placeholder. This matches the env-var-required path the JS side + picked so the examples never surface a stale or fictional model + identifier. + +## Picking the right factory + +| Factory | Selects | Network call | +| ----------------------------- | --------------------------------------------------------------- | -------------------------------------------------- | +| `create_with_api_key` | Every MCP server the api key owns | `GET /v1/mcp-servers` | +| `create_with_server_config` | Servers described in an inline JSON document | None | +| `create_with_server_id` | One MCP server by id | `GET /v1/mcp-servers?serverId=...` | +| `create_with_server_name` | One MCP server by name | `GET /v1/mcp-servers?serverName=...` | +| `create_with_gateway_id` | All MCP servers under one gateway by id | `GET /v1/mcp-servers?gatewayId=...` | +| `create_with_gateway_name` | All MCP servers under one gateway by name | `GET /v1/mcp-servers?gatewayName=...` | +| `create_with_url` | One MCP server reachable at a known URL | None (synthesised locally to an `http_sse` entry) | + +The table mirrors the C++ canonical reference at +`gopher-orch/docs/Agent.md` ("Simple creation factories" section) +so the Python-side documentation stays aligned with the upstream +C++ docs and the TypeScript port. + +The five routing factories +(`create_with_server_id` / `_server_name` / `_gateway_id` / +`_gateway_name` / `_url`) require a PyPI release that includes the +routing factory native symbols. Earlier releases only expose +`create_with_api_key` and `create_with_server_config`. + +## How the examples find the SDK + +Each `.py` file imports `GopherAgent` from the +`gopher_mcp_python` package installed by the wrapper: + +```python +from gopher_mcp_python import GopherAgent +``` + +Resolution flow: + +- Through a wrapper (`create_by_*_run.sh`): the wrapper creates a + fresh venv in `examples/api/test-project-/`, + `pip install`-s `gopher-mcp-python` plus the matching platform + native package from PyPI, then runs the example. The import + resolves against the just-installed PyPI package, never against + the in-tree `gopher_mcp_python/` source. +- Manual run: the example resolves against whatever + `gopher_mcp_python` is on the active Python's `sys.path` — + whatever you `pip install`-ed into your own venv. + +A downstream consumer copying any of these examples into their own +project does not need to edit the import path — the same `from +gopher_mcp_python import GopherAgent` line works as long as the +package is installed. + +## How the wrappers find the native library + +The native `libgopher-orch.dylib` / `.so` / `.dll` is loaded by the +`ctypes` layer inside `gopher_mcp_python.ffi.library`. With the +PyPI-based wrappers, resolution happens entirely inside the venv: + +1. The wrapper installs `gopher-mcp-python-native--` + alongside the main package; that package ships the native binary + under its own `lib/` directory. +2. `gopher_mcp_python/ffi/library.py` walks `sys.path` looking for + the matching `gopher_mcp_python_native_*` package and uses its + `get_lib_path()` to locate the dylib. +3. `DYLD_LIBRARY_PATH` / `LD_LIBRARY_PATH` are **not** set by the + wrappers — the loader finds the platform package via Python + import semantics rather than a search path. + +If you need to point at a different library location entirely +(for example a locally-built `libgopher-orch.dylib` for testing a +patch), set `GOPHER_MCP_PYTHON_LIBRARY_PATH` before invoking the +wrapper. That env var is checked first by +`gopher_mcp_python/ffi/library.py` and bypasses the platform-package +resolution step. + +## Troubleshooting + +### "Failed to load gopher-mcp-python library" + +The matching platform native package was not installed. The +wrappers compute and install it automatically; if you are running +the `.py` manually, install both: + +```sh +pip install gopher-mcp-python gopher-mcp-python-native-- +``` + +### Permission errors on macOS + +Quarantine flags on a freshly-downloaded dylib can block load: + +```sh +xattr -d com.apple.quarantine "$(python -c 'import gopher_mcp_python_native_darwin_arm64 as n; print(n.get_library_file())')" +``` + +### Routing factory raises `AgentError` against an older PyPI release + +The five routing factories require a `gopher-mcp-python` release that +includes the matching native routing factory symbols. If the wrapper +installs an older version, the higher-level factory raises `AgentError` +because the underlying C symbol is missing. Pin to a release that +contains this feature once it is published: + +```sh +SDK_VERSION= ./examples/api/create_by_server_id_run.sh +``` + +## Cross-reference + +- C++ canonical examples: + `gopher-orch/examples/sdk/api/` (in the `third_party/gopher-orch` + submodule of this repo). +- C++ canonical docs: + `gopher-orch/docs/Agent.md` ("Simple creation factories" + section). +- TypeScript siblings: + [`gopher-mcp-js/examples/api/`](https://github.com/GopherSecurity/gopher-mcp-js/tree/main/examples/api). +- FFI binding layer: + `gopher_mcp_python/ffi/library.py` (`agent_create_by_*` methods). +- High-level wrappers: + `gopher_mcp_python/agent.py` (`GopherAgent.create_with_*` static + methods). +- Contract tests: + `tests/test_agent_create_by.py`. +- Sibling pip-style wrappers (older, two-variant superset): + `examples/pip/` — same venv-bootstrap pattern these wrappers + inherit. diff --git a/examples/api/_run_common.sh b/examples/api/_run_common.sh new file mode 100644 index 00000000..733f722e --- /dev/null +++ b/examples/api/_run_common.sh @@ -0,0 +1,107 @@ +#!/bin/bash + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +SDK_VERSION="${SDK_VERSION:-}" + +detect_platform() { + local os arch + os=$(uname -s | tr '[:upper:]' '[:lower:]') + arch=$(uname -m) + case "$os" in + darwin) PLATFORM="darwin" ;; + linux) PLATFORM="linux" ;; + mingw*|msys*|cygwin*) PLATFORM="win32" ;; + *) echo -e "${RED}Unsupported OS: $os${NC}"; exit 1 ;; + esac + case "$arch" in + x86_64|amd64) ARCH="x64" ;; + arm64|aarch64) ARCH="arm64" ;; + *) echo -e "${RED}Unsupported architecture: $arch${NC}"; exit 1 ;; + esac + NATIVE_PACKAGE="gopher-mcp-python-native-${PLATFORM}-${ARCH}" + echo -e "${CYAN}Detected platform: ${PLATFORM}-${ARCH}${NC}" + echo -e "${CYAN}Native package: ${NATIVE_PACKAGE}${NC}" +} + +print_banner() { + local title="$1" + local border + border="$(printf '%*s' "${#title}" '' | tr ' ' '=')" + echo -e "${GREEN}${border}${NC}" + echo -e "${GREEN}${title}${NC}" + echo -e "${GREEN}${border}${NC}" + echo "" +} + +warn_if_empty() { + local name="$1" + local guidance="$2" + local note="${3:-}" + if [ -z "${!name:-}" ]; then + echo -e "${YELLOW}Warning: ${name} environment variable is not set${NC}" + echo -e "${YELLOW}${guidance}${NC}" + if [ -n "$note" ]; then + echo -e "${YELLOW}${note}${NC}" + fi + echo "" + fi +} + +run_api_example() { + local work_name="$1" + local example_file="$2" + shift 2 + + local work_dir="$SCRIPT_DIR/$work_name" + + echo -e "${YELLOW}Setting up test project at $work_dir...${NC}" + rm -rf "$work_dir" + mkdir -p "$work_dir" + cd "$work_dir" + + echo -e "${YELLOW}Creating virtual environment...${NC}" + python3 -m venv venv + + local activate_script="" + if [ -x "venv/bin/python" ] && [ -f "venv/bin/activate" ]; then + activate_script="venv/bin/activate" + elif [ -x "venv/Scripts/python.exe" ] && [ -f "venv/Scripts/activate" ]; then + activate_script="venv/Scripts/activate" + fi + if [ -z "$activate_script" ]; then + echo -e "${RED}Error: virtualenv creation did not produce a usable Python.${NC}" + echo -e "${YELLOW}Install python3-venv and python3-pip, then rerun this script.${NC}" + exit 1 + fi + + # shellcheck disable=SC1090 + source "$activate_script" + + echo -e "${YELLOW}Installing gopher-mcp-python from PyPI...${NC}" + if [ -n "$SDK_VERSION" ]; then + echo -e "${CYAN}Installing version: $SDK_VERSION${NC}" + python -m pip install --quiet "gopher-mcp-python==$SDK_VERSION" \ + "${NATIVE_PACKAGE}==$SDK_VERSION" + else + echo -e "${CYAN}Installing latest published version${NC}" + python -m pip install --quiet gopher-mcp-python "$NATIVE_PACKAGE" + fi + + echo -e "${CYAN}Installed packages:${NC}" + python -m pip list | grep -i gopher || true + + cp "$SCRIPT_DIR/$example_file" . + + echo "" + echo -e "${YELLOW}Running example...${NC}" + echo "" + python "$example_file" "$@" + + echo "" + echo -e "${GREEN}Example completed${NC}" +} diff --git a/examples/api/create_by_api_key.py b/examples/api/create_by_api_key.py new file mode 100644 index 00000000..2ed67d1a --- /dev/null +++ b/examples/api/create_by_api_key.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +""" +SDK example for GopherAgent.create_with_api_key. + +Python port of gopher-mcp-js/examples/api/create_by_api_key.ts, which +itself ports gopher-orch/examples/sdk/api/create_by_api_key.cc. + +Uses a Gopher API key to fetch the caller's full MCP server inventory +via GET /v1/mcp-servers; the agent gets every server the api key owns +with no extra routing. Smallest of the seven create_by_* examples and +a good first sanity check that the toolchain (pip install +gopher-mcp-python, the matching platform native package, env vars) +is wired correctly. + +Provider defaults to AnthropicProvider; the model is taken from +LLM_MODEL. Override either via env or by editing the constants in +main(). + +Configuration (env vars): + GOPHER_API_KEY Gopher API key for /v1/mcp-servers + LLM_PROVIDER Optional. Defaults to "AnthropicProvider". + LLM_MODEL Required. Model identifier the provider accepts. + DEBUG When set, ctypes prints library-resolution diagnostics. + +Usage: + python3 create_by_api_key.py # built-in query + python3 create_by_api_key.py "query one" "query two" ... # supplied queries +""" + +import os +import sys +import traceback + +from gopher_mcp_python import GopherAgent + +API_KEY_PLACEHOLDER = "{YOUR_GOPHER_API_KEY}" +MODEL_PLACEHOLDER = "{YOUR_LLM_MODEL}" + + +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 main() -> None: + print("=== GopherAgent.create_with_api_key example ===") + print(f"Usage: python3 {sys.argv[0]} [query1] [query2] ...") + print("Env: GOPHER_API_KEY LLM_PROVIDER LLM_MODEL DEBUG") + print("") + + queries = sys.argv[1:] if len(sys.argv) > 1 else ["What time is it in Tokyo?"] + + provider = env_or("LLM_PROVIDER", "AnthropicProvider") + model = env_or("LLM_MODEL", MODEL_PLACEHOLDER) + api_key = env_or("GOPHER_API_KEY", API_KEY_PLACEHOLDER) + + print(f"Provider: {provider}") + model_label = f"{model} (set LLM_MODEL)" if model == MODEL_PLACEHOLDER else model + print(f"Model: {model_label}") + api_key_label = ( + f"{api_key} (set GOPHER_API_KEY)" + if api_key == API_KEY_PLACEHOLDER + else "" + ) + print(f"API key: {api_key_label}") + print(f"Queries: {len(queries)}") + + if model == MODEL_PLACEHOLDER or api_key == API_KEY_PLACEHOLDER: + print( + "\nError: LLM_MODEL and GOPHER_API_KEY must both be set.", + file=sys.stderr, + ) + sys.exit(1) + + print("\nCreating agent via GopherAgent.create_with_api_key...") + agent = GopherAgent.create_with_api_key(provider, model, api_key) + 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(file=sys.stderr) + sys.exit(1) diff --git a/examples/api/create_by_api_key_run.sh b/examples/api/create_by_api_key_run.sh new file mode 100755 index 00000000..9f88bf4a --- /dev/null +++ b/examples/api/create_by_api_key_run.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +# Run the Python SDK example for GopherAgent.create_with_api_key +# against the PyPI-published gopher-mcp-python package. +# +# Set SDK_VERSION to pin to a specific release; otherwise the latest +# published version is installed. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=examples/api/_run_common.sh +source "$SCRIPT_DIR/_run_common.sh" + +detect_platform +print_banner "GopherAgent.create_with_api_key example" + +warn_if_empty "GOPHER_API_KEY" "Set it with: export GOPHER_API_KEY=your_api_key" +warn_if_empty "LLM_MODEL" "Set it with: export LLM_MODEL=" +warn_if_empty "ANTHROPIC_API_KEY" "(Required for the default AnthropicProvider.)" + +run_api_example "test-project-create-by-api-key" "create_by_api_key.py" "$@" diff --git a/examples/api/create_by_gateway_id.py b/examples/api/create_by_gateway_id.py new file mode 100644 index 00000000..2d65c040 --- /dev/null +++ b/examples/api/create_by_gateway_id.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +""" +SDK example for GopherAgent.create_with_gateway_id. + +Python port of gopher-mcp-js/examples/api/create_by_gateway_id.ts, +which itself ports +gopher-orch/examples/sdk/api/create_by_gateway_id.cc. + +Scopes a GopherAgent to a single MCP gateway in the caller's +workspace. Internally this hits the same GET /v1/mcp-servers endpoint +as create_with_api_key under the Bearer api key, but adds the +"?gatewayId={id}" routing query so the response carries the backing +MCP servers for that gateway. Use this when the api key owns several +gateways and the agent should bind to exactly one. + +Provider defaults to AnthropicProvider and the model is taken from +LLM_MODEL so the example stays runnable against any Anthropic-served +model without hardcoding a specific identifier in source. Override +either via env or by editing the constants in main(). + +Configuration (env vars): + GOPHER_API_KEY Gopher API key for /v1/mcp-servers + GOPHER_MCP_GATEWAY_ID MCP gateway id to scope the agent to + LLM_PROVIDER Optional. Defaults to "AnthropicProvider". + LLM_MODEL Required. Model identifier the provider accepts. + DEBUG When set, ctypes prints library-resolution diagnostics. + +Usage: + python3 create_by_gateway_id.py # built-in query + python3 create_by_gateway_id.py "query one" "query two" ... # supplied queries +""" + +import os +import sys +import traceback + +from gopher_mcp_python import GopherAgent + +API_KEY_PLACEHOLDER = "{YOUR_GOPHER_API_KEY}" +GATEWAY_ID_PLACEHOLDER = "{YOUR_MCP_GATEWAY_ID}" +MODEL_PLACEHOLDER = "{YOUR_LLM_MODEL}" + + +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 main() -> None: + print("=== GopherAgent.create_with_gateway_id example ===") + print(f"Usage: python3 {sys.argv[0]} [query1] [query2] ...") + print("Env: GOPHER_API_KEY GOPHER_MCP_GATEWAY_ID LLM_PROVIDER LLM_MODEL DEBUG") + print("") + + queries = sys.argv[1:] if len(sys.argv) > 1 else ["What time is it in Tokyo?"] + + provider = env_or("LLM_PROVIDER", "AnthropicProvider") + model = env_or("LLM_MODEL", MODEL_PLACEHOLDER) + api_key = env_or("GOPHER_API_KEY", API_KEY_PLACEHOLDER) + gateway_id = env_or("GOPHER_MCP_GATEWAY_ID", GATEWAY_ID_PLACEHOLDER) + + print(f"Provider: {provider}") + model_label = f"{model} (set LLM_MODEL)" if model == MODEL_PLACEHOLDER else model + print(f"Model: {model_label}") + api_key_label = ( + f"{api_key} (set GOPHER_API_KEY)" + if api_key == API_KEY_PLACEHOLDER + else "" + ) + print(f"API key: {api_key_label}") + gateway_id_label = ( + f"{gateway_id} (set GOPHER_MCP_GATEWAY_ID)" + if gateway_id == GATEWAY_ID_PLACEHOLDER + else gateway_id + ) + print(f"MCP gateway id: {gateway_id_label}") + print(f"Queries: {len(queries)}") + + if ( + model == MODEL_PLACEHOLDER + or api_key == API_KEY_PLACEHOLDER + or gateway_id == GATEWAY_ID_PLACEHOLDER + ): + print( + "\nError: LLM_MODEL, GOPHER_API_KEY, and GOPHER_MCP_GATEWAY_ID " + "must all be set.", + file=sys.stderr, + ) + sys.exit(1) + + print("\nCreating agent via GopherAgent.create_with_gateway_id...") + agent = GopherAgent.create_with_gateway_id(provider, model, api_key, gateway_id) + 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(file=sys.stderr) + sys.exit(1) diff --git a/examples/api/create_by_gateway_id_run.sh b/examples/api/create_by_gateway_id_run.sh new file mode 100755 index 00000000..9a167cb4 --- /dev/null +++ b/examples/api/create_by_gateway_id_run.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +# Run the Python SDK example for GopherAgent.create_with_gateway_id +# against the PyPI-published gopher-mcp-python package. +# +# Set SDK_VERSION to pin to a specific release; otherwise the latest +# published version is installed. The routing factories require a +# release that includes the native routing factory symbols. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=examples/api/_run_common.sh +source "$SCRIPT_DIR/_run_common.sh" + +detect_platform +print_banner "GopherAgent.create_with_gateway_id example" + +warn_if_empty "GOPHER_API_KEY" "Set it with: export GOPHER_API_KEY=your_api_key" +warn_if_empty "GOPHER_MCP_GATEWAY_ID" "Set it with: export GOPHER_MCP_GATEWAY_ID=gw-..." +warn_if_empty "LLM_MODEL" "Set it with: export LLM_MODEL=" +warn_if_empty "ANTHROPIC_API_KEY" "(Required for the default AnthropicProvider.)" + +run_api_example "test-project-create-by-gateway-id" "create_by_gateway_id.py" "$@" diff --git a/examples/api/create_by_gateway_name.py b/examples/api/create_by_gateway_name.py new file mode 100644 index 00000000..af03ab82 --- /dev/null +++ b/examples/api/create_by_gateway_name.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +""" +SDK example for GopherAgent.create_with_gateway_name. + +Python port of gopher-mcp-js/examples/api/create_by_gateway_name.ts, +which itself ports +gopher-orch/examples/sdk/api/create_by_gateway_name.cc. + +Scopes a GopherAgent to a single MCP gateway in the caller's workspace +by human-readable name. Internally this hits the same GET +/v1/mcp-servers endpoint as create_with_api_key under the Bearer api +key, but adds the "?gatewayName={name}" routing query so the response +carries the backing MCP servers for that gateway. Use this when the +api key owns several gateways and the agent should bind to exactly +one identified by name rather than id. + +Provider defaults to AnthropicProvider; the model is taken from +LLM_MODEL. Override either via env or by editing the constants in +main(). + +Configuration (env vars): + GOPHER_API_KEY Gopher API key for /v1/mcp-servers + GOPHER_MCP_GATEWAY_NAME MCP gateway name to scope the agent to + LLM_PROVIDER Optional. Defaults to "AnthropicProvider". + LLM_MODEL Required. Model identifier the provider accepts. + DEBUG When set, ctypes prints library-resolution diagnostics. + +Usage: + python3 create_by_gateway_name.py # built-in query + python3 create_by_gateway_name.py "query one" "query two" ... # supplied queries +""" + +import os +import sys +import traceback + +from gopher_mcp_python import GopherAgent + +API_KEY_PLACEHOLDER = "{YOUR_GOPHER_API_KEY}" +GATEWAY_NAME_PLACEHOLDER = "{YOUR_MCP_GATEWAY_NAME}" +MODEL_PLACEHOLDER = "{YOUR_LLM_MODEL}" + + +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 main() -> None: + print("=== GopherAgent.create_with_gateway_name example ===") + print(f"Usage: python3 {sys.argv[0]} [query1] [query2] ...") + print("Env: GOPHER_API_KEY GOPHER_MCP_GATEWAY_NAME LLM_PROVIDER LLM_MODEL DEBUG") + print("") + + queries = sys.argv[1:] if len(sys.argv) > 1 else ["What time is it in Tokyo?"] + + provider = env_or("LLM_PROVIDER", "AnthropicProvider") + model = env_or("LLM_MODEL", MODEL_PLACEHOLDER) + api_key = env_or("GOPHER_API_KEY", API_KEY_PLACEHOLDER) + gateway_name = env_or("GOPHER_MCP_GATEWAY_NAME", GATEWAY_NAME_PLACEHOLDER) + + print(f"Provider: {provider}") + model_label = f"{model} (set LLM_MODEL)" if model == MODEL_PLACEHOLDER else model + print(f"Model: {model_label}") + api_key_label = ( + f"{api_key} (set GOPHER_API_KEY)" + if api_key == API_KEY_PLACEHOLDER + else "" + ) + print(f"API key: {api_key_label}") + gateway_name_label = ( + f"{gateway_name} (set GOPHER_MCP_GATEWAY_NAME)" + if gateway_name == GATEWAY_NAME_PLACEHOLDER + else gateway_name + ) + print(f"MCP gateway name: {gateway_name_label}") + print(f"Queries: {len(queries)}") + + if ( + model == MODEL_PLACEHOLDER + or api_key == API_KEY_PLACEHOLDER + or gateway_name == GATEWAY_NAME_PLACEHOLDER + ): + print( + "\nError: LLM_MODEL, GOPHER_API_KEY, and GOPHER_MCP_GATEWAY_NAME " + "must all be set.", + file=sys.stderr, + ) + sys.exit(1) + + print("\nCreating agent via GopherAgent.create_with_gateway_name...") + agent = GopherAgent.create_with_gateway_name(provider, model, api_key, gateway_name) + 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(file=sys.stderr) + sys.exit(1) diff --git a/examples/api/create_by_gateway_name_run.sh b/examples/api/create_by_gateway_name_run.sh new file mode 100755 index 00000000..101e66f1 --- /dev/null +++ b/examples/api/create_by_gateway_name_run.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +# Run the Python SDK example for GopherAgent.create_with_gateway_name +# against the PyPI-published gopher-mcp-python package. +# +# Set SDK_VERSION to pin to a specific release; otherwise the latest +# published version is installed. The routing factories require a +# release that includes the native routing factory symbols. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=examples/api/_run_common.sh +source "$SCRIPT_DIR/_run_common.sh" + +detect_platform +print_banner "GopherAgent.create_with_gateway_name example" + +warn_if_empty "GOPHER_API_KEY" "Set it with: export GOPHER_API_KEY=your_api_key" +warn_if_empty "GOPHER_MCP_GATEWAY_NAME" "Set it with: export GOPHER_MCP_GATEWAY_NAME=my-gateway" +warn_if_empty "LLM_MODEL" "Set it with: export LLM_MODEL=" +warn_if_empty "ANTHROPIC_API_KEY" "(Required for the default AnthropicProvider.)" + +run_api_example "test-project-create-by-gateway-name" "create_by_gateway_name.py" "$@" diff --git a/examples/api/create_by_json.py b/examples/api/create_by_json.py new file mode 100644 index 00000000..6b19e339 --- /dev/null +++ b/examples/api/create_by_json.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +""" +SDK example for GopherAgent.create_with_server_config. + +Python port of gopher-mcp-js/examples/api/create_by_json.ts, which +itself ports gopher-orch/examples/sdk/api/create_by_json.cc. + +Builds a GopherAgent from an inline server JSON document, skipping the +remote /v1/mcp-servers fetch that create_with_api_key performs. Useful +when the caller already knows which MCP servers to bind to and wants +to skip the round-trip; the inline payload follows the +{ succeeded, code, message, data: { servers: [...] } } shape that +ConfigLoader on the C++ side accepts. + +Provider defaults to AnthropicProvider; the model is taken from +LLM_MODEL. Edit SERVER_CONFIG below to point at your own MCP servers. + +Configuration (env vars): + LLM_PROVIDER Optional. Defaults to "AnthropicProvider". + LLM_MODEL Required. Model identifier the provider accepts. + DEBUG When set, ctypes prints library-resolution diagnostics. + +Usage: + python3 create_by_json.py # built-in query + python3 create_by_json.py "query one" "query two" ... # supplied queries +""" + +import json +import os +import sys +import traceback + +from gopher_mcp_python import GopherAgent + +MODEL_PLACEHOLDER = "{YOUR_LLM_MODEL}" + +SERVER_CONFIG = json.dumps( + { + "succeeded": True, + "code": 200000000, + "message": "success", + "data": { + "servers": [ + { + "version": "2025-01-09", + "serverId": "1877234567890123456", + "name": "gopher-auth-server", + "transport": "http_sse", + "config": { + "url": "http://127.0.0.1:3001/rpc", + "headers": {}, + }, + "connectTimeout": 5000, + "requestTimeout": 30000, + } + ] + }, + } +) + + +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 main() -> None: + print("=== GopherAgent.create_with_server_config example ===") + print(f"Usage: python3 {sys.argv[0]} [query1] [query2] ...") + print("Env: LLM_PROVIDER LLM_MODEL DEBUG") + print("") + + queries = sys.argv[1:] if len(sys.argv) > 1 else ["What time is it in Tokyo?"] + + provider = env_or("LLM_PROVIDER", "AnthropicProvider") + model = env_or("LLM_MODEL", MODEL_PLACEHOLDER) + + print(f"Provider: {provider}") + model_label = f"{model} (set LLM_MODEL)" if model == MODEL_PLACEHOLDER else model + print(f"Model: {model_label}") + print(f"Queries: {len(queries)}") + + if model == MODEL_PLACEHOLDER: + print("\nError: LLM_MODEL must be set.", file=sys.stderr) + sys.exit(1) + + print("\nCreating agent via GopherAgent.create_with_server_config...") + agent = GopherAgent.create_with_server_config(provider, model, SERVER_CONFIG) + 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(file=sys.stderr) + sys.exit(1) diff --git a/examples/api/create_by_json_run.sh b/examples/api/create_by_json_run.sh new file mode 100755 index 00000000..1b61a5e6 --- /dev/null +++ b/examples/api/create_by_json_run.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +# Run the Python SDK example for GopherAgent.create_with_server_config +# against the PyPI-published gopher-mcp-python package. +# +# Set SDK_VERSION to pin to a specific release; otherwise the latest +# published version is installed. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=examples/api/_run_common.sh +source "$SCRIPT_DIR/_run_common.sh" + +detect_platform +print_banner "GopherAgent.create_with_server_config example" + +warn_if_empty "LLM_MODEL" "Set it with: export LLM_MODEL=" +warn_if_empty "ANTHROPIC_API_KEY" "(Required for the default AnthropicProvider.)" + +run_api_example "test-project-create-by-json" "create_by_json.py" "$@" diff --git a/examples/api/create_by_server_id.py b/examples/api/create_by_server_id.py new file mode 100644 index 00000000..9f698f23 --- /dev/null +++ b/examples/api/create_by_server_id.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +""" +SDK example for GopherAgent.create_with_server_id. + +Python port of gopher-mcp-js/examples/api/create_by_server_id.ts, which +itself ports gopher-orch/examples/sdk/api/create_by_server_id.cc. + +Scopes a GopherAgent to a single MCP server in the caller's workspace +by id. Internally this hits the same GET /v1/mcp-servers endpoint as +create_with_api_key under the Bearer api key, but adds the +"?serverId={id}" routing query so the response carries only the +matching server entry. Use this when the api key owns several MCP +servers but the agent should bind to exactly one. + +Provider defaults to AnthropicProvider; the model is taken from +LLM_MODEL. Override either via env or by editing the constants in +main(). + +Configuration (env vars): + GOPHER_API_KEY Gopher API key for /v1/mcp-servers + GOPHER_MCP_SERVER_ID MCP server id to scope the agent to + LLM_PROVIDER Optional. Defaults to "AnthropicProvider". + LLM_MODEL Required. Model identifier the provider accepts. + DEBUG When set, ctypes prints library-resolution diagnostics. + +Usage: + python3 create_by_server_id.py # built-in query + python3 create_by_server_id.py "query one" "query two" ... # supplied queries +""" + +import os +import sys +import traceback + +from gopher_mcp_python import GopherAgent + +API_KEY_PLACEHOLDER = "{YOUR_GOPHER_API_KEY}" +SERVER_ID_PLACEHOLDER = "{YOUR_MCP_SERVER_ID}" +MODEL_PLACEHOLDER = "{YOUR_LLM_MODEL}" + + +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 main() -> None: + print("=== GopherAgent.create_with_server_id example ===") + print(f"Usage: python3 {sys.argv[0]} [query1] [query2] ...") + print("Env: GOPHER_API_KEY GOPHER_MCP_SERVER_ID LLM_PROVIDER LLM_MODEL DEBUG") + print("") + + queries = sys.argv[1:] if len(sys.argv) > 1 else ["What time is it in Tokyo?"] + + provider = env_or("LLM_PROVIDER", "AnthropicProvider") + model = env_or("LLM_MODEL", MODEL_PLACEHOLDER) + api_key = env_or("GOPHER_API_KEY", API_KEY_PLACEHOLDER) + server_id = env_or("GOPHER_MCP_SERVER_ID", SERVER_ID_PLACEHOLDER) + + print(f"Provider: {provider}") + model_label = f"{model} (set LLM_MODEL)" if model == MODEL_PLACEHOLDER else model + print(f"Model: {model_label}") + api_key_label = ( + f"{api_key} (set GOPHER_API_KEY)" + if api_key == API_KEY_PLACEHOLDER + else "" + ) + print(f"API key: {api_key_label}") + server_id_label = ( + f"{server_id} (set GOPHER_MCP_SERVER_ID)" + if server_id == SERVER_ID_PLACEHOLDER + else server_id + ) + print(f"MCP server id: {server_id_label}") + print(f"Queries: {len(queries)}") + + if ( + model == MODEL_PLACEHOLDER + or api_key == API_KEY_PLACEHOLDER + or server_id == SERVER_ID_PLACEHOLDER + ): + print( + "\nError: LLM_MODEL, GOPHER_API_KEY, and GOPHER_MCP_SERVER_ID " + "must all be set.", + file=sys.stderr, + ) + sys.exit(1) + + print("\nCreating agent via GopherAgent.create_with_server_id...") + agent = GopherAgent.create_with_server_id(provider, model, api_key, server_id) + 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(file=sys.stderr) + sys.exit(1) diff --git a/examples/api/create_by_server_id_run.sh b/examples/api/create_by_server_id_run.sh new file mode 100755 index 00000000..1aacabde --- /dev/null +++ b/examples/api/create_by_server_id_run.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +# Run the Python SDK example for GopherAgent.create_with_server_id +# against the PyPI-published gopher-mcp-python package. +# +# Set SDK_VERSION to pin to a specific release; otherwise the latest +# published version is installed. The routing factories require a +# release that includes the native routing factory symbols. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=examples/api/_run_common.sh +source "$SCRIPT_DIR/_run_common.sh" + +detect_platform +print_banner "GopherAgent.create_with_server_id example" + +warn_if_empty "GOPHER_API_KEY" "Set it with: export GOPHER_API_KEY=your_api_key" +warn_if_empty "GOPHER_MCP_SERVER_ID" "Set it with: export GOPHER_MCP_SERVER_ID=srv-..." +warn_if_empty "LLM_MODEL" "Set it with: export LLM_MODEL=" +warn_if_empty "ANTHROPIC_API_KEY" "(Required for the default AnthropicProvider.)" + +run_api_example "test-project-create-by-server-id" "create_by_server_id.py" "$@" diff --git a/examples/api/create_by_server_name.py b/examples/api/create_by_server_name.py new file mode 100644 index 00000000..9dbf48bb --- /dev/null +++ b/examples/api/create_by_server_name.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +""" +SDK example for GopherAgent.create_with_server_name. + +Python port of gopher-mcp-js/examples/api/create_by_server_name.ts, +which itself ports +gopher-orch/examples/sdk/api/create_by_server_name.cc. + +Scopes a GopherAgent to a single MCP server in the caller's workspace +by human-readable name. Internally this hits the same GET +/v1/mcp-servers endpoint as create_with_api_key under the Bearer api +key, but adds the "?serverName={name}" routing query so the response +carries only the matching server entry. Use this when the api key +owns several MCP servers and the agent should bind to exactly one +identified by name rather than id. + +Provider defaults to AnthropicProvider; the model is taken from +LLM_MODEL. Override either via env or by editing the constants in +main(). + +Configuration (env vars): + GOPHER_API_KEY Gopher API key for /v1/mcp-servers + GOPHER_MCP_SERVER_NAME MCP server name to scope the agent to + LLM_PROVIDER Optional. Defaults to "AnthropicProvider". + LLM_MODEL Required. Model identifier the provider accepts. + DEBUG When set, ctypes prints library-resolution diagnostics. + +Usage: + python3 create_by_server_name.py # built-in query + python3 create_by_server_name.py "query one" "query two" ... # supplied queries +""" + +import os +import sys +import traceback + +from gopher_mcp_python import GopherAgent + +API_KEY_PLACEHOLDER = "{YOUR_GOPHER_API_KEY}" +SERVER_NAME_PLACEHOLDER = "{YOUR_MCP_SERVER_NAME}" +MODEL_PLACEHOLDER = "{YOUR_LLM_MODEL}" + + +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 main() -> None: + print("=== GopherAgent.create_with_server_name example ===") + print(f"Usage: python3 {sys.argv[0]} [query1] [query2] ...") + print("Env: GOPHER_API_KEY GOPHER_MCP_SERVER_NAME LLM_PROVIDER LLM_MODEL DEBUG") + print("") + + queries = sys.argv[1:] if len(sys.argv) > 1 else ["What time is it in Tokyo?"] + + provider = env_or("LLM_PROVIDER", "AnthropicProvider") + model = env_or("LLM_MODEL", MODEL_PLACEHOLDER) + api_key = env_or("GOPHER_API_KEY", API_KEY_PLACEHOLDER) + server_name = env_or("GOPHER_MCP_SERVER_NAME", SERVER_NAME_PLACEHOLDER) + + print(f"Provider: {provider}") + model_label = f"{model} (set LLM_MODEL)" if model == MODEL_PLACEHOLDER else model + print(f"Model: {model_label}") + api_key_label = ( + f"{api_key} (set GOPHER_API_KEY)" + if api_key == API_KEY_PLACEHOLDER + else "" + ) + print(f"API key: {api_key_label}") + server_name_label = ( + f"{server_name} (set GOPHER_MCP_SERVER_NAME)" + if server_name == SERVER_NAME_PLACEHOLDER + else server_name + ) + print(f"MCP server name: {server_name_label}") + print(f"Queries: {len(queries)}") + + if ( + model == MODEL_PLACEHOLDER + or api_key == API_KEY_PLACEHOLDER + or server_name == SERVER_NAME_PLACEHOLDER + ): + print( + "\nError: LLM_MODEL, GOPHER_API_KEY, and GOPHER_MCP_SERVER_NAME " + "must all be set.", + file=sys.stderr, + ) + sys.exit(1) + + print("\nCreating agent via GopherAgent.create_with_server_name...") + agent = GopherAgent.create_with_server_name(provider, model, api_key, server_name) + 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(file=sys.stderr) + sys.exit(1) diff --git a/examples/api/create_by_server_name_run.sh b/examples/api/create_by_server_name_run.sh new file mode 100755 index 00000000..04fa44ba --- /dev/null +++ b/examples/api/create_by_server_name_run.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +# Run the Python SDK example for GopherAgent.create_with_server_name +# against the PyPI-published gopher-mcp-python package. +# +# Set SDK_VERSION to pin to a specific release; otherwise the latest +# published version is installed. The routing factories require a +# release that includes the native routing factory symbols. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=examples/api/_run_common.sh +source "$SCRIPT_DIR/_run_common.sh" + +detect_platform +print_banner "GopherAgent.create_with_server_name example" + +warn_if_empty "GOPHER_API_KEY" "Set it with: export GOPHER_API_KEY=your_api_key" +warn_if_empty "GOPHER_MCP_SERVER_NAME" "Set it with: export GOPHER_MCP_SERVER_NAME=my-server" +warn_if_empty "LLM_MODEL" "Set it with: export LLM_MODEL=" +warn_if_empty "ANTHROPIC_API_KEY" "(Required for the default AnthropicProvider.)" + +run_api_example "test-project-create-by-server-name" "create_by_server_name.py" "$@" diff --git a/examples/api/create_by_url.py b/examples/api/create_by_url.py new file mode 100644 index 00000000..0feb71d6 --- /dev/null +++ b/examples/api/create_by_url.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +""" +SDK example for GopherAgent.create_with_url. + +Python port of gopher-mcp-js/examples/api/create_by_url.ts, which +itself ports gopher-orch/examples/sdk/api/create_by_url.cc. + +Builds a GopherAgent from a single MCP server URL, skipping the +remote /v1/mcp-servers fetch that create_with_api_key performs and +the inline JSON shape that create_with_server_config requires. +Internally the factory synthesises an http_sse server entry around +the URL and delegates to create_by_json. Use this for local +development or one-off endpoints where the operator already knows +the URL. + +Provider defaults to AnthropicProvider; the model is taken from +LLM_MODEL. Override either via env or by editing the constants in +main(). + +Configuration (env vars): + GOPHER_MCP_URL Full URL of the MCP server (e.g. http://127.0.0.1:8080/mcp) + LLM_PROVIDER Optional. Defaults to "AnthropicProvider". + LLM_MODEL Required. Model identifier the provider accepts. + DEBUG When set, ctypes prints library-resolution diagnostics. + +Usage: + python3 create_by_url.py # built-in query + python3 create_by_url.py "query one" "query two" ... # supplied queries +""" + +import os +import sys +import traceback + +from gopher_mcp_python import GopherAgent + +URL_PLACEHOLDER = "{YOUR_MCP_URL}" +MODEL_PLACEHOLDER = "{YOUR_LLM_MODEL}" + + +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 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("") + + queries = sys.argv[1:] if len(sys.argv) > 1 else ["What time is it in Tokyo?"] + + provider = env_or("LLM_PROVIDER", "AnthropicProvider") + model = env_or("LLM_MODEL", MODEL_PLACEHOLDER) + url = env_or("GOPHER_MCP_URL", URL_PLACEHOLDER) + + 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(f"Queries: {len(queries)}") + + if model == MODEL_PLACEHOLDER or url == URL_PLACEHOLDER: + print( + "\nError: LLM_MODEL and GOPHER_MCP_URL must both be set.", + file=sys.stderr, + ) + sys.exit(1) + + print("\nCreating agent via GopherAgent.create_with_url...") + agent = GopherAgent.create_with_url(provider, model, url) + 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(file=sys.stderr) + sys.exit(1) diff --git a/examples/api/create_by_url_run.sh b/examples/api/create_by_url_run.sh new file mode 100755 index 00000000..0aae9406 --- /dev/null +++ b/examples/api/create_by_url_run.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +# 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. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=examples/api/_run_common.sh +source "$SCRIPT_DIR/_run_common.sh" + +detect_platform +print_banner "GopherAgent.create_with_url example" + +warn_if_empty "GOPHER_MCP_URL" "Set it with: export GOPHER_MCP_URL=http://127.0.0.1:8080/mcp" +warn_if_empty "LLM_MODEL" "Set it with: export LLM_MODEL=" +warn_if_empty "ANTHROPIC_API_KEY" "(Required for the default AnthropicProvider.)" + +run_api_example "test-project-create-by-url" "create_by_url.py" "$@" diff --git a/examples/client_example_json.py b/examples/client_example_json.py index efda8ea5..52c0103c 100755 --- a/examples/client_example_json.py +++ b/examples/client_example_json.py @@ -8,7 +8,6 @@ from gopher_mcp_python import GopherAgent - # Server configuration for local MCP servers SERVER_CONFIG = json.dumps( { diff --git a/examples/pip/client_example_json.py b/examples/pip/client_example_json.py index f5060f97..f2df6fe5 100755 --- a/examples/pip/client_example_json.py +++ b/examples/pip/client_example_json.py @@ -12,7 +12,6 @@ from gopher_mcp_python import GopherAgent - # Server configuration for local MCP servers SERVER_CONFIG = json.dumps( { diff --git a/gopher_mcp_python/__init__.py b/gopher_mcp_python/__init__.py index a31dd580..235e51e8 100644 --- a/gopher_mcp_python/__init__.py +++ b/gopher_mcp_python/__init__.py @@ -32,7 +32,7 @@ TimeoutError, ) from gopher_mcp_python.server_config import ServerConfig -from gopher_mcp_python.ffi import GopherOrchLibrary +from gopher_mcp_python.ffi import GopherOrchLibrary, GopherOrchHandle # Auth module re-exports from gopher_mcp_python.ffi.auth import ( @@ -68,6 +68,7 @@ "TimeoutError", # FFI "GopherOrchLibrary", + "GopherOrchHandle", # Auth "GopherAuthError", "ValidationResult", diff --git a/gopher_mcp_python/agent.py b/gopher_mcp_python/agent.py index a1614e72..3e74b486 100644 --- a/gopher_mcp_python/agent.py +++ b/gopher_mcp_python/agent.py @@ -27,14 +27,13 @@ """ import atexit -from typing import Optional +from typing import Callable, Optional from gopher_mcp_python.config import GopherAgentConfig from gopher_mcp_python.result import AgentResult, AgentResultStatus from gopher_mcp_python.errors import AgentError, TimeoutError from gopher_mcp_python.ffi import GopherOrchLibrary, GopherOrchHandle - _initialized = False _cleanup_handler_registered = False @@ -183,6 +182,162 @@ def create_with_server_config( .build() ) + @staticmethod + def create_with_server_id( + provider: str, model: str, api_key: str, server_id: str + ) -> "GopherAgent": + """ + Create a new GopherAgent scoped to a single MCP server by id. + + Fetches server config from the Gopher API using the Bearer api key, + appending "?serverId={server_id}" so the response carries only the + matching MCP server entry. + + Args: + provider: Provider name (e.g., "AnthropicProvider") + model: Model identifier accepted by the chosen provider + api_key: Gopher API key + server_id: MCP server id to scope the agent to + + Returns: + GopherAgent instance + """ + return GopherAgent._create_from_ffi( + lambda lib: lib.agent_create_by_server_id( + provider, model, api_key, server_id + ) + ) + + @staticmethod + def create_with_server_name( + provider: str, model: str, api_key: str, server_name: str + ) -> "GopherAgent": + """ + Create a new GopherAgent scoped to a single MCP server by name. + + Fetches server config from the Gopher API using the Bearer api key, + appending "?serverName={server_name}" so the response carries only + the matching MCP server entry. + + Args: + provider: Provider name (e.g., "AnthropicProvider") + model: Model identifier accepted by the chosen provider + api_key: Gopher API key + server_name: MCP server name to scope the agent to + + Returns: + GopherAgent instance + """ + return GopherAgent._create_from_ffi( + lambda lib: lib.agent_create_by_server_name( + provider, model, api_key, server_name + ) + ) + + @staticmethod + def create_with_gateway_id( + provider: str, model: str, api_key: str, gateway_id: str + ) -> "GopherAgent": + """ + Create a new GopherAgent scoped to a single MCP gateway by id. + + Fetches server config from the Gopher API using the Bearer api key, + appending "?gatewayId={gateway_id}" so the response carries the + backing MCP servers for that gateway. + + Args: + provider: Provider name (e.g., "AnthropicProvider") + model: Model identifier accepted by the chosen provider + api_key: Gopher API key + gateway_id: MCP gateway id to scope the agent to + + Returns: + GopherAgent instance + """ + return GopherAgent._create_from_ffi( + lambda lib: lib.agent_create_by_gateway_id( + provider, model, api_key, gateway_id + ) + ) + + @staticmethod + def create_with_gateway_name( + provider: str, model: str, api_key: str, gateway_name: str + ) -> "GopherAgent": + """ + Create a new GopherAgent scoped to a single MCP gateway by name. + + Fetches server config from the Gopher API using the Bearer api key, + appending "?gatewayName={gateway_name}" so the response carries the + backing MCP servers for that gateway. + + Args: + provider: Provider name (e.g., "AnthropicProvider") + model: Model identifier accepted by the chosen provider + api_key: Gopher API key + gateway_name: MCP gateway name to scope the agent to + + Returns: + GopherAgent instance + """ + return GopherAgent._create_from_ffi( + lambda lib: lib.agent_create_by_gateway_name( + provider, model, api_key, gateway_name + ) + ) + + @staticmethod + def create_with_url(provider: str, model: str, url: str) -> "GopherAgent": + """ + Create a new GopherAgent for a single MCP server reachable at a URL. + + Skips the remote config fetch entirely: synthesises an http_sse + server entry around the URL and delegates to create_by_json on the + native side. Useful for local development or one-off endpoints where + the operator already knows the URL. + + Args: + 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") + + Returns: + GopherAgent instance + """ + return GopherAgent._create_from_ffi( + lambda lib: lib.agent_create_by_url(provider, model, url) + ) + + @staticmethod + def _create_from_ffi( + create_handle: Callable[[GopherOrchLibrary], Optional[GopherOrchHandle]], + ) -> "GopherAgent": + """ + Shared handle-creation pump for factories that bypass GopherAgentConfig. + + Ensures the native library is initialised, invokes the supplied FFI + callable, and translates a null handle return into AgentError using + the same last_error / clear_error contract as create(). + """ + if not _initialized: + GopherAgent.init() + + lib = GopherOrchLibrary.get_instance() + if lib is None: + raise AgentError("Native library not available") + + try: + handle = create_handle(lib) + 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 "Failed to create agent") + + return GopherAgent(handle) + def run(self, query: str, timeout_ms: int = 60000) -> str: """ Run a query against the agent. diff --git a/gopher_mcp_python/config.py b/gopher_mcp_python/config.py index 8a4befe5..89e6a362 100644 --- a/gopher_mcp_python/config.py +++ b/gopher_mcp_python/config.py @@ -9,10 +9,20 @@ class GopherAgentConfig: """ - Immutable configuration for GopherAgent. + Immutable configuration for GopherAgent created via GopherAgent.create(). Use the builder() method to create configurations. + The builder accepts only the api_key / server_config XOR that maps to + the original gopher_orch_agent_create_by_api_key and + gopher_orch_agent_create_by_json C entry points. The five newer routing + factories (GopherAgent.create_with_server_id, create_with_server_name, + create_with_gateway_id, create_with_gateway_name, create_with_url) take + additional inputs (server / gateway identifier, or URL) that do not fit + that XOR shape and deliberately bypass this builder; they are exposed + as static methods on GopherAgent and dispatch into GopherOrchLibrary + directly via GopherAgent._create_from_ffi. + Example: >>> config = (GopherAgentConfig.builder() ... .provider("AnthropicProvider") diff --git a/gopher_mcp_python/ffi/auth/auth_client.py b/gopher_mcp_python/ffi/auth/auth_client.py index 70871e60..de0bc0cf 100644 --- a/gopher_mcp_python/ffi/auth/auth_client.py +++ b/gopher_mcp_python/ffi/auth/auth_client.py @@ -22,7 +22,6 @@ ) from gopher_mcp_python.ffi.auth.validation_options import GopherValidationOptions - # ============================================================================ # Library Lifecycle Functions # ============================================================================ diff --git a/gopher_mcp_python/ffi/library.py b/gopher_mcp_python/ffi/library.py index fa272d96..200b9582 100644 --- a/gopher_mcp_python/ffi/library.py +++ b/gopher_mcp_python/ffi/library.py @@ -134,6 +134,46 @@ def _setup_functions(self) -> None: ] self._lib.gopher_orch_agent_create_by_api_key.restype = c_void_p + # 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 + # after the initial factories, so bind each one independently to keep + # the SDK loadable against older libgopher-orch builds while still + # configuring every symbol that is present. + routing_factories = [ + ( + "gopher_orch_agent_create_by_server_id", + [c_char_p, c_char_p, c_char_p, c_char_p], + c_void_p, + ), + ( + "gopher_orch_agent_create_by_server_name", + [c_char_p, c_char_p, c_char_p, c_char_p], + c_void_p, + ), + ( + "gopher_orch_agent_create_by_gateway_id", + [c_char_p, c_char_p, c_char_p, c_char_p], + c_void_p, + ), + ( + "gopher_orch_agent_create_by_gateway_name", + [c_char_p, c_char_p, c_char_p, c_char_p], + c_void_p, + ), + ( + "gopher_orch_agent_create_by_url", + [c_char_p, c_char_p, c_char_p], + c_void_p, + ), + ] + for name, argtypes, restype in routing_factories: + try: + fn = getattr(self._lib, name) + except AttributeError: + continue + fn.argtypes = argtypes + fn.restype = restype + 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 @@ -287,6 +327,106 @@ def agent_create_by_api_key( api_key.encode("utf-8"), ) + def agent_create_by_server_id( + self, provider: str, model: str, api_key: str, server_id: str + ) -> Optional[GopherOrchHandle]: + """Create an agent scoped to a single MCP server by id. + + The native side fetches server config from the Gopher API using the + Bearer api key, appending "?serverId={server_id}" so the response + carries only the matching MCP server entry. + """ + if not self._available or self._lib is None: + return None + fn = getattr(self._lib, "gopher_orch_agent_create_by_server_id", None) + if fn is None: + raise RuntimeError(_missing_routing_factory_message()) + return fn( + provider.encode("utf-8"), + model.encode("utf-8"), + api_key.encode("utf-8"), + server_id.encode("utf-8"), + ) + + def agent_create_by_server_name( + self, provider: str, model: str, api_key: str, server_name: str + ) -> Optional[GopherOrchHandle]: + """Create an agent scoped to a single MCP server by name. + + Mirrors agent_create_by_server_id but routes via "?serverName=". + """ + if not self._available or self._lib is None: + return None + fn = getattr(self._lib, "gopher_orch_agent_create_by_server_name", None) + if fn is None: + raise RuntimeError(_missing_routing_factory_message()) + return fn( + provider.encode("utf-8"), + model.encode("utf-8"), + api_key.encode("utf-8"), + server_name.encode("utf-8"), + ) + + def agent_create_by_gateway_id( + self, provider: str, model: str, api_key: str, gateway_id: str + ) -> Optional[GopherOrchHandle]: + """Create an agent scoped to a single MCP gateway by id. + + The native side appends "?gatewayId={gateway_id}" to the Gopher API + fetch so the response carries the backing MCP servers for that + gateway. + """ + if not self._available or self._lib is None: + return None + fn = getattr(self._lib, "gopher_orch_agent_create_by_gateway_id", None) + if fn is None: + raise RuntimeError(_missing_routing_factory_message()) + return fn( + provider.encode("utf-8"), + model.encode("utf-8"), + api_key.encode("utf-8"), + gateway_id.encode("utf-8"), + ) + + def agent_create_by_gateway_name( + self, provider: str, model: str, api_key: str, gateway_name: str + ) -> Optional[GopherOrchHandle]: + """Create an agent scoped to a single MCP gateway by name. + + Mirrors agent_create_by_gateway_id but routes via "?gatewayName=". + """ + if not self._available or self._lib is None: + return None + fn = getattr(self._lib, "gopher_orch_agent_create_by_gateway_name", None) + if fn is None: + raise RuntimeError(_missing_routing_factory_message()) + return fn( + provider.encode("utf-8"), + model.encode("utf-8"), + api_key.encode("utf-8"), + gateway_name.encode("utf-8"), + ) + + def agent_create_by_url( + self, provider: str, model: str, url: str + ) -> Optional[GopherOrchHandle]: + """Create an agent for a single MCP server reachable at a URL. + + Skips the remote config fetch: the native side synthesises an + http_sse server entry around the URL. Useful for local development + or one-off endpoints where the operator already knows the URL. + """ + if not self._available or self._lib is None: + return None + fn = getattr(self._lib, "gopher_orch_agent_create_by_url", None) + if fn is None: + raise RuntimeError(_missing_routing_factory_message()) + return fn( + provider.encode("utf-8"), + model.encode("utf-8"), + url.encode("utf-8"), + ) + def agent_run( self, agent: GopherOrchHandle, query: str, timeout_ms: int ) -> Optional[str]: @@ -367,3 +507,10 @@ def set_log_level(self, level: int) -> None: """ if self._available and self._lib is not None: self._lib.gopher_orch_set_log_level(level) + + +def _missing_routing_factory_message() -> str: + return ( + "this build of libgopher-orch predates the routing factories; " + "upgrade to a native gopher-orch library release that includes them" + ) diff --git a/setup.py b/setup.py index eedec75a..c816fb57 100644 --- a/setup.py +++ b/setup.py @@ -4,6 +4,7 @@ All configuration is in pyproject.toml. This file only exists to support editable installs with pip < 21.3. """ + from setuptools import setup setup() diff --git a/tests/ffi/auth/test_validation_options.py b/tests/ffi/auth/test_validation_options.py index dcf94c85..29ff453a 100644 --- a/tests/ffi/auth/test_validation_options.py +++ b/tests/ffi/auth/test_validation_options.py @@ -8,7 +8,6 @@ gopher_create_validation_options, ) - # Skip all tests if auth functions not available pytestmark = pytest.mark.skipif( not is_auth_available(), diff --git a/tests/test_agent_create_by.py b/tests/test_agent_create_by.py new file mode 100644 index 00000000..d1a5aa69 --- /dev/null +++ b/tests/test_agent_create_by.py @@ -0,0 +1,104 @@ +""" +Contract tests for the five routing factories on GopherAgent. + +Mirrors the failure-path test set in +gopher-orch/tests/gopher/orch/agent_create_by_test.cc which locks down +the nullptr-on-failure contract that the C FFI surfaces here as +AgentError. Happy-path coverage needs a stubbed HTTP listener capturing +the /v1/mcp-servers query string with the camelCase routing keys +(serverId / serverName / gatewayId / gatewayName); that infrastructure +is tracked separately, same as on the C++ side. +""" + +import pytest + +from gopher_mcp_python import AgentError, GopherAgent +from gopher_mcp_python.ffi import GopherOrchLibrary + +PROVIDER = "AnthropicProvider" +MODEL = "test-model" +BAD_PROVIDER = "NotARealProvider" +URL = "http://127.0.0.1:1/mcp" + +ROUTING_FACTORY_SYMBOLS = [ + "gopher_orch_agent_create_by_server_id", + "gopher_orch_agent_create_by_server_name", + "gopher_orch_agent_create_by_gateway_id", + "gopher_orch_agent_create_by_gateway_name", + "gopher_orch_agent_create_by_url", +] + + +def has_routing_factory_symbols() -> bool: + lib = GopherOrchLibrary.get_instance() + if lib is None or lib._lib is None: + return False + return all(hasattr(lib._lib, symbol) for symbol in ROUTING_FACTORY_SYMBOLS) + + +pytestmark = pytest.mark.skipif( + not has_routing_factory_symbols(), + reason=( + "Native routing factory symbols not available -- use a libgopher-orch " + "release that includes them" + ), +) + + +class TestRoutingFactoryContracts: + """Failure-path contract for the five routing factories on GopherAgent.""" + + # ---------------------------------------------------------------- + # Empty api key. fetch_mcp_servers throws on the native side; the + # factory must surface that as AgentError rather than returning a + # partially-constructed agent or a null handle leaking through. + # ---------------------------------------------------------------- + + def test_create_with_server_id_rejects_empty_api_key(self) -> None: + with pytest.raises(AgentError): + GopherAgent.create_with_server_id(PROVIDER, MODEL, "", "srv-1") + + def test_create_with_server_name_rejects_empty_api_key(self) -> None: + with pytest.raises(AgentError): + GopherAgent.create_with_server_name(PROVIDER, MODEL, "", "my-server") + + def test_create_with_gateway_id_rejects_empty_api_key(self) -> None: + with pytest.raises(AgentError): + GopherAgent.create_with_gateway_id(PROVIDER, MODEL, "", "gw-1") + + def test_create_with_gateway_name_rejects_empty_api_key(self) -> None: + with pytest.raises(AgentError): + GopherAgent.create_with_gateway_name(PROVIDER, MODEL, "", "my-gateway") + + # ---------------------------------------------------------------- + # Mirrors the native CreateByUrlRejectsEmptyUrl case. The Python wrapper + # delegates validation to libgopher-orch and surfaces it as AgentError. + # ---------------------------------------------------------------- + + def test_create_with_url_rejects_empty_url(self) -> None: + with pytest.raises(AgentError): + GopherAgent.create_with_url(PROVIDER, MODEL, "") + + # ---------------------------------------------------------------- + # Unknown provider. create_with_url synthesises a local http_sse + # config and reaches create_by_json on the native side, which + # rejects an unknown provider name. Use an unlikely local port so the test + # does not accidentally talk to a developer service on 8080. + # ---------------------------------------------------------------- + + def test_create_with_url_rejects_unknown_provider(self) -> None: + with pytest.raises(AgentError): + GopherAgent.create_with_url(BAD_PROVIDER, MODEL, URL) + + # ---------------------------------------------------------------- + # AgentError surfaces a non-empty message so SDK consumers can log + # a meaningful diagnostic; the C side fills last_error() and the + # wrapper pump should propagate it through. A future change to the + # error pump that swallows the underlying C diagnostic gets caught + # here immediately. + # ---------------------------------------------------------------- + + def test_create_with_server_id_surfaces_non_empty_message(self) -> None: + with pytest.raises(AgentError) as exc_info: + GopherAgent.create_with_server_id(PROVIDER, MODEL, "", "srv-1") + assert len(str(exc_info.value)) > 0 diff --git a/tests/test_dump_version.py b/tests/test_dump_version.py new file mode 100644 index 00000000..ec09169f --- /dev/null +++ b/tests/test_dump_version.py @@ -0,0 +1,28 @@ +"""Regression tests for release version dumping behavior.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_dump_version_prefers_highest_version_tag_on_head() -> None: + script = (ROOT / "dump-version.sh").read_text() + + points_at = "PREV_TAG=$(git tag --points-at HEAD --list 'v*' --sort=-v:refname | head -1)" + describe = ( + "PREV_TAG=$(git describe --tags --abbrev=0 --match 'v*' HEAD " + "2>/dev/null || true)" + ) + + assert points_at in script + assert describe in script + assert script.index(points_at) < script.index(describe) + + +def test_dump_version_warns_when_tag_and_recorded_version_disagree() -> None: + script = (ROOT / "dump-version.sh").read_text() + + assert 'PREV_TAG_BASE=$(echo "${PREV_TAG#v}"' in script + assert "tag name and recorded version disagree" in script + assert 'PREV_GOPHER_ORCH_VERSION=""' in script diff --git a/tests/test_ffi.py b/tests/test_ffi.py index cc691af1..8a6ced8a 100644 --- a/tests/test_ffi.py +++ b/tests/test_ffi.py @@ -7,9 +7,12 @@ import json import os +from ctypes import c_char_p, c_int, c_void_p import pytest +import gopher_mcp_python.agent as agent_module +from gopher_mcp_python import AgentError, GopherAgent from gopher_mcp_python.ffi import GopherOrchLibrary @@ -21,6 +24,108 @@ def is_native_library_available() -> bool: class TestGopherOrchLibrary: """Tests for GopherOrchLibrary FFI bindings.""" + def test_should_bind_present_optional_routing_symbols_independently(self): + """Missing optional symbols must not skip binding later symbols.""" + + class FakeFunction: + def __init__(self): + self.argtypes = None + self.restype = c_int + + def __call__(self, *args): + return None + + class FakeLib: + missing = {"gopher_orch_agent_create_by_server_id"} + + def __init__(self): + names = [ + "gopher_orch_agent_create_by_json", + "gopher_orch_agent_create_by_api_key", + "gopher_orch_agent_create_by_server_name", + "gopher_orch_agent_create_by_gateway_id", + "gopher_orch_agent_create_by_gateway_name", + "gopher_orch_agent_create_by_url", + "gopher_orch_agent_run", + "gopher_orch_agent_add_ref", + "gopher_orch_agent_release", + "gopher_orch_api_fetch_servers", + "gopher_orch_last_error", + "gopher_orch_clear_error", + "gopher_orch_free", + "gopher_orch_set_log_level", + ] + self._functions = {name: FakeFunction() for name in names} + + def __getattr__(self, name): + if name in self.missing: + raise AttributeError(name) + try: + return self._functions[name] + except KeyError: + raise AttributeError(name) from None + + fake_lib = FakeLib() + lib = GopherOrchLibrary.__new__(GopherOrchLibrary) + lib._lib = fake_lib + + lib._setup_functions() + + assert fake_lib.gopher_orch_agent_create_by_server_name.restype is c_void_p + assert fake_lib.gopher_orch_agent_create_by_gateway_id.restype is c_void_p + assert fake_lib.gopher_orch_agent_create_by_gateway_name.restype is c_void_p + assert fake_lib.gopher_orch_agent_create_by_url.restype is c_void_p + assert fake_lib.gopher_orch_agent_create_by_url.argtypes == [ + c_char_p, + c_char_p, + c_char_p, + ] + + def test_missing_optional_routing_symbol_raises_upgrade_error(self): + """Absent routing factories should not look like native NULL returns.""" + + class FakeLib: + pass + + lib = GopherOrchLibrary.__new__(GopherOrchLibrary) + lib._available = True + lib._lib = FakeLib() + + with pytest.raises(RuntimeError, match="predates the routing factories"): + lib.agent_create_by_url( + "AnthropicProvider", "claude-3-haiku-20240307", "http://x/mcp" + ) + + def test_public_factory_surfaces_missing_routing_symbol_message( + self, monkeypatch + ): + """AgentError should tell users to upgrade when the native symbol is absent.""" + + class FakeLib: + def agent_create_by_url(self, provider, model, url): + raise RuntimeError( + "this build of libgopher-orch predates the routing factories; " + "upgrade to a native gopher-orch library release that includes " + "them" + ) + + monkeypatch.setattr(agent_module, "_initialized", True) + monkeypatch.setattr( + GopherOrchLibrary, + "get_instance", + classmethod(lambda cls: FakeLib()), + ) + + with pytest.raises(AgentError) as exc_info: + GopherAgent.create_with_url( + "AnthropicProvider", "claude-3-haiku-20240307", "http://x/mcp" + ) + + assert "predates the routing factories" in str(exc_info.value) + assert "upgrade to a native gopher-orch library release" in str( + exc_info.value + ) + def test_library_should_be_available(self): """Test that library should be available.""" available = GopherOrchLibrary.is_available() diff --git a/third_party/gopher-orch b/third_party/gopher-orch index ff84c196..bf4b46ad 160000 --- a/third_party/gopher-orch +++ b/third_party/gopher-orch @@ -1 +1 @@ -Subproject commit ff84c1969e5ccd6250d0876e653db8c8f77667b0 +Subproject commit bf4b46adb67e3ed9f2ddfcd4c460d9b3271f20c3