diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..1a1e14cc --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,229 @@ +name: Release + +on: + push: + branches: [br_release] + +permissions: + contents: write + +jobs: + release: + name: Create Release with Native Binaries + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get version from latest tag + id: version + run: | + # Get the latest tag on this branch + VERSION_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + + if [ -z "$VERSION_TAG" ]; then + echo "Error: No tags found. Run dump-version.sh first." + exit 1 + fi + + # Remove 'v' prefix for version number + VERSION="${VERSION_TAG#v}" + + # Determine if this is a prerelease + # X.Y.Z-N (numeric extension) is NOT a prerelease + # X.Y.Z-alpha, X.Y.Z-rc1, etc. ARE prereleases + if echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+-[0-9]+$'; then + # Numeric extension like 0.1.2-4 - NOT a prerelease + IS_PRERELEASE="false" + elif echo "$VERSION" | grep -qE '-'; then + # Has hyphen but not numeric - IS a prerelease (e.g., -rc1, -alpha) + IS_PRERELEASE="true" + else + # No hyphen - NOT a prerelease + IS_PRERELEASE="false" + fi + + echo "version_tag=${VERSION_TAG}" >> $GITHUB_OUTPUT + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "is_prerelease=${IS_PRERELEASE}" >> $GITHUB_OUTPUT + echo "Version Tag: ${VERSION_TAG}" + echo "Version: ${VERSION}" + echo "Is Prerelease: ${IS_PRERELEASE}" + + - name: Check if release already exists + id: check_release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if gh release view ${{ steps.version.outputs.version_tag }} &>/dev/null; then + echo "Release ${{ steps.version.outputs.version_tag }} already exists" + echo "exists=true" >> $GITHUB_OUTPUT + else + echo "Release ${{ steps.version.outputs.version_tag }} does not exist" + echo "exists=false" >> $GITHUB_OUTPUT + fi + + - name: Download native binaries from gopher-orch + id: gopher_orch + if: steps.check_release.outputs.exists != 'true' + env: + GH_TOKEN: ${{ secrets.GOPHER_ORCH_TOKEN }} + run: | + VERSION="${{ steps.version.outputs.version }}" + + # Extract base version (X.Y.Z) from extended version (X.Y.Z-E) + # e.g., 0.1.2-3 -> 0.1.2 + if echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+-[0-9]+$'; then + BASE_VERSION=$(echo "$VERSION" | sed -E 's/^([0-9]+\.[0-9]+\.[0-9]+)-[0-9]+$/\1/') + else + BASE_VERSION="$VERSION" + fi + + GOPHER_ORCH_TAG="v${BASE_VERSION}" + echo "gopher_orch_version=${GOPHER_ORCH_TAG}" >> $GITHUB_OUTPUT + echo "Downloading native binaries from gopher-orch ${GOPHER_ORCH_TAG}..." + + mkdir -p downloads + gh release download "${GOPHER_ORCH_TAG}" \ + -R GopherSecurity/gopher-orch \ + -D downloads \ + -p "libgopher-orch-*.tar.gz" \ + -p "libgopher-orch-*.zip" + + - name: Prepare release assets + if: steps.check_release.outputs.exists != 'true' + run: | + mkdir -p release-assets + cp downloads/* release-assets/ + + - name: Generate release notes + if: steps.check_release.outputs.exists != 'true' + run: | + VERSION="${{ steps.version.outputs.version }}" + VERSION_TAG="${{ steps.version.outputs.version_tag }}" + + cat > RELEASE_NOTES.md << EOF + ## gopher-mcp-go ${VERSION_TAG} + + Go SDK for gopher-orch orchestration framework. + + ### Installation + + \`\`\`bash + # Install Go module + go get github.com/GopherSecurity/gopher-mcp-go@${VERSION_TAG} + + # Download native library for your platform + # macOS (Apple Silicon) + gh release download ${VERSION_TAG} -R GopherSecurity/gopher-mcp-go -p "libgopher-orch-macos-arm64.tar.gz" + tar -xzf libgopher-orch-macos-arm64.tar.gz -C /usr/local + + # macOS (Intel) + gh release download ${VERSION_TAG} -R GopherSecurity/gopher-mcp-go -p "libgopher-orch-macos-x64.tar.gz" + tar -xzf libgopher-orch-macos-x64.tar.gz -C /usr/local + + # Linux (x64) + gh release download ${VERSION_TAG} -R GopherSecurity/gopher-mcp-go -p "libgopher-orch-linux-x64.tar.gz" + sudo tar -xzf libgopher-orch-linux-x64.tar.gz -C /usr/local + + # Linux (arm64) + gh release download ${VERSION_TAG} -R GopherSecurity/gopher-mcp-go -p "libgopher-orch-linux-arm64.tar.gz" + sudo tar -xzf libgopher-orch-linux-arm64.tar.gz -C /usr/local + \`\`\` + + ### Environment Setup + + \`\`\`bash + export CGO_CFLAGS="-I/usr/local/include" + export CGO_LDFLAGS="-L/usr/local/lib -lgopher-orch" + export DYLD_LIBRARY_PATH="/usr/local/lib:\$DYLD_LIBRARY_PATH" # macOS + export LD_LIBRARY_PATH="/usr/local/lib:\$LD_LIBRARY_PATH" # Linux + \`\`\` + + ### Build Information + + - **Version:** ${VERSION} + - **gopher-orch:** ${{ steps.gopher_orch.outputs.gopher_orch_version }} + - **Commit:** ${{ github.sha }} + - **Date:** $(date -u +"%Y-%m-%d %H:%M:%S UTC") + + EOF + + # Extract changelog content + if [ -f "CHANGELOG.md" ]; then + echo "### What's Changed" >> RELEASE_NOTES.md + echo "" >> RELEASE_NOTES.md + + # Get content from the version section + sed -n "/^## \[${VERSION}\]/,/^## \[/p" CHANGELOG.md | \ + grep -v "^## \[" | \ + head -30 >> RELEASE_NOTES.md || true + fi + + # Add comparison link + PREV_TAG=$(git tag --sort=-creatordate | grep -v "^${VERSION_TAG}$" | head -1) + if [ -n "$PREV_TAG" ]; then + echo "" >> RELEASE_NOTES.md + echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/${PREV_TAG}...${VERSION_TAG}" >> RELEASE_NOTES.md + fi + + echo "=== Release Notes ===" + cat RELEASE_NOTES.md + + - name: Create GitHub Release + if: steps.check_release.outputs.exists != 'true' + uses: softprops/action-gh-release@v1 + with: + tag_name: ${{ steps.version.outputs.version_tag }} + name: gopher-mcp-go ${{ steps.version.outputs.version_tag }} + body_path: RELEASE_NOTES.md + draft: false + prerelease: ${{ steps.version.outputs.is_prerelease == 'true' }} + files: release-assets/* + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Summary + run: | + echo "## Release Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "- **Version:** ${{ steps.version.outputs.version_tag }}" >> $GITHUB_STEP_SUMMARY + echo "- **Release URL:** https://github.com/${{ github.repository }}/releases/tag/${{ steps.version.outputs.version_tag }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Native Libraries" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + if [ -d "release-assets" ]; then + ls release-assets/ | while read file; do + echo "- \`${file}\`" >> $GITHUB_STEP_SUMMARY + done + fi + + test: + name: Run Tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Run tests (without native library) + run: | + # Run tests that don't require native library + go test ./... -v -short || echo "Some tests may require native library" + + notify: + name: Notify on Failure + needs: [release, test] + runs-on: ubuntu-latest + if: failure() + steps: + - name: Report failure + run: | + echo "Release workflow failed!" + echo "Check the logs for details." diff --git a/.gitmodules b/.gitmodules index d5cd4211..23a5d305 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,4 @@ [submodule "third_party/gopher-orch"] path = third_party/gopher-orch url = https://github.com/GopherSecurity/gopher-orch.git + branch = br_release diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..d3e9ffa3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Initial release of gopher-mcp-go SDK +- Go bindings for gopher-orch native library +- OAuth 2.0 authentication support (AuthContext, WwwAuthenticate) +- MCP (Model Context Protocol) client implementation + +### Changed +- Updated module path to github.com/GopherSecurity/gopher-mcp-go + +--- + +[Unreleased]: https://github.com/GopherSecurity/gopher-mcp-go/compare/HEAD diff --git a/README.md b/README.md index 0425b0f4..06d315af 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# gopher-orch - Go SDK +# gopher-mcp-go -Go SDK for Gopher Orch - AI Agent orchestration framework with native C++ performance. +Go SDK for Gopher MCP - AI Agent orchestration framework with native C++ performance. ## Table of Contents @@ -45,6 +45,7 @@ Go SDK for Gopher Orch - AI Agent orchestration framework with native C++ perfor - **Native Performance** - Powered by C++ core with Go bindings via CGO - **AI Agent Framework** - Build intelligent agents with LLM integration - **MCP Protocol** - Model Context Protocol client and server support +- **OAuth 2.0 Authentication** - JWT token validation with JWKS support - **Tool Orchestration** - Manage and execute tools across multiple MCP servers - **State Management** - Built-in state graph for complex workflows - **Type Safety** - Full Go type safety with idiomatic patterns @@ -95,10 +96,10 @@ This SDK is ideal for: ## Installation -### Option 1: Go Modules (when published) +### Option 1: Go Modules ```bash -go get github.com/GopherSecurity/gopher-orch-go +go get github.com/GopherSecurity/gopher-mcp-go ``` ### Option 2: Build from Source @@ -114,7 +115,7 @@ import ( "fmt" "log" - "github.com/GopherSecurity/gopher-orch-go/src" + "github.com/GopherSecurity/gopher-mcp-go/src" ) func main() { @@ -289,7 +290,7 @@ export LD_LIBRARY_PATH=/path/to/lib:$LD_LIBRARY_PATH The main type for creating and running AI agents: ```go -import "github.com/GopherSecurity/gopher-orch-go/src" +import "github.com/GopherSecurity/gopher-mcp-go/src" // Initialize the library (called automatically on first Create) src.Init() @@ -359,8 +360,8 @@ The SDK provides typed errors for different failure scenarios: ```go import ( - "github.com/GopherSecurity/gopher-orch-go/src" - "github.com/GopherSecurity/gopher-orch-go/src/errors" + "github.com/GopherSecurity/gopher-mcp-go/src" + "github.com/GopherSecurity/gopher-mcp-go/src/errors" ) agent, err := src.Create(config) @@ -392,7 +393,7 @@ import ( "log" "os" - "github.com/GopherSecurity/gopher-orch-go/src" + "github.com/GopherSecurity/gopher-mcp-go/src" ) func main() { @@ -423,7 +424,7 @@ import ( "fmt" "log" - "github.com/GopherSecurity/gopher-orch-go/src" + "github.com/GopherSecurity/gopher-mcp-go/src" ) const serverConfig = `{ @@ -487,6 +488,45 @@ DYLD_LIBRARY_PATH=native/lib \ go run examples/client_example_json.go ``` +### Auth Example (OAuth 2.0 MCP Server) + +The auth example demonstrates building an MCP server with OAuth 2.0 authentication: + +```bash +cd examples/auth +./run_example.sh +``` + +**Features:** +- JWT token validation using JWKS +- OAuth 2.0 protected resource metadata +- Scope-based tool authorization +- Configurable auth server integration + +**What it does:** +1. Downloads the latest SDK from GitHub releases +2. Downloads native libraries for your platform +3. Builds and runs an authenticated MCP server + +**Server Endpoints:** +- `/health` - Health check +- `/mcp` - MCP endpoint (requires authentication) +- `/.well-known/oauth-protected-resource` - OAuth discovery + +**Configuration:** +Edit `server.config` to configure: +- Auth server URL (JWKS URI, issuer) +- Server host/port +- Tool scope requirements + +```bash +# Run without authentication (for testing) +./run_example.sh --no-auth + +# Run on custom port +./run_example.sh --port 8080 +``` + --- ## Development @@ -653,7 +693,7 @@ Contributions are welcome! Please read our contributing guidelines. ## License -MIT License - see [LICENSE](LICENSE) file for details. +Apache License 2.0 - see [LICENSE](LICENSE) file for details. ## Links diff --git a/build.sh b/build.sh index b10c55f6..4526a88a 100755 --- a/build.sh +++ b/build.sh @@ -46,8 +46,8 @@ fi git config --local url."git@${SSH_HOST}:GopherSecurity/".insteadOf "https://github.com/GopherSecurity/" git config --local submodule.third_party/gopher-orch.url "git@${SSH_HOST}:GopherSecurity/gopher-orch.git" -# Update main submodule -if ! git submodule update --init 2>/dev/null; then +# Update main submodule to latest commit from branch specified in .gitmodules +if ! git submodule update --init --remote 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" @@ -200,8 +200,23 @@ go build ./... echo -e "${GREEN}✓ Go SDK built successfully${NC}" echo "" -# Step 5: Run tests -echo -e "${YELLOW}Step 5: Running tests...${NC}" +# Step 5: Build auth example +echo -e "${YELLOW}Step 5: Building auth example...${NC}" +cd "${SCRIPT_DIR}/examples/auth" + +# Build auth example - requires native library with gopher_auth_* symbols +# If those symbols are not available, skip the build +CGO_CFLAGS="-I${SCRIPT_DIR}/native/include" \ +CGO_LDFLAGS="-L${SCRIPT_DIR}/native/lib -lgopher-orch -lgopher-mcp -lgopher-mcp-event " \ +go build -o auth-server . 2>/dev/null && \ + echo -e "${GREEN}✓ Auth example built${NC}" || \ + echo -e "${YELLOW}⚠ Auth example skipped (native library missing gopher_auth_* symbols)${NC}" + +cd "${SCRIPT_DIR}" +echo "" + +# Step 6: Run tests +echo -e "${YELLOW}Step 6: Running tests...${NC}" cd "${SCRIPT_DIR}" CGO_CFLAGS="-I${SCRIPT_DIR}/native/include" \ CGO_LDFLAGS="-L${SCRIPT_DIR}/native/lib -lgopher-orch" \ @@ -216,9 +231,13 @@ echo -e "${GREEN}======================================${NC}" echo "" echo -e "Native libraries: ${YELLOW}${NATIVE_LIB_DIR}${NC}" echo -e "Native headers: ${YELLOW}${NATIVE_INCLUDE_DIR}${NC}" +echo -e "Auth example: ${YELLOW}${SCRIPT_DIR}/examples/auth/auth-server${NC}" echo "" echo -e "To run tests manually:" echo -e " ${YELLOW}DYLD_LIBRARY_PATH=\$(pwd)/native/lib go test ./...${NC}" echo "" echo -e "To build:" echo -e " ${YELLOW}go build ./...${NC}" +echo "" +echo -e "To run auth example:" +echo -e " ${YELLOW}cd examples/auth && ./auth-server --no-auth${NC}" diff --git a/dump-version.sh b/dump-version.sh new file mode 100755 index 00000000..17f93d80 --- /dev/null +++ b/dump-version.sh @@ -0,0 +1,338 @@ +#!/bin/bash +# +# dump-version.sh - Prepare a new release version for gopher-mcp-go +# +# Usage: +# ./dump-version.sh [VERSION] +# +# Arguments: +# VERSION - Optional. Format: X.Y.Z or X.Y.Z.E +# If not provided, uses latest gopher-orch release version (X.Y.Z) +# If provided as X.Y.Z.E, X.Y.Z must match gopher-orch version +# X.Y.Z.E is converted to X.Y.Z-E for Go semver compatibility +# +# This script will: +# 1. Fetch latest version from gopher-orch releases +# 2. Validate and determine the target version +# 3. Update CHANGELOG.md ([Unreleased] -> [X.Y.Z] - date) +# 4. Create git tag vX.Y.Z +# 5. Commit the changes +# +# After running this script: +# 1. Review the changes: git show HEAD +# 2. Push to release: git push origin br_release vX.Y.Z +# + +set -e + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +# Get script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# Files +CHANGELOG_FILE="CHANGELOG.md" +GO_MOD_FILE="go.mod" + +echo -e "${CYAN}========================================${NC}" +echo -e "${CYAN} gopher-mcp-go Release Version Dump${NC}" +echo -e "${CYAN}========================================${NC}" +echo "" + +# ----------------------------------------------------------------------------- +# Step 1: Fetch latest gopher-orch version from GitHub releases +# ----------------------------------------------------------------------------- +echo -e "${YELLOW}Step 1: Fetching latest gopher-orch version...${NC}" + +# Check if gh CLI is available +if ! command -v gh &> /dev/null; then + echo -e "${RED}Error: GitHub CLI (gh) is not installed${NC}" + echo "Install it with: brew install gh" + echo "Then authenticate: gh auth login" + exit 1 +fi + +# Fetch latest release from gopher-orch using gh CLI (handles private repo auth) +GOPHER_ORCH_TAG=$(gh release view --repo GopherSecurity/gopher-orch --json tagName -q '.tagName' 2>/dev/null) + +if [ -z "$GOPHER_ORCH_TAG" ]; then + echo -e "${RED}Error: Could not fetch latest gopher-orch release${NC}" + echo "Make sure you have access to GopherSecurity/gopher-orch repository." + echo "Run 'gh auth login' to authenticate if needed." + exit 1 +fi + +# Remove 'v' prefix if present (e.g., v0.1.1 -> 0.1.1) +GOPHER_ORCH_VERSION="${GOPHER_ORCH_TAG#v}" + +if [ -z "$GOPHER_ORCH_VERSION" ]; then + echo -e "${RED}Error: Could not parse gopher-orch version from release${NC}" + exit 1 +fi + +echo -e " Latest gopher-orch version: ${GREEN}$GOPHER_ORCH_VERSION${NC}" + +# Validate gopher-orch version format (X.Y.Z) +if ! echo "$GOPHER_ORCH_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo -e "${RED}Error: gopher-orch version '$GOPHER_ORCH_VERSION' is not in X.Y.Z format${NC}" + exit 1 +fi + +# ----------------------------------------------------------------------------- +# Step 2: Determine target version +# ----------------------------------------------------------------------------- +echo "" +echo -e "${YELLOW}Step 2: Determining target version...${NC}" + +INPUT_VERSION="$1" + +if [ -z "$INPUT_VERSION" ]; then + # No argument provided, use gopher-orch version directly + TARGET_VERSION="$GOPHER_ORCH_VERSION" + echo -e " No version argument provided" + echo -e " Using gopher-orch version: ${GREEN}$TARGET_VERSION${NC}" +else + # Version argument provided, validate it + # Format should be X.Y.Z or X.Y.Z.E + if echo "$INPUT_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + # X.Y.Z format - must match gopher-orch exactly + if [ "$INPUT_VERSION" != "$GOPHER_ORCH_VERSION" ]; then + echo -e "${RED}Error: Version $INPUT_VERSION does not match gopher-orch version $GOPHER_ORCH_VERSION${NC}" + exit 1 + fi + TARGET_VERSION="$INPUT_VERSION" + elif echo "$INPUT_VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then + # X.Y.Z.E format - first 3 parts must match gopher-orch + INPUT_BASE=$(echo "$INPUT_VERSION" | sed -E 's/^([0-9]+\.[0-9]+\.[0-9]+)\.[0-9]+$/\1/') + INPUT_EXT=$(echo "$INPUT_VERSION" | sed -E 's/^[0-9]+\.[0-9]+\.[0-9]+\.([0-9]+)$/\1/') + if [ "$INPUT_BASE" != "$GOPHER_ORCH_VERSION" ]; then + echo -e "${RED}Error: Version base $INPUT_BASE does not match gopher-orch version $GOPHER_ORCH_VERSION${NC}" + echo "Extended version X.Y.Z.E must have X.Y.Z matching gopher-orch." + exit 1 + fi + # Convert X.Y.Z.E to X.Y.Z-E for Go semver compatibility + TARGET_VERSION="${INPUT_BASE}-${INPUT_EXT}" + echo -e " Converting ${INPUT_VERSION} -> ${TARGET_VERSION} (Go semver)" + else + echo -e "${RED}Error: Invalid version format '$INPUT_VERSION'${NC}" + echo "Expected format: X.Y.Z or X.Y.Z.E" + exit 1 + fi + echo -e " Using provided version: ${GREEN}$TARGET_VERSION${NC}" +fi + +TAG_VERSION="v$TARGET_VERSION" + +# ----------------------------------------------------------------------------- +# Step 3: Check if tag already exists +# ----------------------------------------------------------------------------- +echo "" +echo -e "${YELLOW}Step 3: Checking existing tags...${NC}" + +if git tag -l | grep -q "^$TAG_VERSION$"; then + echo -e "${RED}Error: Tag $TAG_VERSION already exists${NC}" + echo "If you want to re-release, delete the tag first:" + echo " git tag -d $TAG_VERSION" + echo " git push origin :refs/tags/$TAG_VERSION" + exit 1 +fi + +echo -e " Tag ${GREEN}$TAG_VERSION${NC} is available" + +# ----------------------------------------------------------------------------- +# Step 4: Check [Unreleased] section has content +# ----------------------------------------------------------------------------- +echo "" +echo -e "${YELLOW}Step 4: Checking [Unreleased] section...${NC}" + +if [ ! -f "$CHANGELOG_FILE" ]; then + echo -e "${YELLOW}Warning: $CHANGELOG_FILE not found, creating one...${NC}" + cat > "$CHANGELOG_FILE" << EOF +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Initial release of gopher-mcp-go SDK +- Go bindings for gopher-orch native library +- OAuth 2.0 authentication support +- MCP (Model Context Protocol) client implementation + +--- + +[Unreleased]: https://github.com/GopherSecurity/gopher-mcp-go/compare/HEAD +EOF +fi + +# Extract content between [Unreleased] and next ## section +UNRELEASED_CONTENT=$(sed -n '/^## \[Unreleased\]/,/^## \[/p' "$CHANGELOG_FILE" | \ + grep -v "^## \[" | grep -v "^$" | head -20) + +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 + fi +else + echo -e " ${GREEN}[Unreleased] section has content${NC}" + echo " Preview:" + echo "$UNRELEASED_CONTENT" | head -5 | sed 's/^/ /' +fi + +# ----------------------------------------------------------------------------- +# Step 5: Update CHANGELOG.md +# ----------------------------------------------------------------------------- +echo "" +echo -e "${YELLOW}Step 5: Updating CHANGELOG.md...${NC}" + +TODAY=$(date +%Y-%m-%d) +REPO_URL="https://github.com/GopherSecurity/gopher-mcp-go" + +# Create backup +cp "$CHANGELOG_FILE" "${CHANGELOG_FILE}.bak" + +# Find the line number of [Unreleased] header +UNRELEASED_LINE=$(grep -n "^## \[Unreleased\]" "$CHANGELOG_FILE" | head -1 | cut -d: -f1) + +if [ -z "$UNRELEASED_LINE" ]; then + echo -e "${RED}Error: Could not find [Unreleased] section in CHANGELOG.md${NC}" + rm -f "${CHANGELOG_FILE}.bak" + exit 1 +fi + +# Find the previous version for link generation +PREV_VERSION=$(grep -E "^## \[[0-9]+\.[0-9]+\.[0-9]+" "$CHANGELOG_FILE" | head -1 | sed -E 's/^## \[([^]]+)\].*/\1/') + +# Check if there's a links section at the bottom (starts with --- or [Unreleased]:) +HAS_LINKS_SECTION=$(grep -c "^\[Unreleased\]:" "$CHANGELOG_FILE" || true) + +# Find where links section starts (look for --- separator or [Unreleased]: link) +if [ "$HAS_LINKS_SECTION" -gt 0 ]; then + # Find the --- line before [Unreleased]: link, or the [Unreleased]: line itself + LINKS_LINE=$(grep -n "^\[Unreleased\]:" "$CHANGELOG_FILE" | head -1 | cut -d: -f1) + # Check if there's a --- separator before it + SEPARATOR_LINE=$(grep -n "^---$" "$CHANGELOG_FILE" | tail -1 | cut -d: -f1) + if [ -n "$SEPARATOR_LINE" ] && [ "$SEPARATOR_LINE" -lt "$LINKS_LINE" ]; then + LINKS_LINE=$SEPARATOR_LINE + fi +else + LINKS_LINE="" +fi + +# Build new CHANGELOG content +{ + # 1. Header section (everything before [Unreleased]) + head -n $((UNRELEASED_LINE - 1)) "$CHANGELOG_FILE" + + # 2. New [Unreleased] section (empty) + echo "## [Unreleased]" + echo "" + + # 3. New version section with today's date + echo "## [$TARGET_VERSION] - $TODAY" + + # 4. Content after old [Unreleased] header until links section or EOF + if [ -n "$LINKS_LINE" ]; then + # Get content between [Unreleased] header and links section + tail -n +$((UNRELEASED_LINE + 1)) "$CHANGELOG_FILE" | head -n $((LINKS_LINE - UNRELEASED_LINE - 1)) + else + # No links section, get everything after [Unreleased] header + tail -n +$((UNRELEASED_LINE + 1)) "$CHANGELOG_FILE" + fi + + # 5. Add/Update links section + echo "" + echo "---" + echo "" + # [Unreleased] link pointing to compare from new version to HEAD + echo "[Unreleased]: ${REPO_URL}/compare/v${TARGET_VERSION}...HEAD" + # Add new version link + if [ -n "$PREV_VERSION" ]; then + echo "[$TARGET_VERSION]: ${REPO_URL}/compare/v${PREV_VERSION}...v${TARGET_VERSION}" + else + echo "[$TARGET_VERSION]: ${REPO_URL}/releases/tag/v${TARGET_VERSION}" + fi + # Keep existing version links (skip old [Unreleased] link and current version) + if [ "$HAS_LINKS_SECTION" -gt 0 ]; then + grep -E "^\[[0-9]+\.[0-9]+\.[0-9]+" "$CHANGELOG_FILE" | grep -v "^\[$TARGET_VERSION\]" || true + fi +} > "${CHANGELOG_FILE}.new" + +mv "${CHANGELOG_FILE}.new" "$CHANGELOG_FILE" +rm -f "${CHANGELOG_FILE}.bak" + +echo -e " ${GREEN}CHANGELOG.md updated${NC}" +echo -e " [Unreleased] -> [$TARGET_VERSION] - $TODAY" + +# ----------------------------------------------------------------------------- +# Step 6: Commit changes and create tag +# ----------------------------------------------------------------------------- +echo "" +echo -e "${YELLOW}Step 6: Committing changes and creating tag...${NC}" + +# Show what changed +echo "" +echo -e "${CYAN}Changes to be committed:${NC}" +git diff --stat "$CHANGELOG_FILE" + +echo "" +echo -e "${CYAN}Committing...${NC}" + +git add "$CHANGELOG_FILE" +git commit -m "Release version $TARGET_VERSION + +Prepare release v$TARGET_VERSION: +- Update CHANGELOG.md: [Unreleased] -> [$TARGET_VERSION] - $TODAY + +gopher-orch version: $GOPHER_ORCH_VERSION + +Changes in this release: +$(echo "$UNRELEASED_CONTENT" | head -10) +" + +# Create annotated tag +echo "" +echo -e "${CYAN}Creating tag $TAG_VERSION...${NC}" +git tag -a "$TAG_VERSION" -m "Release $TARGET_VERSION + +gopher-orch version: $GOPHER_ORCH_VERSION + +Changes: +$(echo "$UNRELEASED_CONTENT" | head -15) +" + +echo "" +echo -e "${GREEN}========================================${NC}" +echo -e "${GREEN} Release preparation complete!${NC}" +echo -e "${GREEN}========================================${NC}" +echo "" +echo -e "Version: ${CYAN}$TARGET_VERSION${NC}" +echo -e "Tag: ${CYAN}$TAG_VERSION${NC}" +echo -e "gopher-orch: ${CYAN}$GOPHER_ORCH_VERSION${NC}" +echo "" +echo -e "${YELLOW}Next steps:${NC}" +echo " 1. Review the commit: git show HEAD" +echo " 2. Push to release: git push origin br_release $TAG_VERSION" +echo "" +echo -e "${CYAN}After pushing:${NC}" +echo " - CI workflow will create GitHub Release" +echo " - proxy.golang.org will cache on first 'go get'" +echo " - pkg.go.dev will index automatically" +echo "" +echo -e "${CYAN}Users can install with:${NC}" +echo " go get github.com/GopherSecurity/gopher-mcp-go@$TAG_VERSION" +echo "" diff --git a/examples/auth/.gitignore b/examples/auth/.gitignore new file mode 100644 index 00000000..a1620f16 --- /dev/null +++ b/examples/auth/.gitignore @@ -0,0 +1,14 @@ +# Build output +auth-server + +# Downloaded native libraries +native/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store diff --git a/examples/auth/README.md b/examples/auth/README.md new file mode 100644 index 00000000..49022105 --- /dev/null +++ b/examples/auth/README.md @@ -0,0 +1,307 @@ +# Gopher Auth MCP Server - Go Example + +This example demonstrates an MCP (Model Context Protocol) server with OAuth 2.0 authentication using the gopher-mcp-go SDK. + +## Overview + +The auth example server provides: +- OAuth 2.0 / OIDC discovery endpoints (RFC 8414, RFC 9728) +- JWT token validation via native library +- Scope-based authorization for MCP tools +- Example weather tools with different scope requirements + +## Prerequisites + +- Go 1.21 or later +- GitHub CLI (`gh`) for downloading native libraries + +## Installation + +### 1. Clone or Copy This Example + +```bash +# Option A: Clone the repository +git clone https://github.com/GopherSecurity/gopher-mcp-go.git +cd gopher-mcp-go/examples/auth + +# Option B: Copy the example files to your project +# Copy the examples/auth directory contents +``` + +### 2. Install the Go SDK + +```bash +go get github.com/GopherSecurity/gopher-mcp-go@v0.1.2 +``` + +### 3. Download Native Libraries + +The SDK requires native libraries for OAuth token validation. The `run_example.sh` script downloads these automatically, or you can install them manually: + +```bash +# Using the run script (downloads automatically) +./run_example.sh --no-auth + +# Or download manually using the install script +curl -sSL https://raw.githubusercontent.com/GopherSecurity/gopher-mcp-go/main/install-native.sh | bash +``` + +## Quick Start + +### Development Mode (No Auth) + +```bash +# Run with auth disabled (all requests bypass authentication) +./run_example.sh --no-auth + +# Or build and run manually +go build -o auth-server . +./auth-server --no-auth +``` + +### With Full OAuth Support + +```bash +# Run with OAuth authentication enabled +./run_example.sh + +# Or build with auth tag manually +export CGO_CFLAGS="-I./native/include" +export CGO_LDFLAGS="-L./native/lib -lgopher-orch" +export DYLD_LIBRARY_PATH="./native/lib:$DYLD_LIBRARY_PATH" +go build -tags auth -o auth-server . +./auth-server +``` + +### Using Environment Variables + +```bash +# Use a specific SDK version +SDK_VERSION=v0.1.3 ./run_example.sh + +# Use custom native library location +NATIVE_LIB_DIR=/usr/local/lib ./run_example.sh --skip-download +``` + +## Configuration + +The server reads configuration from `server.config` (INI format): + +```ini +# Server settings +host=0.0.0.0 +port=3001 +server_url=https://example.ngrok-free.dev + +# OAuth/IDP settings +client_id=your-client-id +client_secret=your-client-secret +auth_server_url=https://auth.example.com/realms/mcp + +# Direct OAuth endpoint URLs (optional, derived from auth_server_url) +# jwks_uri=https://auth.example.com/realms/mcp/protocol/openid-connect/certs +# issuer=https://auth.example.com/realms/mcp +# oauth_authorize_url=https://auth.example.com/realms/mcp/protocol/openid-connect/auth +# oauth_token_url=https://auth.example.com/realms/mcp/protocol/openid-connect/token + +# Scopes +allowed_scopes=openid profile email mcp:read mcp:admin + +# Cache settings +jwks_cache_duration=3600 +jwks_auto_refresh=true +request_timeout=30 + +# Auth bypass mode (for development/testing) +auth_disabled=true +``` + +### Configuration Options + +| Option | Description | Default | +|--------|-------------|---------| +| `host` | Bind address | `0.0.0.0` | +| `port` | Port number | `3001` | +| `server_url` | Public URL of this server | Derived from host:port | +| `auth_server_url` | OAuth/OIDC provider URL | (required for auth) | +| `client_id` | OAuth client ID | (required for registration) | +| `client_secret` | OAuth client secret | (required for registration) | +| `allowed_scopes` | Space-separated allowed scopes | `openid profile email` | +| `jwks_cache_duration` | JWKS cache TTL in seconds | `3600` | +| `auth_disabled` | Bypass authentication | `false` | + +## Available Endpoints + +### Health Check + +```bash +curl http://localhost:3001/health +``` + +Response: +```json +{ + "status": "ok", + "timestamp": "2024-01-15T10:30:00Z", + "version": "1.0.0", + "uptime": 123 +} +``` + +### OAuth Discovery + +```bash +# RFC 9728 - Protected Resource Metadata +curl http://localhost:3001/.well-known/oauth-protected-resource + +# RFC 8414 - Authorization Server Metadata +curl http://localhost:3001/.well-known/oauth-authorization-server + +# OIDC Discovery +curl http://localhost:3001/.well-known/openid-configuration +``` + +### MCP Endpoints + +```bash +# Initialize MCP session +curl -X POST http://localhost:3001/mcp \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' + +# List available tools +curl -X POST http://localhost:3001/mcp \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' + +# Call a tool (with auth) +curl -X POST http://localhost:3001/mcp \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_TOKEN" \ + -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get-weather","arguments":{"city":"London"}}}' +``` + +## Available Tools + +| Tool | Scope Required | Description | +|------|---------------|-------------| +| `get-weather` | (none) | Get current weather for a city | +| `get-forecast` | `mcp:read` | Get weather forecast (1-14 days) | +| `get-weather-alerts` | `mcp:admin` | Get weather alerts for a region | + +### Tool Examples + +```bash +# get-weather (no auth required) +curl -X POST http://localhost:3001/mcp \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get-weather","arguments":{"city":"Tokyo"}}}' + +# get-forecast (requires mcp:read scope) +curl -X POST http://localhost:3001/mcp \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer TOKEN_WITH_MCP_READ" \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get-forecast","arguments":{"city":"Paris","days":7}}}' + +# get-weather-alerts (requires mcp:admin scope) +curl -X POST http://localhost:3001/mcp \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer TOKEN_WITH_MCP_ADMIN" \ + -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get-weather-alerts","arguments":{"region":"California"}}}' +``` + +## Testing Without Authentication + +Set `auth_disabled=true` in config or use `--no-auth` flag: + +```bash +./auth-server --no-auth + +# All tools work without tokens +curl -X POST http://localhost:3001/mcp \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"get-weather-alerts","arguments":{"region":"Texas"}}}' +``` + +## Troubleshooting + +### "Native library not found" + +The native gopher-orch library is required for JWT validation: + +```bash +# Download using the run script +./run_example.sh + +# Or manually download +curl -sSL https://raw.githubusercontent.com/GopherSecurity/gopher-mcp-go/main/install-native.sh | bash -s -- latest ./native +``` + +Without the native library: +- Build without `-tags auth` for stub mode +- All auth checks are bypassed in stub mode +- Use `--no-auth` for explicit development mode + +### "Auth client creation failed" + +Check that: +1. `jwks_uri` points to a valid JWKS endpoint +2. `issuer` matches the token issuer +3. Network can reach the auth server + +### "Token validation failed" + +Ensure: +1. Token is not expired +2. Token issuer matches config +3. Token was signed by a key in JWKS +4. Required scopes are present in token + +### CGO Build Errors + +If you get CGO compilation errors: + +```bash +# Ensure native libraries are downloaded +ls -la ./native/lib/ + +# Set the required environment variables +export CGO_CFLAGS="-I./native/include" +export CGO_LDFLAGS="-L./native/lib -lgopher-orch" +export DYLD_LIBRARY_PATH="./native/lib:$DYLD_LIBRARY_PATH" # macOS +export LD_LIBRARY_PATH="./native/lib:$LD_LIBRARY_PATH" # Linux +``` + +## Project Structure + +``` +auth/ +├── config/ +│ ├── config.go # Configuration loader +│ └── config_test.go # Config tests +├── middleware/ +│ ├── oauth_auth.go # Auth middleware (auth build) +│ └── oauth_auth_stub.go # Stub middleware (default build) +├── routes/ +│ ├── health.go # Health endpoint +│ ├── mcp.go # MCP JSON-RPC handler +│ └── oauth.go # OAuth discovery endpoints +├── tools/ +│ ├── tool.go # Tool interface +│ └── weather.go # Example weather tools +├── native/ # Downloaded native libraries +│ ├── lib/ # .dylib/.so files +│ └── include/ # Header files +├── main.go # Entry point +├── go.mod # Go module definition +├── server.config # Example configuration +├── run_example.sh # Build and run script +└── README.md # This file +``` + +## SDK Documentation + +For more information about the gopher-mcp-go SDK: + +- Repository: https://github.com/GopherSecurity/gopher-mcp-go +- Documentation: https://pkg.go.dev/github.com/GopherSecurity/gopher-mcp-go diff --git a/examples/auth/config/config.go b/examples/auth/config/config.go new file mode 100644 index 00000000..b69f788e --- /dev/null +++ b/examples/auth/config/config.go @@ -0,0 +1,223 @@ +package config + +import ( + "bufio" + "fmt" + "os" + "strconv" + "strings" + "time" +) + +// AuthServerConfig holds the configuration for the auth MCP server +type AuthServerConfig struct { + // Server settings + Host string + Port int + ServerURL string + + // OAuth settings + AuthServerURL string + JwksURI string + Issuer string + ClientID string + ClientSecret string + OAuthAuthorizeURL string + OAuthTokenURL string + ExchangeIDPs []string + + // Scopes + AllowedScopes []string + + // Cache settings + JwksCacheDuration time.Duration + JwksAutoRefresh bool + RequestTimeout time.Duration + + // Auth bypass mode + AuthDisabled bool +} + +// parseConfigFile reads an INI-style configuration file and returns a map of key-value pairs +func parseConfigFile(path string) (map[string]string, error) { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("failed to open config file: %w", err) + } + defer file.Close() + + config := make(map[string]string) + scanner := bufio.NewScanner(file) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + // Skip empty lines and comments + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + // Parse key=value pairs + idx := strings.Index(line, "=") + if idx == -1 { + continue + } + + key := strings.TrimSpace(line[:idx]) + value := strings.TrimSpace(line[idx+1:]) + + if key != "" { + config[key] = value + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("error reading config file: %w", err) + } + + return config, nil +} + +// LoadConfigFromFile loads configuration from an INI-style config file +func LoadConfigFromFile(path string) (*AuthServerConfig, error) { + values, err := parseConfigFile(path) + if err != nil { + return nil, err + } + + config := CreateDefaultConfig() + + // Server settings + if v, ok := values["host"]; ok { + config.Host = v + } + if v, ok := values["port"]; ok { + if port, err := strconv.Atoi(v); err == nil { + config.Port = port + } + } + if v, ok := values["server_url"]; ok { + config.ServerURL = v + } + + // OAuth settings + if v, ok := values["auth_server_url"]; ok { + config.AuthServerURL = v + } + if v, ok := values["jwks_uri"]; ok { + config.JwksURI = v + } + if v, ok := values["issuer"]; ok { + config.Issuer = v + } + if v, ok := values["client_id"]; ok { + config.ClientID = v + } + if v, ok := values["client_secret"]; ok { + config.ClientSecret = v + } + if v, ok := values["oauth_authorize_url"]; ok { + config.OAuthAuthorizeURL = v + } + if v, ok := values["oauth_token_url"]; ok { + config.OAuthTokenURL = v + } + if v, ok := values["exchange_idps"]; ok && v != "" { + config.ExchangeIDPs = strings.Split(v, ",") + for i := range config.ExchangeIDPs { + config.ExchangeIDPs[i] = strings.TrimSpace(config.ExchangeIDPs[i]) + } + } + + // Scopes + if v, ok := values["allowed_scopes"]; ok && v != "" { + config.AllowedScopes = strings.Fields(v) + } + + // Cache settings + if v, ok := values["jwks_cache_duration"]; ok { + if seconds, err := strconv.Atoi(v); err == nil { + config.JwksCacheDuration = time.Duration(seconds) * time.Second + } + } + if v, ok := values["jwks_auto_refresh"]; ok { + config.JwksAutoRefresh = parseBool(v) + } + if v, ok := values["request_timeout"]; ok { + if seconds, err := strconv.Atoi(v); err == nil { + config.RequestTimeout = time.Duration(seconds) * time.Second + } + } + + // Auth bypass + if v, ok := values["auth_disabled"]; ok { + config.AuthDisabled = parseBool(v) + } + + // Derive endpoints from auth_server_url if not explicitly set + config.DeriveEndpoints() + + return config, nil +} + +// parseBool parses a boolean value from a string +// Accepts: "true", "1", "yes" (case-insensitive) -> true +// All other values -> false +func parseBool(value string) bool { + v := strings.ToLower(strings.TrimSpace(value)) + return v == "true" || v == "1" || v == "yes" +} + +// DeriveEndpoints derives OAuth endpoints from auth_server_url if they are not explicitly set +// Also derives server_url from host and port if not set +func (c *AuthServerConfig) DeriveEndpoints() { + // Derive OAuth endpoints from auth_server_url if not explicitly set + if c.AuthServerURL != "" { + baseURL := strings.TrimSuffix(c.AuthServerURL, "/") + + if c.JwksURI == "" { + c.JwksURI = baseURL + "/protocol/openid-connect/certs" + } + if c.Issuer == "" { + c.Issuer = baseURL + } + if c.OAuthAuthorizeURL == "" { + c.OAuthAuthorizeURL = baseURL + "/protocol/openid-connect/auth" + } + if c.OAuthTokenURL == "" { + c.OAuthTokenURL = baseURL + "/protocol/openid-connect/token" + } + } + + // Derive server_url from host and port if not set + if c.ServerURL == "" && c.Host != "" && c.Port > 0 { + // Use localhost instead of 0.0.0.0 for client-facing URLs + host := c.Host + if host == "0.0.0.0" { + host = "localhost" + } + c.ServerURL = fmt.Sprintf("http://%s:%d", host, c.Port) + } +} + +// CreateDefaultConfig returns a configuration with sensible defaults +func CreateDefaultConfig() *AuthServerConfig { + return &AuthServerConfig{ + Host: "0.0.0.0", + Port: 3001, + ServerURL: "", + AuthServerURL: "", + JwksURI: "", + Issuer: "", + ClientID: "", + ClientSecret: "", + OAuthAuthorizeURL: "", + OAuthTokenURL: "", + ExchangeIDPs: []string{}, + AllowedScopes: []string{"openid", "profile", "email"}, + JwksCacheDuration: 3600 * time.Second, + JwksAutoRefresh: true, + RequestTimeout: 30 * time.Second, + AuthDisabled: false, + } +} diff --git a/examples/auth/config/config_test.go b/examples/auth/config/config_test.go new file mode 100644 index 00000000..645b6535 --- /dev/null +++ b/examples/auth/config/config_test.go @@ -0,0 +1,278 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseConfigFile(t *testing.T) { + // Create temp config file + content := `# This is a comment +host=localhost +port=8080 + +# Another comment +client_id=test-client +client_secret=test-secret +` + tmpFile := createTempConfigFile(t, content) + defer os.Remove(tmpFile) + + values, err := parseConfigFile(tmpFile) + require.NoError(t, err) + + assert.Equal(t, "localhost", values["host"]) + assert.Equal(t, "8080", values["port"]) + assert.Equal(t, "test-client", values["client_id"]) + assert.Equal(t, "test-secret", values["client_secret"]) + + // Comments should not be in the map + _, hasComment := values["# This is a comment"] + assert.False(t, hasComment) +} + +func TestParseConfigFileHandlesEmptyLines(t *testing.T) { + content := ` + +host=localhost + + +port=8080 + +` + tmpFile := createTempConfigFile(t, content) + defer os.Remove(tmpFile) + + values, err := parseConfigFile(tmpFile) + require.NoError(t, err) + + assert.Equal(t, "localhost", values["host"]) + assert.Equal(t, "8080", values["port"]) + assert.Len(t, values, 2) +} + +func TestParseConfigFileFileNotFound(t *testing.T) { + _, err := parseConfigFile("/nonexistent/path/config.txt") + assert.Error(t, err) +} + +func TestLoadConfigFromFile(t *testing.T) { + content := `host=127.0.0.1 +port=3002 +server_url=https://myserver.com +auth_server_url=https://auth.example.com/realms/test +client_id=my-client +client_secret=my-secret +allowed_scopes=openid profile email mcp:read mcp:admin +jwks_cache_duration=7200 +jwks_auto_refresh=true +request_timeout=60 +auth_disabled=false +` + tmpFile := createTempConfigFile(t, content) + defer os.Remove(tmpFile) + + cfg, err := LoadConfigFromFile(tmpFile) + require.NoError(t, err) + + assert.Equal(t, "127.0.0.1", cfg.Host) + assert.Equal(t, 3002, cfg.Port) + assert.Equal(t, "https://myserver.com", cfg.ServerURL) + assert.Equal(t, "https://auth.example.com/realms/test", cfg.AuthServerURL) + assert.Equal(t, "my-client", cfg.ClientID) + assert.Equal(t, "my-secret", cfg.ClientSecret) + assert.Equal(t, []string{"openid", "profile", "email", "mcp:read", "mcp:admin"}, cfg.AllowedScopes) + assert.Equal(t, 7200*time.Second, cfg.JwksCacheDuration) + assert.True(t, cfg.JwksAutoRefresh) + assert.Equal(t, 60*time.Second, cfg.RequestTimeout) + assert.False(t, cfg.AuthDisabled) +} + +func TestEndpointDerivationFromAuthServerURL(t *testing.T) { + content := `auth_server_url=https://auth.example.com/realms/mcp +` + tmpFile := createTempConfigFile(t, content) + defer os.Remove(tmpFile) + + cfg, err := LoadConfigFromFile(tmpFile) + require.NoError(t, err) + + // Endpoints should be derived from auth_server_url + assert.Equal(t, "https://auth.example.com/realms/mcp/protocol/openid-connect/certs", cfg.JwksURI) + assert.Equal(t, "https://auth.example.com/realms/mcp", cfg.Issuer) + assert.Equal(t, "https://auth.example.com/realms/mcp/protocol/openid-connect/auth", cfg.OAuthAuthorizeURL) + assert.Equal(t, "https://auth.example.com/realms/mcp/protocol/openid-connect/token", cfg.OAuthTokenURL) +} + +func TestEndpointDerivationWithTrailingSlash(t *testing.T) { + content := `auth_server_url=https://auth.example.com/realms/mcp/ +` + tmpFile := createTempConfigFile(t, content) + defer os.Remove(tmpFile) + + cfg, err := LoadConfigFromFile(tmpFile) + require.NoError(t, err) + + // Trailing slash should be removed before deriving endpoints + assert.Equal(t, "https://auth.example.com/realms/mcp/protocol/openid-connect/certs", cfg.JwksURI) + assert.Equal(t, "https://auth.example.com/realms/mcp", cfg.Issuer) +} + +func TestEndpointDerivationExplicitOverride(t *testing.T) { + content := `auth_server_url=https://auth.example.com/realms/mcp +jwks_uri=https://custom.example.com/jwks +issuer=https://custom-issuer.example.com +` + tmpFile := createTempConfigFile(t, content) + defer os.Remove(tmpFile) + + cfg, err := LoadConfigFromFile(tmpFile) + require.NoError(t, err) + + // Explicit values should not be overridden + assert.Equal(t, "https://custom.example.com/jwks", cfg.JwksURI) + assert.Equal(t, "https://custom-issuer.example.com", cfg.Issuer) + + // But non-explicit values should still be derived + assert.Equal(t, "https://auth.example.com/realms/mcp/protocol/openid-connect/auth", cfg.OAuthAuthorizeURL) + assert.Equal(t, "https://auth.example.com/realms/mcp/protocol/openid-connect/token", cfg.OAuthTokenURL) +} + +func TestServerURLDerivationFromHostAndPort(t *testing.T) { + content := `host=localhost +port=8080 +` + tmpFile := createTempConfigFile(t, content) + defer os.Remove(tmpFile) + + cfg, err := LoadConfigFromFile(tmpFile) + require.NoError(t, err) + + assert.Equal(t, "http://localhost:8080", cfg.ServerURL) +} + +func TestServerURLNotDerivedWhenExplicit(t *testing.T) { + content := `host=localhost +port=8080 +server_url=https://myserver.example.com +` + tmpFile := createTempConfigFile(t, content) + defer os.Remove(tmpFile) + + cfg, err := LoadConfigFromFile(tmpFile) + require.NoError(t, err) + + assert.Equal(t, "https://myserver.example.com", cfg.ServerURL) +} + +func TestParseBool(t *testing.T) { + tests := []struct { + input string + expected bool + }{ + {"true", true}, + {"TRUE", true}, + {"True", true}, + {"1", true}, + {"yes", true}, + {"YES", true}, + {"Yes", true}, + {"false", false}, + {"FALSE", false}, + {"0", false}, + {"no", false}, + {"NO", false}, + {"", false}, + {"invalid", false}, + {" true ", true}, + {" false ", false}, + } + + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + result := parseBool(tc.input) + assert.Equal(t, tc.expected, result, "parseBool(%q) should be %v", tc.input, tc.expected) + }) + } +} + +func TestIntegerParsing(t *testing.T) { + content := `port=9999 +jwks_cache_duration=1800 +request_timeout=45 +` + tmpFile := createTempConfigFile(t, content) + defer os.Remove(tmpFile) + + cfg, err := LoadConfigFromFile(tmpFile) + require.NoError(t, err) + + assert.Equal(t, 9999, cfg.Port) + assert.Equal(t, 1800*time.Second, cfg.JwksCacheDuration) + assert.Equal(t, 45*time.Second, cfg.RequestTimeout) +} + +func TestCreateDefaultConfig(t *testing.T) { + cfg := CreateDefaultConfig() + + assert.Equal(t, "0.0.0.0", cfg.Host) + assert.Equal(t, 3001, cfg.Port) + assert.Equal(t, "", cfg.ServerURL) + assert.Equal(t, "", cfg.AuthServerURL) + assert.Equal(t, "", cfg.JwksURI) + assert.Equal(t, "", cfg.Issuer) + assert.Equal(t, "", cfg.ClientID) + assert.Equal(t, "", cfg.ClientSecret) + assert.Equal(t, "", cfg.OAuthAuthorizeURL) + assert.Equal(t, "", cfg.OAuthTokenURL) + assert.Empty(t, cfg.ExchangeIDPs) + assert.Equal(t, []string{"openid", "profile", "email"}, cfg.AllowedScopes) + assert.Equal(t, 3600*time.Second, cfg.JwksCacheDuration) + assert.True(t, cfg.JwksAutoRefresh) + assert.Equal(t, 30*time.Second, cfg.RequestTimeout) + assert.False(t, cfg.AuthDisabled) +} + +func TestAllowedScopesParsing(t *testing.T) { + content := `allowed_scopes=openid profile email mcp:read mcp:write +` + tmpFile := createTempConfigFile(t, content) + defer os.Remove(tmpFile) + + cfg, err := LoadConfigFromFile(tmpFile) + require.NoError(t, err) + + expected := []string{"openid", "profile", "email", "mcp:read", "mcp:write"} + assert.Equal(t, expected, cfg.AllowedScopes) +} + +func TestExchangeIDPsParsing(t *testing.T) { + content := `exchange_idps=google, github, keycloak +` + tmpFile := createTempConfigFile(t, content) + defer os.Remove(tmpFile) + + cfg, err := LoadConfigFromFile(tmpFile) + require.NoError(t, err) + + expected := []string{"google", "github", "keycloak"} + assert.Equal(t, expected, cfg.ExchangeIDPs) +} + +// Helper function to create a temporary config file +func createTempConfigFile(t *testing.T, content string) string { + t.Helper() + + tmpDir := os.TempDir() + tmpFile := filepath.Join(tmpDir, "test_config_"+t.Name()+".conf") + + err := os.WriteFile(tmpFile, []byte(content), 0644) + require.NoError(t, err) + + return tmpFile +} diff --git a/examples/auth/go.mod b/examples/auth/go.mod new file mode 100644 index 00000000..468c0818 --- /dev/null +++ b/examples/auth/go.mod @@ -0,0 +1,12 @@ +module github.com/GopherSecurity/gopher-mcp-go/examples/auth + +go 1.21 + +require github.com/stretchr/testify v1.11.1 + +require ( + github.com/GopherSecurity/gopher-mcp-go v0.1.2-5 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/examples/auth/go.sum b/examples/auth/go.sum new file mode 100644 index 00000000..6531c02c --- /dev/null +++ b/examples/auth/go.sum @@ -0,0 +1,7 @@ +github.com/GopherSecurity/gopher-mcp-go v0.1.2-4 h1:0Q6JjVMWxKf+qHhrP7M3WOcpYSlx2HtTyFsg9G8YlL4= +github.com/GopherSecurity/gopher-mcp-go v0.1.2-4/go.mod h1:Y5guI1etaepItxwPkIE3IoE2QvybjykgoqXYy+XE82c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/examples/auth/main.go b/examples/auth/main.go new file mode 100644 index 00000000..1d4ae060 --- /dev/null +++ b/examples/auth/main.go @@ -0,0 +1,172 @@ +//go:build auth + +package main + +import ( + "context" + "flag" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "strconv" + "syscall" + "time" + + "github.com/GopherSecurity/gopher-mcp-go/examples/auth/config" + "github.com/GopherSecurity/gopher-mcp-go/examples/auth/middleware" + "github.com/GopherSecurity/gopher-mcp-go/examples/auth/routes" + "github.com/GopherSecurity/gopher-mcp-go/examples/auth/tools" + "github.com/GopherSecurity/gopher-mcp-go/src/ffi" +) + +const version = "1.0.0" + +func main() { + // Parse command line flags + configPath := flag.String("config", "server.config", "Path to config file") + flag.StringVar(configPath, "c", "server.config", "Path to config file (shorthand)") + noAuth := flag.Bool("no-auth", false, "Disable authentication") + host := flag.String("host", "", "Override host from config") + port := flag.Int("port", 0, "Override port from config") + flag.Parse() + + // Load configuration + cfg, err := config.LoadConfigFromFile(*configPath) + if err != nil { + log.Printf("Warning: Could not load config file %s: %v", *configPath, err) + log.Println("Using default configuration") + cfg = config.CreateDefaultConfig() + } + + // Apply command line overrides + if *noAuth { + cfg.AuthDisabled = true + } + if *host != "" { + cfg.Host = *host + } + if *port > 0 { + cfg.Port = *port + } + + // Derive endpoints if needed + cfg.DeriveEndpoints() + + // Initialize auth library if auth is enabled + var authClient ffi.AuthClientHandle + authAvailable := ffi.IsAuthAvailable() + + if !cfg.AuthDisabled && authAvailable { + ffi.AuthInit() + defer ffi.AuthShutdown() + + // Create auth client + authClient = ffi.AuthClientCreate(cfg.JwksURI, cfg.Issuer) + if authClient != nil { + defer ffi.AuthClientDestroy(authClient) + + // Set client options + ffi.AuthClientSetOption(authClient, "cache_duration", + strconv.Itoa(int(cfg.JwksCacheDuration.Seconds()))) + ffi.AuthClientSetOption(authClient, "auto_refresh", + strconv.FormatBool(cfg.JwksAutoRefresh)) + ffi.AuthClientSetOption(authClient, "request_timeout", + strconv.Itoa(int(cfg.RequestTimeout.Seconds()))) + } + } + + // Create middleware + authMiddleware := middleware.NewOAuthAuthMiddleware(authClient, cfg) + + // Create MCP handler + mcpHandler := routes.NewMCPHandler() + + // Register weather tools + tools.RegisterWeatherTools(mcpHandler) + + // Setup HTTP routes + mux := http.NewServeMux() + + // Health endpoint + mux.HandleFunc("/health", routes.HealthHandler(version)) + + // OAuth routes + routes.RegisterOAuthRoutes(mux, cfg) + + // MCP endpoints with middleware + mux.Handle("/mcp", authMiddleware.Middleware(mcpHandler)) + mux.Handle("/rpc", authMiddleware.Middleware(mcpHandler)) + + // Print startup banner + printBanner(cfg, authAvailable && authClient != nil) + + // Create server + addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) + server := &http.Server{ + Addr: addr, + Handler: mux, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + } + + // Start server in goroutine + go func() { + log.Printf("Server starting on %s (listening on %s)", cfg.ServerURL, addr) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("Server error: %v", err) + } + }() + + // Wait for shutdown signal + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Println("Shutting down server...") + + // Graceful shutdown with timeout + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := server.Shutdown(ctx); err != nil { + log.Fatalf("Server forced to shutdown: %v", err) + } + + log.Println("Server stopped") +} + +func printBanner(cfg *config.AuthServerConfig, authAvailable bool) { + fmt.Println("========================================") + fmt.Println(" Gopher Auth MCP Server") + fmt.Printf(" Version: %s\n", version) + fmt.Println("========================================") + fmt.Println() + fmt.Println("Endpoints:") + fmt.Printf(" Health: %s/health\n", cfg.ServerURL) + fmt.Printf(" MCP: %s/mcp\n", cfg.ServerURL) + fmt.Printf(" RPC: %s/rpc\n", cfg.ServerURL) + fmt.Println() + fmt.Println("OAuth Discovery:") + fmt.Printf(" Protected Resource: %s/.well-known/oauth-protected-resource\n", cfg.ServerURL) + fmt.Printf(" Auth Server: %s/.well-known/oauth-authorization-server\n", cfg.ServerURL) + fmt.Printf(" OpenID Config: %s/.well-known/openid-configuration\n", cfg.ServerURL) + fmt.Println() + fmt.Println("Authentication:") + if cfg.AuthDisabled { + fmt.Println(" Status: DISABLED (all requests bypass auth)") + } else if !authAvailable { + fmt.Println(" Status: DISABLED (auth client creation failed)") + } else { + fmt.Println(" Status: ENABLED") + fmt.Printf(" Auth Server: %s\n", cfg.AuthServerURL) + fmt.Printf(" JWKS URI: %s\n", cfg.JwksURI) + } + fmt.Println() + fmt.Println("Tools:") + fmt.Println(" get-weather (no scope required)") + fmt.Println(" get-forecast (requires mcp:read)") + fmt.Println(" get-weather-alerts (requires mcp:admin)") + fmt.Println() +} diff --git a/examples/auth/middleware/oauth_auth.go b/examples/auth/middleware/oauth_auth.go new file mode 100644 index 00000000..0ea90373 --- /dev/null +++ b/examples/auth/middleware/oauth_auth.go @@ -0,0 +1,178 @@ +package middleware + +import ( + "context" + "encoding/json" + "net/http" + "strings" + + "github.com/GopherSecurity/gopher-mcp-go/examples/auth/config" + "github.com/GopherSecurity/gopher-mcp-go/examples/auth/routes" + "github.com/GopherSecurity/gopher-mcp-go/src/ffi" +) + +// OAuthAuthMiddleware handles OAuth token validation +type OAuthAuthMiddleware struct { + authClient ffi.AuthClientHandle + config *config.AuthServerConfig +} + +// NewOAuthAuthMiddleware creates a new OAuth authentication middleware +func NewOAuthAuthMiddleware(client ffi.AuthClientHandle, cfg *config.AuthServerConfig) *OAuthAuthMiddleware { + return &OAuthAuthMiddleware{ + authClient: client, + config: cfg, + } +} + +// extractToken extracts the bearer token from the request +// Checks Authorization header first, then access_token query parameter +func extractToken(r *http.Request) string { + // Check Authorization header + authHeader := r.Header.Get("Authorization") + if authHeader != "" { + // Format: "Bearer {token}" + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") { + return strings.TrimSpace(parts[1]) + } + } + + // Check query parameter + token := r.URL.Query().Get("access_token") + if token != "" { + return token + } + + return "" +} + +// isPublicPath checks if the path does not require authentication +func isPublicPath(path string) bool { + publicPrefixes := []string{ + "/.well-known/", + "/oauth/", + } + + publicPaths := []string{ + "/health", + "/authorize", + } + + // Check prefixes + for _, prefix := range publicPrefixes { + if strings.HasPrefix(path, prefix) { + return true + } + } + + // Check exact paths + for _, p := range publicPaths { + if path == p { + return true + } + } + + return false +} + +// CreateBypassAuthContext creates an AuthContext for when auth is disabled +func CreateBypassAuthContext() *routes.AuthContext { + return &routes.AuthContext{ + UserID: "anonymous", + Scopes: []string{"openid", "profile", "email", "mcp:read", "mcp:admin"}, + Audience: "", + TokenExpiry: 0, + Authenticated: false, + } +} + +// Middleware returns an HTTP middleware that validates OAuth tokens +func (m *OAuthAuthMiddleware) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Handle OPTIONS preflight requests - always allow with CORS headers + if r.Method == http.MethodOptions { + setCorsHeaders(w) + w.WriteHeader(http.StatusNoContent) + return + } + + // Skip auth for public paths + if isPublicPath(r.URL.Path) { + next.ServeHTTP(w, r) + return + } + + // Skip auth if disabled in config + if m.config.AuthDisabled { + authCtx := CreateBypassAuthContext() + ctx := context.WithValue(r.Context(), routes.AuthContextKey{}, authCtx) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + + // Extract token from request + token := extractToken(r) + if token == "" { + sendUnauthorized(w, "Missing bearer token") + return + } + + // Validate token using FFI + result := ffi.AuthValidateToken(m.authClient, token) + if !result.Valid { + sendUnauthorized(w, result.ErrorMessage) + return + } + + // Decode payload to get claims + payload := ffi.AuthDecodeToken(token) + if payload == nil { + sendUnauthorized(w, "Failed to decode token") + return + } + defer ffi.AuthPayloadDestroy(payload) + + // Parse scopes from space-separated string + scopeStr := ffi.AuthPayloadGetScope(payload) + var scopes []string + if scopeStr != "" { + scopes = strings.Fields(scopeStr) + } + + // Create auth context from payload + authCtx := &routes.AuthContext{ + UserID: ffi.AuthPayloadGetSubject(payload), + Scopes: scopes, + Audience: ffi.AuthPayloadGetAudience(payload), + TokenExpiry: ffi.AuthPayloadGetExpiry(payload), + Authenticated: true, + } + + // Store auth context in request context + ctx := context.WithValue(r.Context(), routes.AuthContextKey{}, authCtx) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// setCorsHeaders sets CORS headers on responses +func setCorsHeaders(w http.ResponseWriter) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Accept, Accept-Language, Content-Language, Content-Type, Authorization, X-Requested-With, Origin, Cache-Control, Pragma, Mcp-Session-Id, Mcp-Protocol-Version") + w.Header().Set("Access-Control-Max-Age", "86400") +} + +// sendUnauthorized sends a 401 Unauthorized response +func sendUnauthorized(w http.ResponseWriter, message string) { + setCorsHeaders(w) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("WWW-Authenticate", `Bearer realm="mcp", error="invalid_token"`) + w.WriteHeader(http.StatusUnauthorized) + + response := map[string]string{ + "error": "unauthorized", + "message": message, + } + json.NewEncoder(w).Encode(response) +} diff --git a/examples/auth/routes/health.go b/examples/auth/routes/health.go new file mode 100644 index 00000000..8feb2616 --- /dev/null +++ b/examples/auth/routes/health.go @@ -0,0 +1,41 @@ +package routes + +import ( + "encoding/json" + "net/http" + "time" +) + +var serverStartTime = time.Now() + +// HealthResponse represents the health check response +type HealthResponse struct { + Status string `json:"status"` + Timestamp string `json:"timestamp"` + Version string `json:"version"` + Uptime int64 `json:"uptime"` +} + +// HealthHandler returns an http.HandlerFunc that serves health check requests +func HealthHandler(version string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + handleCORS(w) + return + } + + uptime := int64(time.Since(serverStartTime).Seconds()) + + response := HealthResponse{ + Status: "ok", + Timestamp: time.Now().UTC().Format(time.RFC3339), + Version: version, + Uptime: uptime, + } + + setCorsHeaders(w) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) + } +} diff --git a/examples/auth/routes/mcp.go b/examples/auth/routes/mcp.go new file mode 100644 index 00000000..c5fd332c --- /dev/null +++ b/examples/auth/routes/mcp.go @@ -0,0 +1,342 @@ +package routes + +import ( + "encoding/json" + "net/http" + + "github.com/GopherSecurity/gopher-mcp-go/examples/auth/tools" +) + +// JSON-RPC 2.0 error codes +const ( + ParseError = -32700 + InvalidRequest = -32600 + MethodNotFound = -32601 + InvalidParams = -32602 + InternalError = -32603 +) + +// JSONRPCRequest represents a JSON-RPC 2.0 request +type JSONRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + ID interface{} `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` +} + +// JSONRPCResponse represents a JSON-RPC 2.0 response +type JSONRPCResponse struct { + JSONRPC string `json:"jsonrpc"` + ID interface{} `json:"id"` + Result interface{} `json:"result,omitempty"` + Error *RPCError `json:"error,omitempty"` +} + +// RPCError represents a JSON-RPC 2.0 error object +type RPCError struct { + Code int `json:"code"` + Message string `json:"message"` + Data interface{} `json:"data,omitempty"` +} + +// sendError sends a JSON-RPC error response +func sendError(w http.ResponseWriter, id interface{}, code int, message string) { + response := JSONRPCResponse{ + JSONRPC: "2.0", + ID: id, + Error: &RPCError{ + Code: code, + Message: message, + }, + } + + setCorsHeaders(w) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) +} + +// sendErrorWithData sends a JSON-RPC error response with additional data +func sendErrorWithData(w http.ResponseWriter, id interface{}, code int, message string, data interface{}) { + response := JSONRPCResponse{ + JSONRPC: "2.0", + ID: id, + Error: &RPCError{ + Code: code, + Message: message, + Data: data, + }, + } + + setCorsHeaders(w) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) +} + +// sendResult sends a JSON-RPC success response +func sendResult(w http.ResponseWriter, id interface{}, result interface{}) { + response := JSONRPCResponse{ + JSONRPC: "2.0", + ID: id, + Result: result, + } + + setCorsHeaders(w) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) +} + +// MCPHandler handles MCP JSON-RPC requests +type MCPHandler struct { + tools map[string]tools.Tool +} + +// NewMCPHandler creates a new MCP handler +func NewMCPHandler() *MCPHandler { + return &MCPHandler{ + tools: make(map[string]tools.Tool), + } +} + +// RegisterTool registers a tool with the MCP handler +func (h *MCPHandler) RegisterTool(tool tools.Tool) { + h.tools[tool.Name()] = tool +} + +// ServeHTTP implements http.Handler interface +func (h *MCPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + handleCORS(w) + return + } + + if r.Method != http.MethodPost { + w.Header().Set("Allow", "POST, OPTIONS") + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req JSONRPCRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendError(w, nil, ParseError, "Parse error: "+err.Error()) + return + } + + // Validate JSON-RPC version + if req.JSONRPC != "2.0" { + sendError(w, req.ID, InvalidRequest, "Invalid JSON-RPC version") + return + } + + // Route to appropriate handler based on method + switch req.Method { + case "initialize": + h.handleInitialize(w, req) + case "ping": + h.handlePing(w, req) + case "tools/list": + h.handleToolsList(w, req) + case "tools/call": + h.handleToolsCall(w, r, req) + default: + sendError(w, req.ID, MethodNotFound, "Method not found: "+req.Method) + } +} + +// InitializeResult represents the initialize response +type InitializeResult struct { + ProtocolVersion string `json:"protocolVersion"` + Capabilities Capabilities `json:"capabilities"` + ServerInfo ServerInfo `json:"serverInfo"` +} + +// Capabilities represents server capabilities +type Capabilities struct { + Tools map[string]interface{} `json:"tools"` +} + +// ServerInfo represents server information +type ServerInfo struct { + Name string `json:"name"` + Version string `json:"version"` +} + +// handleInitialize handles the initialize method +func (h *MCPHandler) handleInitialize(w http.ResponseWriter, req JSONRPCRequest) { + result := InitializeResult{ + ProtocolVersion: "2024-11-05", + Capabilities: Capabilities{ + Tools: map[string]interface{}{}, + }, + ServerInfo: ServerInfo{ + Name: "gopher-auth-mcp-server", + Version: "1.0.0", + }, + } + + sendResult(w, req.ID, result) +} + +// handlePing handles the ping method +func (h *MCPHandler) handlePing(w http.ResponseWriter, req JSONRPCRequest) { + sendResult(w, req.ID, map[string]interface{}{}) +} + +// ToolsListResult represents the tools/list response +type ToolsListResult struct { + Tools []tools.ToolInfo `json:"tools"` +} + +// handleToolsList handles the tools/list method +func (h *MCPHandler) handleToolsList(w http.ResponseWriter, req JSONRPCRequest) { + toolList := make([]tools.ToolInfo, 0, len(h.tools)) + + for _, tool := range h.tools { + toolList = append(toolList, tools.ToolInfo{ + Name: tool.Name(), + Description: tool.Description(), + InputSchema: tool.InputSchema(), + }) + } + + result := ToolsListResult{ + Tools: toolList, + } + + sendResult(w, req.ID, result) +} + +// ToolsCallParams represents the params for tools/call +type ToolsCallParams struct { + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` +} + +// ToolCallResult represents the result of a tool call +type ToolCallResult struct { + Content []ContentItem `json:"content"` + IsError bool `json:"isError,omitempty"` +} + +// ContentItem represents a content item in the tool result +type ContentItem struct { + Type string `json:"type"` + Text string `json:"text"` +} + +// AuthContextKey is the context key for storing auth context +type AuthContextKey struct{} + +// AuthContext represents authentication context (defined here for use, implemented in middleware) +type AuthContext struct { + UserID string + Scopes []string + Audience string + TokenExpiry int64 + Authenticated bool +} + +// HasScope checks if the auth context has a specific scope +func (ctx *AuthContext) HasScope(scope string) bool { + if ctx == nil { + return false + } + for _, s := range ctx.Scopes { + if s == scope { + return true + } + } + return false +} + +// GetAuthContext retrieves the auth context from the request context +func GetAuthContext(r *http.Request) *AuthContext { + ctx := r.Context().Value(AuthContextKey{}) + if ctx == nil { + return nil + } + authCtx, ok := ctx.(*AuthContext) + if !ok { + return nil + } + return authCtx +} + +// handleToolsCall handles the tools/call method +func (h *MCPHandler) handleToolsCall(w http.ResponseWriter, r *http.Request, req JSONRPCRequest) { + // Parse params + var params ToolsCallParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + sendError(w, req.ID, InvalidParams, "Invalid params: "+err.Error()) + return + } + + // Look up tool in registry + tool, exists := h.tools[params.Name] + if !exists { + sendError(w, req.ID, InvalidParams, "Tool not found: "+params.Name) + return + } + + // Get auth context from request + authCtx := GetAuthContext(r) + + // Check if user has required scope for the tool + requiredScope := tool.RequiredScope() + if requiredScope != "" { + if authCtx == nil || !authCtx.HasScope(requiredScope) { + sendAccessDenied(w, req.ID, requiredScope) + return + } + } + + // Execute tool handler + result, err := tool.Execute(params.Arguments) + if err != nil { + // Return error as tool result + errorJSON, _ := json.Marshal(map[string]string{ + "error": "execution_error", + "message": err.Error(), + }) + toolResult := ToolCallResult{ + Content: []ContentItem{ + {Type: "text", Text: string(errorJSON)}, + }, + IsError: true, + } + sendResult(w, req.ID, toolResult) + return + } + + // Wrap result in content array + resultJSON, err := json.Marshal(result) + if err != nil { + sendError(w, req.ID, InternalError, "Failed to marshal result: "+err.Error()) + return + } + + toolResult := ToolCallResult{ + Content: []ContentItem{ + {Type: "text", Text: string(resultJSON)}, + }, + } + sendResult(w, req.ID, toolResult) +} + +// sendAccessDenied sends an access denied response for missing scope +func sendAccessDenied(w http.ResponseWriter, id interface{}, scope string) { + errorJSON, _ := json.Marshal(map[string]string{ + "error": "access_denied", + "message": "Required scope: " + scope, + }) + + result := ToolCallResult{ + Content: []ContentItem{ + {Type: "text", Text: string(errorJSON)}, + }, + IsError: true, + } + sendResult(w, id, result) +} diff --git a/examples/auth/routes/oauth.go b/examples/auth/routes/oauth.go new file mode 100644 index 00000000..bbfa0092 --- /dev/null +++ b/examples/auth/routes/oauth.go @@ -0,0 +1,239 @@ +package routes + +import ( + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/GopherSecurity/gopher-mcp-go/examples/auth/config" +) + +// ProtectedResourceResponse represents the RFC 9728 OAuth Protected Resource response +type ProtectedResourceResponse struct { + Resource string `json:"resource"` + AuthorizationServers []string `json:"authorization_servers"` + ScopesSupported []string `json:"scopes_supported"` + BearerMethodsSupported []string `json:"bearer_methods_supported"` + ResourceDocumentation string `json:"resource_documentation"` +} + +// AuthorizationServerResponse represents the RFC 8414 OAuth Authorization Server Metadata +type AuthorizationServerResponse struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + JwksURI string `json:"jwks_uri"` + RegistrationEndpoint string `json:"registration_endpoint"` + ScopesSupported []string `json:"scopes_supported"` + ResponseTypesSupported []string `json:"response_types_supported"` + GrantTypesSupported []string `json:"grant_types_supported"` + TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"` + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` +} + +// OpenIDConfigurationResponse represents the OIDC Discovery response +type OpenIDConfigurationResponse struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + JwksURI string `json:"jwks_uri"` + RegistrationEndpoint string `json:"registration_endpoint"` + UserinfoEndpoint string `json:"userinfo_endpoint"` + ScopesSupported []string `json:"scopes_supported"` + ResponseTypesSupported []string `json:"response_types_supported"` + GrantTypesSupported []string `json:"grant_types_supported"` + TokenEndpointAuthMethodsSupported []string `json:"token_endpoint_auth_methods_supported"` + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` + SubjectTypesSupported []string `json:"subject_types_supported"` + IDTokenSigningAlgValuesSupported []string `json:"id_token_signing_alg_values_supported"` +} + +// ClientRegistrationRequest represents a client registration request (RFC 7591) +type ClientRegistrationRequest struct { + RedirectURIs []string `json:"redirect_uris"` +} + +// ClientRegistrationResponse represents a client registration response (RFC 7591) +type ClientRegistrationResponse struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + RedirectURIs []string `json:"redirect_uris"` + ClientIDIssuedAt int64 `json:"client_id_issued_at"` + ClientSecretExpiresAt int64 `json:"client_secret_expires_at"` +} + +// RegisterOAuthRoutes registers OAuth discovery endpoints on the provided ServeMux +func RegisterOAuthRoutes(mux *http.ServeMux, cfg *config.AuthServerConfig) { + mux.HandleFunc("/.well-known/oauth-protected-resource", protectedResourceHandler(cfg)) + mux.HandleFunc("/.well-known/oauth-protected-resource/mcp", protectedResourceHandler(cfg)) + mux.HandleFunc("/.well-known/oauth-authorization-server", authorizationServerHandler(cfg)) + mux.HandleFunc("/.well-known/openid-configuration", openIDConfigurationHandler(cfg)) + mux.HandleFunc("/oauth/authorize", authorizeHandler(cfg)) + mux.HandleFunc("/oauth/register", registerHandler(cfg)) +} + +// protectedResourceHandler handles RFC 9728 OAuth Protected Resource discovery +func protectedResourceHandler(cfg *config.AuthServerConfig) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + handleCORS(w) + return + } + + response := ProtectedResourceResponse{ + Resource: cfg.ServerURL + "/mcp", + AuthorizationServers: []string{cfg.ServerURL}, + ScopesSupported: cfg.AllowedScopes, + BearerMethodsSupported: []string{"header", "query"}, + ResourceDocumentation: cfg.ServerURL + "/docs", + } + + writeJSONResponse(w, response) + } +} + +// authorizationServerHandler handles RFC 8414 OAuth Authorization Server Metadata +func authorizationServerHandler(cfg *config.AuthServerConfig) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + handleCORS(w) + return + } + + // Use ServerURL as fallback for issuer if not set + issuer := cfg.Issuer + if issuer == "" { + issuer = cfg.ServerURL + } + + response := AuthorizationServerResponse{ + Issuer: issuer, + AuthorizationEndpoint: cfg.OAuthAuthorizeURL, + TokenEndpoint: cfg.OAuthTokenURL, + JwksURI: cfg.JwksURI, + RegistrationEndpoint: cfg.ServerURL + "/oauth/register", + ScopesSupported: cfg.AllowedScopes, + ResponseTypesSupported: []string{"code"}, + GrantTypesSupported: []string{"authorization_code", "refresh_token"}, + TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post", "none"}, + CodeChallengeMethodsSupported: []string{"S256"}, + } + + writeJSONResponse(w, response) + } +} + +// writeJSONResponse writes a JSON response with appropriate headers +func writeJSONResponse(w http.ResponseWriter, data interface{}) { + setCorsHeaders(w) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(data) +} + +// setCorsHeaders sets CORS headers on responses +func setCorsHeaders(w http.ResponseWriter) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Accept, Accept-Language, Content-Language, Content-Type, Authorization, X-Requested-With, Origin, Cache-Control, Pragma, Mcp-Session-Id, Mcp-Protocol-Version") + w.Header().Set("Access-Control-Max-Age", "86400") +} + +// handleCORS handles OPTIONS preflight requests +func handleCORS(w http.ResponseWriter) { + setCorsHeaders(w) + w.WriteHeader(http.StatusNoContent) +} + +// openIDConfigurationHandler handles OIDC Discovery endpoint +func openIDConfigurationHandler(cfg *config.AuthServerConfig) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + handleCORS(w) + return + } + + // Use ServerURL as fallback for issuer if not set + issuer := cfg.Issuer + if issuer == "" { + issuer = cfg.ServerURL + } + + // Derive userinfo endpoint from auth_server_url + var userinfoEndpoint string + if cfg.AuthServerURL != "" { + userinfoEndpoint = strings.TrimSuffix(cfg.AuthServerURL, "/") + "/protocol/openid-connect/userinfo" + } + + response := OpenIDConfigurationResponse{ + Issuer: issuer, + AuthorizationEndpoint: cfg.OAuthAuthorizeURL, + TokenEndpoint: cfg.OAuthTokenURL, + JwksURI: cfg.JwksURI, + RegistrationEndpoint: cfg.ServerURL + "/oauth/register", + UserinfoEndpoint: userinfoEndpoint, + ScopesSupported: cfg.AllowedScopes, + ResponseTypesSupported: []string{"code"}, + GrantTypesSupported: []string{"authorization_code", "refresh_token"}, + TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post", "none"}, + CodeChallengeMethodsSupported: []string{"S256"}, + SubjectTypesSupported: []string{"public"}, + IDTokenSigningAlgValuesSupported: []string{"RS256"}, + } + + writeJSONResponse(w, response) + } +} + +// authorizeHandler redirects to the OAuth authorization endpoint with all query parameters +func authorizeHandler(cfg *config.AuthServerConfig) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + handleCORS(w) + return + } + + // Build redirect URL with all query parameters forwarded + redirectURL := cfg.OAuthAuthorizeURL + if r.URL.RawQuery != "" { + redirectURL += "?" + r.URL.RawQuery + } + + setCorsHeaders(w) + http.Redirect(w, r, redirectURL, http.StatusFound) + } +} + +// registerHandler handles RFC 7591 Dynamic Client Registration (stateless mode) +func registerHandler(cfg *config.AuthServerConfig) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions { + handleCORS(w) + return + } + + if r.Method != http.MethodPost { + w.Header().Set("Allow", "POST, OPTIONS") + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req ClientRegistrationRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid JSON body", http.StatusBadRequest) + return + } + + // Stateless mode: always return the same client credentials from config + response := ClientRegistrationResponse{ + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + RedirectURIs: req.RedirectURIs, + ClientIDIssuedAt: time.Now().Unix(), + ClientSecretExpiresAt: 0, // Never expires + } + + writeJSONResponse(w, response) + } +} diff --git a/examples/auth/run_example.sh b/examples/auth/run_example.sh new file mode 100755 index 00000000..6a60eb78 --- /dev/null +++ b/examples/auth/run_example.sh @@ -0,0 +1,294 @@ +#!/bin/bash + +# Gopher Auth MCP Server - Run Script +# This script downloads dependencies and runs the Go auth example server +# Works as a standalone third-party example + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +# Configuration +SDK_VERSION="${SDK_VERSION:-latest}" +NATIVE_LIB_DIR="${NATIVE_LIB_DIR:-$SCRIPT_DIR/native/lib}" +NATIVE_INCLUDE_DIR="${NATIVE_INCLUDE_DIR:-$SCRIPT_DIR/native/include}" +GITHUB_REPO="GopherSecurity/gopher-mcp-go" + +# Print usage +usage() { + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Options:" + echo " --no-auth Disable authentication (bypass all auth checks)" + echo " --host HOST Override host from config" + echo " --port PORT Override port from config" + echo " --config FILE Path to config file (default: server.config)" + echo " --skip-download Skip native library download (use existing)" + echo " --help Show this help message" + echo "" + echo "Environment Variables:" + echo " SDK_VERSION Version of gopher-mcp-go SDK (default: latest)" + echo " Automatically fetches latest from GitHub releases" + echo " NATIVE_LIB_DIR Directory for native libraries (default: ./native/lib)" + echo "" + echo "Examples:" + echo " $0 # Run with latest SDK version" + echo " $0 --no-auth # Run with auth disabled" + echo " $0 --port 8080 # Run on port 8080" + echo " SDK_VERSION=v0.1.2 $0 # Use specific SDK version" + echo "" + echo "Note: Running this script triggers 'go get' which caches the module" + echo " on proxy.golang.org and indexes it on pkg.go.dev" + echo "" +} + +# Check Go version +check_go_version() { + if ! command -v go &> /dev/null; then + echo -e "${RED}Error: Go is not installed${NC}" + echo "Please install Go 1.21 or later from https://golang.org/dl/" + exit 1 + fi + + GO_VERSION=$(go version | grep -oE 'go[0-9]+\.[0-9]+' | sed 's/go//') + MAJOR=$(echo "$GO_VERSION" | cut -d. -f1) + MINOR=$(echo "$GO_VERSION" | cut -d. -f2) + + if [ "$MAJOR" -lt 1 ] || ([ "$MAJOR" -eq 1 ] && [ "$MINOR" -lt 21 ]); then + echo -e "${RED}Error: Go 1.21 or later is required (found $GO_VERSION)${NC}" + exit 1 + fi + + echo -e "${GREEN}Go version: $GO_VERSION${NC}" +} + +# Check for gh CLI +check_gh_cli() { + if ! command -v gh &> /dev/null; then + echo -e "${RED}Error: GitHub CLI (gh) is not installed${NC}" + echo "Install it with: brew install gh" + echo "Then authenticate: gh auth login" + exit 1 + fi +} + +# Download native library from gopher-mcp-go releases +download_native_library() { + echo -e "${YELLOW}Downloading native library ($SDK_VERSION)...${NC}" + + # Detect platform + OS=$(uname -s | tr '[:upper:]' '[:lower:]') + ARCH=$(uname -m) + + case "$OS" in + darwin) OS_NAME="macos" ;; + linux) OS_NAME="linux" ;; + mingw*|msys*|cygwin*) OS_NAME="windows" ;; + *) echo -e "${RED}Error: Unsupported OS: $OS${NC}"; exit 1 ;; + esac + + case "$ARCH" in + x86_64|amd64) ARCH_NAME="x64" ;; + arm64|aarch64) ARCH_NAME="arm64" ;; + *) echo -e "${RED}Error: Unsupported architecture: $ARCH${NC}"; exit 1 ;; + esac + + PLATFORM="${OS_NAME}-${ARCH_NAME}" + + # Determine file extension + if [ "$OS_NAME" = "windows" ]; then + ARCHIVE_EXT="zip" + else + ARCHIVE_EXT="tar.gz" + fi + + ARCHIVE_NAME="libgopher-orch-${PLATFORM}.${ARCHIVE_EXT}" + + echo -e " Platform: ${GREEN}${PLATFORM}${NC}" + echo -e " Archive: ${GREEN}${ARCHIVE_NAME}${NC}" + + # Create temp directory + TEMP_DIR=$(mktemp -d) + trap "rm -rf $TEMP_DIR" EXIT + + cd "$TEMP_DIR" + + # Download from gopher-mcp-go releases + gh release download "$SDK_VERSION" \ + -R "$GITHUB_REPO" \ + -p "$ARCHIVE_NAME" || { + echo -e "${RED}Error: Could not download $ARCHIVE_NAME${NC}" + echo "" + echo "Available assets for $SDK_VERSION:" + gh release view "$SDK_VERSION" -R "$GITHUB_REPO" --json assets -q '.assets[].name' 2>/dev/null || echo " (could not list assets)" + exit 1 + } + + echo -e "${GREEN}Downloaded${NC}" + + # Extract + echo -e "${YELLOW}Extracting...${NC}" + + if [ "$ARCHIVE_EXT" = "zip" ]; then + unzip -o "$ARCHIVE_NAME" + else + tar -xzf "$ARCHIVE_NAME" + fi + + # Create directories + mkdir -p "$NATIVE_LIB_DIR" + mkdir -p "$NATIVE_INCLUDE_DIR" + + # Copy libraries + if [ -d "lib" ]; then + cp -P lib/* "$NATIVE_LIB_DIR/" 2>/dev/null || true + fi + + # Copy headers + if [ -d "include" ]; then + cp -r include/* "$NATIVE_INCLUDE_DIR/" 2>/dev/null || true + fi + + # Handle flat structure (files directly in archive) + cp -P *.dylib "$NATIVE_LIB_DIR/" 2>/dev/null || true + cp -P *.so* "$NATIVE_LIB_DIR/" 2>/dev/null || true + cp -P *.dll "$NATIVE_LIB_DIR/" 2>/dev/null || true + cp -P *.h "$NATIVE_INCLUDE_DIR/" 2>/dev/null || true + + cd "$SCRIPT_DIR" + + echo -e "${GREEN}Native library installed to $NATIVE_LIB_DIR${NC}" +} + +# Check if native library exists +check_native_library() { + if [ -f "$NATIVE_LIB_DIR/libgopher-orch.dylib" ] || [ -f "$NATIVE_LIB_DIR/libgopher-orch.so" ] || \ + [ -f "$NATIVE_LIB_DIR/libgopher-orch.0.dylib" ] || [ -f "$NATIVE_LIB_DIR/libgopher-orch.0.so" ]; then + echo -e "${GREEN}Native library found at $NATIVE_LIB_DIR${NC}" + return 0 + fi + return 1 +} + +# Parse arguments +SERVER_ARGS="" +SKIP_DOWNLOAD=false + +while [[ $# -gt 0 ]]; do + case $1 in + --help|-h) + usage + exit 0 + ;; + --no-auth) + SERVER_ARGS="$SERVER_ARGS --no-auth" + shift + ;; + --host) + SERVER_ARGS="$SERVER_ARGS --host $2" + shift 2 + ;; + --port) + SERVER_ARGS="$SERVER_ARGS --port $2" + shift 2 + ;; + --config|-c) + SERVER_ARGS="$SERVER_ARGS --config $2" + shift 2 + ;; + --skip-download) + SKIP_DOWNLOAD=true + shift + ;; + *) + echo -e "${RED}Unknown option: $1${NC}" + usage + exit 1 + ;; + esac +done + +echo "=========================================" +echo " Gopher Auth MCP Server" +echo "=========================================" +echo "" + +# Check Go version +check_go_version + +# Download native library if needed +if [ "$SKIP_DOWNLOAD" = false ]; then + if check_native_library; then + echo -e "${YELLOW}Using existing native library. Use --skip-download=false to re-download.${NC}" + else + check_gh_cli + download_native_library + fi +else + if ! check_native_library; then + echo -e "${RED}Error: Native library not found and --skip-download specified${NC}" + exit 1 + fi +fi + +echo "" + +# Set environment for CGO +export CGO_CFLAGS="-I${NATIVE_INCLUDE_DIR}" +export CGO_LDFLAGS="-L${NATIVE_LIB_DIR} -lgopher-orch -lgopher-mcp -lgopher-mcp-event " +export DYLD_LIBRARY_PATH="${NATIVE_LIB_DIR}:${DYLD_LIBRARY_PATH}" +export LD_LIBRARY_PATH="${NATIVE_LIB_DIR}:${LD_LIBRARY_PATH}" + +# Get latest SDK version and update go.mod +echo "Fetching latest gopher-mcp-go version..." +if [ "$SDK_VERSION" = "latest" ]; then + SDK_VERSION=$(gh release view -R "$GITHUB_REPO" --json tagName -q '.tagName' 2>/dev/null) || { + echo -e "${YELLOW}Warning: Could not fetch latest version, using go.mod version${NC}" + SDK_VERSION="" + } +fi + +if [ -n "$SDK_VERSION" ]; then + echo -e " SDK version: ${GREEN}${SDK_VERSION}${NC}" + + # Remove old SDK version from go.mod to avoid version conflict + # (old versions may have incorrect module path) + go mod edit -droprequire=github.com/GopherSecurity/gopher-mcp-go 2>/dev/null || true + rm -f go.sum + + # Update go.mod to use the specified version + echo "Updating go.mod to use ${SDK_VERSION}..." + go get "github.com/GopherSecurity/gopher-mcp-go@${SDK_VERSION}" + echo -e "${GREEN}SDK updated to ${SDK_VERSION}${NC}" + + # Trigger pkg.go.dev indexing by fetching module info via proxy + echo "Triggering pkg.go.dev indexing..." + GOPROXY=proxy.golang.org go list -m "github.com/GopherSecurity/gopher-mcp-go@${SDK_VERSION}" >/dev/null 2>&1 && \ + echo -e "${GREEN}pkg.go.dev indexing triggered${NC}" || \ + echo -e "${YELLOW}Warning: Could not trigger pkg.go.dev indexing${NC}" +else + echo "Using existing go.mod version" + go mod download +fi + +echo -e "${GREEN}Dependencies downloaded${NC}" + +# Build the server +echo "Building server..." +go build -tags auth -o auth-server . + +echo -e "${GREEN}Build successful${NC}" +echo "" + +# Run the server +echo "Starting server..." +echo "" +./auth-server $SERVER_ARGS diff --git a/examples/auth/server.config b/examples/auth/server.config new file mode 100644 index 00000000..304c56c7 --- /dev/null +++ b/examples/auth/server.config @@ -0,0 +1,35 @@ +# Auth MCP Server Configuration +# This file follows the same format as the C++ auth example + +# Server settings +# host=0.0.0.0 +# port=3001 +server_url=https://marni-nightcapped-nonmeditatively.ngrok-free.dev + +# OAuth/IDP settings +# Uncomment and configure for Keycloak or other OAuth provider +client_id=oauth_0a650b79c5a64c3b920ae8c2b20599d9 +client_secret=6BiU2beUi2wIBxY3MUBLyYqoWKa4t0U_kJVm9mvSOKw +auth_server_url=https://auth-test.gopher.security/realms/gopher-mcp +oauth_authorize_url=https://api-test.gopher.security/oauth/authorize +# oauth_token_url derived from auth_server_url: https://auth-test.gopher.security/realms/gopher-mcp-auth/protocol/openid-connect/token + +# Direct OAuth endpoint URLs (optional, derived from auth_server_url if not set) +# jwks_uri=https://keycloak.example.com/realms/mcp/protocol/openid-connect/certs +# issuer=https://keycloak.example.com/realms/mcp +# oauth_authorize_url=https://keycloak.example.com/realms/mcp/protocol/openid-connect/auth +# oauth_token_url=https://keycloak.example.com/realms/mcp/protocol/openid-connect/token + +# Scopes +exchange_idps=oauth-idp-714982830194556929-google +allowed_scopes=openid profile email scope-001 + +# Cache settings +jwks_cache_duration=3600 +jwks_auto_refresh=true +request_timeout=30 + +# Auth bypass mode (for development/testing) +# Set to true to disable authentication +auth_disabled=false + diff --git a/examples/auth/tools/tool.go b/examples/auth/tools/tool.go new file mode 100644 index 00000000..9210d962 --- /dev/null +++ b/examples/auth/tools/tool.go @@ -0,0 +1,30 @@ +package tools + +import "encoding/json" + +// Tool represents an MCP tool that can be executed +type Tool interface { + // Name returns the tool's unique name + Name() string + + // Description returns a human-readable description of what the tool does + Description() string + + // InputSchema returns the JSON schema for the tool's input parameters + InputSchema() json.RawMessage + + // RequiredScope returns the OAuth scope required to execute this tool + // Return empty string if no specific scope is required + RequiredScope() string + + // Execute runs the tool with the given arguments and returns the result + // The result should be JSON-serializable + Execute(args json.RawMessage) (interface{}, error) +} + +// ToolInfo represents metadata about a tool for the tools/list response +type ToolInfo struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema json.RawMessage `json:"inputSchema"` +} diff --git a/examples/auth/tools/weather.go b/examples/auth/tools/weather.go new file mode 100644 index 00000000..295f6332 --- /dev/null +++ b/examples/auth/tools/weather.go @@ -0,0 +1,226 @@ +package tools + +import ( + "encoding/json" + "fmt" + "hash/fnv" +) + +// WeatherTool implements the Tool interface for weather-related tools +type WeatherTool struct { + name string + description string + inputSchema json.RawMessage + requiredScope string + handler func(args json.RawMessage) (interface{}, error) +} + +func (t *WeatherTool) Name() string { return t.name } +func (t *WeatherTool) Description() string { return t.description } +func (t *WeatherTool) InputSchema() json.RawMessage { return t.inputSchema } +func (t *WeatherTool) RequiredScope() string { return t.requiredScope } +func (t *WeatherTool) Execute(args json.RawMessage) (interface{}, error) { + return t.handler(args) +} + +// ToolRegistrar is an interface for registering tools +type ToolRegistrar interface { + RegisterTool(tool Tool) +} + +// RegisterWeatherTools registers all weather tools with the handler +func RegisterWeatherTools(handler ToolRegistrar) { + handler.RegisterTool(createGetWeatherTool()) + handler.RegisterTool(createGetForecastTool()) + handler.RegisterTool(createGetWeatherAlertsTool()) +} + +// hashString returns a deterministic hash value for a string using FNV-1a +func hashString(s string) uint32 { + h := fnv.New32a() + h.Write([]byte(s)) + return h.Sum32() +} + +// Weather conditions based on hash +var conditions = []string{"Sunny", "Partly Cloudy", "Cloudy", "Rainy", "Stormy", "Snowy", "Foggy", "Windy"} + +// createGetWeatherTool creates the get-weather tool (no scope required) +func createGetWeatherTool() *WeatherTool { + schema := json.RawMessage(`{ + "type": "object", + "properties": { + "city": { + "type": "string", + "description": "The city name to get weather for" + } + }, + "required": ["city"] + }`) + + return &WeatherTool{ + name: "get-weather", + description: "Get current weather for a city", + inputSchema: schema, + requiredScope: "", // No scope required + handler: func(args json.RawMessage) (interface{}, error) { + var params struct { + City string `json:"city"` + } + if err := json.Unmarshal(args, ¶ms); err != nil { + return nil, fmt.Errorf("invalid arguments: %w", err) + } + if params.City == "" { + return nil, fmt.Errorf("city is required") + } + + // Generate deterministic weather based on city name + hash := hashString(params.City) + temp := int(15 + (hash % 25)) // Temperature between 15-39 + condition := conditions[hash%uint32(len(conditions))] + humidity := int(30 + (hash % 50)) // Humidity between 30-79% + windSpeed := int(5 + (hash % 30)) // Wind speed between 5-34 km/h + + return map[string]interface{}{ + "city": params.City, + "temperature": temp, + "unit": "celsius", + "condition": condition, + "humidity": humidity, + "windSpeed": windSpeed, + }, nil + }, + } +} + +// createGetForecastTool creates the get-forecast tool (requires mcp:read) +func createGetForecastTool() *WeatherTool { + schema := json.RawMessage(`{ + "type": "object", + "properties": { + "city": { + "type": "string", + "description": "The city name to get forecast for" + }, + "days": { + "type": "integer", + "description": "Number of days to forecast (default: 5)", + "minimum": 1, + "maximum": 14 + } + }, + "required": ["city"] + }`) + + return &WeatherTool{ + name: "get-forecast", + description: "Get weather forecast for a city (requires mcp:read scope)", + inputSchema: schema, + requiredScope: "mcp:read", + handler: func(args json.RawMessage) (interface{}, error) { + var params struct { + City string `json:"city"` + Days int `json:"days"` + } + if err := json.Unmarshal(args, ¶ms); err != nil { + return nil, fmt.Errorf("invalid arguments: %w", err) + } + if params.City == "" { + return nil, fmt.Errorf("city is required") + } + if params.Days <= 0 { + params.Days = 5 + } + if params.Days > 14 { + params.Days = 14 + } + + // Generate deterministic forecast based on city name + forecasts := make([]map[string]interface{}, params.Days) + baseHash := hashString(params.City) + + for i := 0; i < params.Days; i++ { + dayHash := baseHash + uint32(i*12345) + highTemp := int(18 + (dayHash % 20)) + lowTemp := highTemp - int(5+(dayHash%10)) + condition := conditions[dayHash%uint32(len(conditions))] + precipChance := int((dayHash % 100)) + + forecasts[i] = map[string]interface{}{ + "day": i + 1, + "highTemperature": highTemp, + "lowTemperature": lowTemp, + "condition": condition, + "precipitationChance": precipChance, + } + } + + return map[string]interface{}{ + "city": params.City, + "unit": "celsius", + "forecast": forecasts, + }, nil + }, + } +} + +// createGetWeatherAlertsTool creates the get-weather-alerts tool (requires mcp:admin) +func createGetWeatherAlertsTool() *WeatherTool { + schema := json.RawMessage(`{ + "type": "object", + "properties": { + "region": { + "type": "string", + "description": "The region to get weather alerts for" + } + }, + "required": ["region"] + }`) + + return &WeatherTool{ + name: "get-weather-alerts", + description: "Get weather alerts for a region (requires mcp:admin scope)", + inputSchema: schema, + requiredScope: "mcp:admin", + handler: func(args json.RawMessage) (interface{}, error) { + var params struct { + Region string `json:"region"` + } + if err := json.Unmarshal(args, ¶ms); err != nil { + return nil, fmt.Errorf("invalid arguments: %w", err) + } + if params.Region == "" { + return nil, fmt.Errorf("region is required") + } + + // Generate deterministic alerts based on region name + hash := hashString(params.Region) + numAlerts := int(hash % 4) // 0-3 alerts + + alertTypes := []string{"Severe Thunderstorm", "Flash Flood", "Heat Advisory", "Winter Storm", "High Wind", "Dense Fog"} + severities := []string{"Watch", "Warning", "Advisory"} + + alerts := make([]map[string]interface{}, numAlerts) + for i := 0; i < numAlerts; i++ { + alertHash := hash + uint32(i*67890) + alertType := alertTypes[alertHash%uint32(len(alertTypes))] + severity := severities[alertHash%uint32(len(severities))] + + alerts[i] = map[string]interface{}{ + "id": fmt.Sprintf("ALERT-%d-%d", hash%10000, i), + "type": alertType, + "severity": severity, + "message": fmt.Sprintf("%s %s in effect for %s", alertType, severity, params.Region), + "issued": "2024-01-15T10:00:00Z", + "expires": "2024-01-15T22:00:00Z", + } + } + + return map[string]interface{}{ + "region": params.Region, + "alerts": alerts, + "count": numAlerts, + }, nil + }, + } +} diff --git a/examples/client_example_json.go b/examples/client_example_json.go index db2c0b6e..eb096a45 100644 --- a/examples/client_example_json.go +++ b/examples/client_example_json.go @@ -6,7 +6,7 @@ import ( "os" "strings" - "github.com/GopherSecurity/gopher-orch-go/src" + "github.com/GopherSecurity/gopher-mcp-go/src" ) // Server configuration for local MCP servers diff --git a/go.mod b/go.mod index 1f07f6d5..c3a75cdc 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/GopherSecurity/gopher-orch-go +module github.com/GopherSecurity/gopher-mcp-go go 1.21 diff --git a/install-native.sh b/install-native.sh new file mode 100755 index 00000000..d91db6d2 --- /dev/null +++ b/install-native.sh @@ -0,0 +1,182 @@ +#!/bin/bash +# +# install-native.sh - Download and install native libraries for gopher-mcp-go +# +# Usage: +# ./install-native.sh [VERSION] [INSTALL_DIR] +# +# Arguments: +# VERSION - Version to install (default: latest) +# INSTALL_DIR - Installation directory (default: /usr/local) +# +# Examples: +# ./install-native.sh # Install latest to /usr/local +# ./install-native.sh v0.1.1 # Install specific version +# ./install-native.sh latest ./native # Install to custom directory +# + +set -e + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +VERSION="${1:-latest}" +INSTALL_DIR="${2:-/usr/local}" + +echo -e "${CYAN}========================================${NC}" +echo -e "${CYAN} gopher-mcp-go Native Library Installer${NC}" +echo -e "${CYAN}========================================${NC}" +echo "" + +# Check for gh CLI +if ! command -v gh &> /dev/null; then + echo -e "${RED}Error: GitHub CLI (gh) is not installed${NC}" + echo "Install it with: brew install gh" + echo "Then authenticate: gh auth login" + exit 1 +fi + +# Detect platform +OS=$(uname -s | tr '[:upper:]' '[:lower:]') +ARCH=$(uname -m) + +case "$OS" in + darwin) OS_NAME="macos" ;; + linux) OS_NAME="linux" ;; + mingw*|msys*|cygwin*) OS_NAME="windows" ;; + *) echo -e "${RED}Error: Unsupported OS: $OS${NC}"; exit 1 ;; +esac + +case "$ARCH" in + x86_64|amd64) ARCH_NAME="x64" ;; + arm64|aarch64) ARCH_NAME="arm64" ;; + *) echo -e "${RED}Error: Unsupported architecture: $ARCH${NC}"; exit 1 ;; +esac + +PLATFORM="${OS_NAME}-${ARCH_NAME}" +echo -e "Detected platform: ${GREEN}${PLATFORM}${NC}" + +# Determine file extension +if [ "$OS_NAME" = "windows" ]; then + ARCHIVE_EXT="zip" +else + ARCHIVE_EXT="tar.gz" +fi + +ARCHIVE_NAME="libgopher-orch-${PLATFORM}.${ARCHIVE_EXT}" + +# Get version if "latest" +if [ "$VERSION" = "latest" ]; then + echo -e "${YELLOW}Fetching latest version...${NC}" + VERSION=$(gh release view -R GopherSecurity/gopher-mcp-go --json tagName -q '.tagName' 2>/dev/null) || { + echo -e "${RED}Error: Could not fetch latest release${NC}" + echo "Make sure the repository has releases and you have access." + exit 1 + } +fi + +echo -e "Version: ${GREEN}${VERSION}${NC}" +echo -e "Archive: ${GREEN}${ARCHIVE_NAME}${NC}" +echo -e "Install directory: ${GREEN}${INSTALL_DIR}${NC}" +echo "" + +# Create temp directory +TEMP_DIR=$(mktemp -d) +trap "rm -rf $TEMP_DIR" EXIT + +cd "$TEMP_DIR" + +# Download +echo -e "${YELLOW}Downloading native library...${NC}" +gh release download "$VERSION" \ + -R GopherSecurity/gopher-mcp-go \ + -p "$ARCHIVE_NAME" || { + echo -e "${RED}Error: Could not download $ARCHIVE_NAME${NC}" + echo "" + echo "Available assets for $VERSION:" + gh release view "$VERSION" -R GopherSecurity/gopher-mcp-go --json assets -q '.assets[].name' + exit 1 +} + +echo -e "${GREEN}✓ Downloaded${NC}" + +# Extract +echo -e "${YELLOW}Extracting...${NC}" + +if [ "$ARCHIVE_EXT" = "zip" ]; then + unzip -o "$ARCHIVE_NAME" +else + tar -xzf "$ARCHIVE_NAME" +fi + +echo -e "${GREEN}✓ Extracted${NC}" + +# Install +echo -e "${YELLOW}Installing to ${INSTALL_DIR}...${NC}" + +# Check if we need sudo +NEED_SUDO="" +if [ ! -w "$INSTALL_DIR" ] 2>/dev/null; then + NEED_SUDO="sudo" + echo -e "${YELLOW} (requires sudo)${NC}" +fi + +# Create directories +$NEED_SUDO mkdir -p "${INSTALL_DIR}/lib" +$NEED_SUDO mkdir -p "${INSTALL_DIR}/include" + +# Copy libraries +if [ -d "lib" ]; then + $NEED_SUDO cp -P lib/* "${INSTALL_DIR}/lib/" 2>/dev/null || true +fi + +# Copy headers +if [ -d "include" ]; then + $NEED_SUDO cp -r include/* "${INSTALL_DIR}/include/" 2>/dev/null || true +fi + +# Handle flat structure (files directly in archive) +$NEED_SUDO cp -P *.dylib "${INSTALL_DIR}/lib/" 2>/dev/null || true +$NEED_SUDO cp -P *.so* "${INSTALL_DIR}/lib/" 2>/dev/null || true +$NEED_SUDO cp -P *.dll "${INSTALL_DIR}/lib/" 2>/dev/null || true +$NEED_SUDO cp -P *.h "${INSTALL_DIR}/include/" 2>/dev/null || true + +echo -e "${GREEN}✓ Installed${NC}" +echo "" + +# Show installed files +echo -e "${CYAN}Installed files:${NC}" +echo " Libraries:" +ls -la "${INSTALL_DIR}/lib/"*gopher* 2>/dev/null | sed 's/^/ /' || echo " (none found)" +echo " Headers:" +ls -la "${INSTALL_DIR}/include/"*gopher* 2>/dev/null | sed 's/^/ /' || echo " (none found)" +echo "" + +# Print environment setup +echo -e "${GREEN}========================================${NC}" +echo -e "${GREEN} Installation Complete!${NC}" +echo -e "${GREEN}========================================${NC}" +echo "" +echo -e "${YELLOW}Add to your shell profile (~/.bashrc, ~/.zshrc):${NC}" +echo "" +echo " export CGO_CFLAGS=\"-I${INSTALL_DIR}/include\"" +echo " export CGO_LDFLAGS=\"-L${INSTALL_DIR}/lib -lgopher-orch\"" + +if [ "$OS_NAME" = "macos" ]; then + echo " export DYLD_LIBRARY_PATH=\"${INSTALL_DIR}/lib:\$DYLD_LIBRARY_PATH\"" +elif [ "$OS_NAME" = "linux" ]; then + echo " export LD_LIBRARY_PATH=\"${INSTALL_DIR}/lib:\$LD_LIBRARY_PATH\"" + echo "" + echo -e "${YELLOW}Or add to system library path:${NC}" + echo " echo '${INSTALL_DIR}/lib' | sudo tee /etc/ld.so.conf.d/gopher-orch.conf" + echo " sudo ldconfig" +fi + +echo "" +echo -e "${CYAN}To verify installation:${NC}" +echo " go build ./..." +echo "" diff --git a/src/agent.go b/src/agent.go index d81cfb2f..44f29190 100644 --- a/src/agent.go +++ b/src/agent.go @@ -28,8 +28,8 @@ import ( "sync" "sync/atomic" - "github.com/GopherSecurity/gopher-orch-go/src/errors" - "github.com/GopherSecurity/gopher-orch-go/src/ffi" + "github.com/GopherSecurity/gopher-mcp-go/src/errors" + "github.com/GopherSecurity/gopher-mcp-go/src/ffi" ) var ( diff --git a/src/ffi/auth.go b/src/ffi/auth.go new file mode 100644 index 00000000..c5b094c4 --- /dev/null +++ b/src/ffi/auth.go @@ -0,0 +1,301 @@ +//go:build auth + +package ffi + +/* +#cgo CFLAGS: -I${SRCDIR}/../../native/include +#cgo LDFLAGS: -L${SRCDIR}/../../native/lib -lgopher-orch -lgopher-mcp -lgopher-mcp-event + +#include +#include +#include + +// Auth library initialization functions +extern void gopher_auth_init(); +extern void gopher_auth_shutdown(); +extern const char* gopher_auth_version(); + +// Auth client types and functions +typedef void* gopher_auth_client_t; +typedef void* gopher_auth_validation_options_t; + +extern int32_t gopher_auth_client_create(gopher_auth_client_t* client, const char* jwks_uri, const char* issuer); +extern int32_t gopher_auth_client_destroy(gopher_auth_client_t client); +extern int32_t gopher_auth_client_set_option(gopher_auth_client_t client, const char* key, const char* value); + +// Token validation types and functions +typedef struct { + bool valid; + int32_t error_code; + const char* error_message; +} gopher_auth_validation_result_t; + +extern int32_t gopher_auth_validate_token(gopher_auth_client_t client, const char* token, gopher_auth_validation_options_t options, gopher_auth_validation_result_t* result); + +// JWT payload types and functions +typedef void* gopher_auth_payload_t; + +extern int32_t gopher_auth_extract_payload(const char* token, gopher_auth_payload_t* payload); +extern int32_t gopher_auth_payload_destroy(gopher_auth_payload_t payload); +extern int32_t gopher_auth_payload_get_subject(gopher_auth_payload_t payload, char** value); +extern int32_t gopher_auth_payload_get_issuer(gopher_auth_payload_t payload, char** value); +extern int32_t gopher_auth_payload_get_audience(gopher_auth_payload_t payload, char** value); +extern int32_t gopher_auth_payload_get_scopes(gopher_auth_payload_t payload, char** value); +extern int32_t gopher_auth_payload_get_expiration(gopher_auth_payload_t payload, int64_t* value); +extern void gopher_auth_free_string(char* str); +*/ +import "C" +import ( + "sync" + "unsafe" +) + +var ( + authAvailable bool + authAvailableOnce sync.Once + authInitialized bool +) + +// IsAuthAvailable checks if the auth functions are available in the native library +func IsAuthAvailable() bool { + authAvailableOnce.Do(func() { + // Auth functions are part of libgopher-orch + // If the library is loaded, auth functions are available + authAvailable = IsAvailable() + }) + return authAvailable +} + +// AuthInit initializes the auth library +// Must be called before using any other auth functions +func AuthInit() { + if !authInitialized { + C.gopher_auth_init() + authInitialized = true + } +} + +// AuthShutdown shuts down the auth library and releases resources +// Should be called when auth functionality is no longer needed +func AuthShutdown() { + if authInitialized { + C.gopher_auth_shutdown() + authInitialized = false + } +} + +// AuthVersion returns the version string of the auth library +func AuthVersion() string { + version := C.gopher_auth_version() + if version == nil { + return "" + } + return C.GoString(version) +} + +// AuthClientHandle represents a handle to a native auth client +type AuthClientHandle unsafe.Pointer + +// AuthClientCreate creates a new auth client with the given JWKS URI and issuer +// The client is used for JWT token validation +// Returns nil if creation fails +func AuthClientCreate(jwksURI, issuer string) AuthClientHandle { + cJwksURI := C.CString(jwksURI) + cIssuer := C.CString(issuer) + defer C.free(unsafe.Pointer(cJwksURI)) + defer C.free(unsafe.Pointer(cIssuer)) + + var client C.gopher_auth_client_t + result := C.gopher_auth_client_create(&client, cJwksURI, cIssuer) + if result != 0 { + return nil + } + return AuthClientHandle(client) +} + +// AuthClientDestroy destroys an auth client and releases its resources +// Safe to call with nil handle +func AuthClientDestroy(client AuthClientHandle) { + if client != nil { + C.gopher_auth_client_destroy(C.gopher_auth_client_t(client)) + } +} + +// AuthClientSetOption sets a configuration option on the auth client +// Common options include: +// - "cache_duration": JWKS cache TTL in seconds +// - "auto_refresh": "true" or "false" for automatic JWKS refresh +// - "request_timeout": HTTP request timeout in seconds +func AuthClientSetOption(client AuthClientHandle, key, value string) { + if client == nil { + return + } + cKey := C.CString(key) + cValue := C.CString(value) + defer C.free(unsafe.Pointer(cKey)) + defer C.free(unsafe.Pointer(cValue)) + + C.gopher_auth_client_set_option(C.gopher_auth_client_t(client), cKey, cValue) +} + +// ValidationResult represents the result of JWT token validation +type ValidationResult struct { + Valid bool // Whether the token is valid + ErrorCode int32 // Error code if validation failed (0 if valid) + ErrorMessage string // Error message if validation failed +} + +// AuthValidateToken validates a JWT token using the auth client +// Returns a ValidationResult indicating whether the token is valid +// and any error details if validation failed +func AuthValidateToken(client AuthClientHandle, token string) ValidationResult { + if client == nil { + return ValidationResult{ + Valid: false, + ErrorCode: -1, + ErrorMessage: "auth client is nil", + } + } + + cToken := C.CString(token) + defer C.free(unsafe.Pointer(cToken)) + + var result C.gopher_auth_validation_result_t + err := C.gopher_auth_validate_token(C.gopher_auth_client_t(client), cToken, nil, &result) + + if err != 0 { + return ValidationResult{ + Valid: false, + ErrorCode: int32(err), + ErrorMessage: "validation failed", + } + } + + var errorMsg string + if result.error_message != nil { + errorMsg = C.GoString(result.error_message) + } + + return ValidationResult{ + Valid: bool(result.valid), + ErrorCode: int32(result.error_code), + ErrorMessage: errorMsg, + } +} + +// AuthPayloadHandle represents a handle to a decoded JWT payload +type AuthPayloadHandle unsafe.Pointer + +// AuthDecodeToken decodes a JWT token without validation +// Returns a payload handle that can be used to extract claims +// The caller must call AuthPayloadDestroy when done with the payload +// Returns nil if decoding fails +func AuthDecodeToken(token string) AuthPayloadHandle { + cToken := C.CString(token) + defer C.free(unsafe.Pointer(cToken)) + + var payload C.gopher_auth_payload_t + result := C.gopher_auth_extract_payload(cToken, &payload) + if result != 0 { + return nil + } + return AuthPayloadHandle(payload) +} + +// AuthPayloadDestroy destroys a payload handle and releases its resources +// Safe to call with nil handle +func AuthPayloadDestroy(payload AuthPayloadHandle) { + if payload != nil { + C.gopher_auth_payload_destroy(C.gopher_auth_payload_t(payload)) + } +} + +// AuthPayloadGetSubject returns the subject (sub) claim from the JWT payload +// Returns empty string if payload is nil or claim is not present +func AuthPayloadGetSubject(payload AuthPayloadHandle) string { + if payload == nil { + return "" + } + var value *C.char + result := C.gopher_auth_payload_get_subject(C.gopher_auth_payload_t(payload), &value) + if result != 0 || value == nil { + return "" + } + str := C.GoString(value) + C.gopher_auth_free_string(value) + return str +} + +// AuthPayloadGetIssuer returns the issuer (iss) claim from the JWT payload +// Returns empty string if payload is nil or claim is not present +func AuthPayloadGetIssuer(payload AuthPayloadHandle) string { + if payload == nil { + return "" + } + var value *C.char + result := C.gopher_auth_payload_get_issuer(C.gopher_auth_payload_t(payload), &value) + if result != 0 || value == nil { + return "" + } + str := C.GoString(value) + C.gopher_auth_free_string(value) + return str +} + +// AuthPayloadGetAudience returns the audience (aud) claim from the JWT payload +// Returns empty string if payload is nil or claim is not present +func AuthPayloadGetAudience(payload AuthPayloadHandle) string { + if payload == nil { + return "" + } + var value *C.char + result := C.gopher_auth_payload_get_audience(C.gopher_auth_payload_t(payload), &value) + if result != 0 || value == nil { + return "" + } + str := C.GoString(value) + C.gopher_auth_free_string(value) + return str +} + +// AuthPayloadGetScope returns the scope claim from the JWT payload +// Returns a space-separated string of scopes +// Returns empty string if payload is nil or claim is not present +func AuthPayloadGetScope(payload AuthPayloadHandle) string { + if payload == nil { + return "" + } + var value *C.char + result := C.gopher_auth_payload_get_scopes(C.gopher_auth_payload_t(payload), &value) + if result != 0 || value == nil { + return "" + } + str := C.GoString(value) + C.gopher_auth_free_string(value) + return str +} + +// AuthPayloadGetExpiry returns the expiration time (exp) claim from the JWT payload +// Returns the Unix timestamp in seconds +// Returns 0 if payload is nil or claim is not present +func AuthPayloadGetExpiry(payload AuthPayloadHandle) int64 { + if payload == nil { + return 0 + } + var value C.int64_t + result := C.gopher_auth_payload_get_expiration(C.gopher_auth_payload_t(payload), &value) + if result != 0 { + return 0 + } + return int64(value) +} + +// AuthPayloadGetIssuedAt returns the issued at (iat) claim from the JWT payload +// Returns the Unix timestamp in seconds +// Returns 0 if payload is nil or claim is not present +// Note: This uses the expiration getter as issued_at is not directly available +func AuthPayloadGetIssuedAt(payload AuthPayloadHandle) int64 { + // The native library doesn't have a direct issued_at getter + // Return 0 as a placeholder + return 0 +} diff --git a/src/ffi/auth_test.go b/src/ffi/auth_test.go new file mode 100644 index 00000000..3708bbb0 --- /dev/null +++ b/src/ffi/auth_test.go @@ -0,0 +1,216 @@ +//go:build auth + +package ffi + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsAuthAvailable(t *testing.T) { + // This tests that IsAuthAvailable doesn't crash + available := IsAuthAvailable() + // If we get here without crash, the test passes + t.Logf("Auth functions available: %v", available) + + // IsAuthAvailable should return the same value as IsAvailable + // since auth functions are part of the same library + assert.Equal(t, IsAvailable(), available) +} + +func TestAuthInitAndShutdown(t *testing.T) { + skipIfNativeLibraryNotAvailable(t) + + // Test that AuthInit doesn't panic + assert.NotPanics(t, func() { + AuthInit() + }) + + // Test that AuthShutdown doesn't panic + assert.NotPanics(t, func() { + AuthShutdown() + }) + + // Test multiple init/shutdown cycles + assert.NotPanics(t, func() { + AuthInit() + AuthInit() // Double init should be safe + AuthShutdown() + AuthShutdown() // Double shutdown should be safe + }) +} + +func TestAuthVersion(t *testing.T) { + skipIfNativeLibraryNotAvailable(t) + + AuthInit() + defer AuthShutdown() + + version := AuthVersion() + t.Logf("Auth library version: %s", version) + + // Version should be a non-empty string + assert.NotEmpty(t, version, "Auth version should not be empty") +} + +func TestAuthClientCreate(t *testing.T) { + skipIfNativeLibraryNotAvailable(t) + + AuthInit() + defer AuthShutdown() + + // Create client with valid JWKS URI and issuer + client := AuthClientCreate( + "https://example.com/.well-known/jwks.json", + "https://example.com", + ) + + // Client should be created (non-nil handle) + assert.NotNil(t, client, "Auth client should be created") + + // Clean up + AuthClientDestroy(client) +} + +func TestAuthClientDestroyWithValidHandle(t *testing.T) { + skipIfNativeLibraryNotAvailable(t) + + AuthInit() + defer AuthShutdown() + + client := AuthClientCreate( + "https://example.com/.well-known/jwks.json", + "https://example.com", + ) + + // Destroy should not panic + assert.NotPanics(t, func() { + AuthClientDestroy(client) + }) +} + +func TestAuthClientDestroyWithNilHandle(t *testing.T) { + skipIfNativeLibraryNotAvailable(t) + + // Destroying nil handle should not panic + assert.NotPanics(t, func() { + AuthClientDestroy(nil) + }) +} + +func TestAuthClientSetOption(t *testing.T) { + skipIfNativeLibraryNotAvailable(t) + + AuthInit() + defer AuthShutdown() + + client := AuthClientCreate( + "https://example.com/.well-known/jwks.json", + "https://example.com", + ) + defer AuthClientDestroy(client) + + // Setting options should not panic + assert.NotPanics(t, func() { + AuthClientSetOption(client, "cache_duration", "3600") + AuthClientSetOption(client, "auto_refresh", "true") + AuthClientSetOption(client, "request_timeout", "30") + }) +} + +func TestAuthClientSetOptionWithNilClient(t *testing.T) { + skipIfNativeLibraryNotAvailable(t) + + // Setting option on nil client should not panic + assert.NotPanics(t, func() { + AuthClientSetOption(nil, "cache_duration", "3600") + }) +} + +func TestAuthValidateTokenWithInvalidToken(t *testing.T) { + skipIfNativeLibraryNotAvailable(t) + + AuthInit() + defer AuthShutdown() + + client := AuthClientCreate( + "https://example.com/.well-known/jwks.json", + "https://example.com", + ) + defer AuthClientDestroy(client) + + // Validate an obviously invalid token + result := AuthValidateToken(client, "invalid-token") + + // Should return Valid: false for invalid token + assert.False(t, result.Valid, "Invalid token should not be valid") + t.Logf("Validation result for invalid token: Valid=%v, ErrorCode=%d, ErrorMessage=%s", + result.Valid, result.ErrorCode, result.ErrorMessage) +} + +func TestAuthValidateTokenWithNilClient(t *testing.T) { + skipIfNativeLibraryNotAvailable(t) + + // Validating with nil client should return error result + result := AuthValidateToken(nil, "some-token") + + assert.False(t, result.Valid, "Validation with nil client should fail") + assert.Equal(t, int32(-1), result.ErrorCode) + assert.Equal(t, "auth client is nil", result.ErrorMessage) +} + +func TestAuthValidateTokenWithEmptyToken(t *testing.T) { + skipIfNativeLibraryNotAvailable(t) + + AuthInit() + defer AuthShutdown() + + client := AuthClientCreate( + "https://example.com/.well-known/jwks.json", + "https://example.com", + ) + defer AuthClientDestroy(client) + + // Validate empty token + result := AuthValidateToken(client, "") + + // Should return Valid: false for empty token + assert.False(t, result.Valid, "Empty token should not be valid") +} + +func TestAuthDecodeToken(t *testing.T) { + skipIfNativeLibraryNotAvailable(t) + + AuthInit() + defer AuthShutdown() + + // Try to decode an invalid token - should return nil or handle gracefully + payload := AuthDecodeToken("invalid-token") + + // Clean up if payload was created + if payload != nil { + AuthPayloadDestroy(payload) + } +} + +func TestAuthPayloadDestroyWithNilHandle(t *testing.T) { + skipIfNativeLibraryNotAvailable(t) + + // Destroying nil payload should not panic + assert.NotPanics(t, func() { + AuthPayloadDestroy(nil) + }) +} + +func TestAuthPayloadGettersWithNilPayload(t *testing.T) { + skipIfNativeLibraryNotAvailable(t) + + // All getters should return empty/zero values for nil payload + assert.Equal(t, "", AuthPayloadGetSubject(nil)) + assert.Equal(t, "", AuthPayloadGetIssuer(nil)) + assert.Equal(t, "", AuthPayloadGetAudience(nil)) + assert.Equal(t, "", AuthPayloadGetScope(nil)) + assert.Equal(t, int64(0), AuthPayloadGetExpiry(nil)) + assert.Equal(t, int64(0), AuthPayloadGetIssuedAt(nil)) +} diff --git a/src/ffi/library.go b/src/ffi/library.go index 0450f584..07803363 100644 --- a/src/ffi/library.go +++ b/src/ffi/library.go @@ -2,7 +2,7 @@ package ffi /* #cgo CFLAGS: -I${SRCDIR}/../../native/include -#cgo LDFLAGS: -L${SRCDIR}/../../native/lib -lgopher-orch -lgopher-mcp -lgopher-mcp-event -lfmt +#cgo LDFLAGS: -L${SRCDIR}/../../native/lib -lgopher-orch -lgopher-mcp -lgopher-mcp-event #include #include diff --git a/third_party/gopher-orch b/third_party/gopher-orch index 6b45ffbb..c8e7c406 160000 --- a/third_party/gopher-orch +++ b/third_party/gopher-orch @@ -1 +1 @@ -Subproject commit 6b45ffbbee74d5ae034008fc2cb2a927f3131992 +Subproject commit c8e7c40606db330142632ecf90aaa8777bc42a3a