diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 381870b..e1839f9 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -30,7 +30,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index e922e21..14644ee 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -26,7 +26,7 @@ jobs: actions: read # Required for Claude to read CI results on PRs steps: - name: Checkout repository - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: fetch-depth: 1 diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index c01ff41..a47d418 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -12,7 +12,7 @@ jobs: name: Code Quality Checks runs-on: ubuntu-latest steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Install uv uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3 diff --git a/.github/workflows/image-build.yml b/.github/workflows/image-build.yml index a9cb16d..c5871e0 100644 --- a/.github/workflows/image-build.yml +++ b/.github/workflows/image-build.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Set up Docker Buildx uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index 2417d45..2c62823 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Set up Docker Buildx uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 diff --git a/.github/workflows/offline-tests.yml b/.github/workflows/offline-tests.yml index 010e331..5965730 100644 --- a/.github/workflows/offline-tests.yml +++ b/.github/workflows/offline-tests.yml @@ -13,7 +13,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Set up Docker Buildx uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 63ee518..99766d8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ jobs: id-token: write steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Set up Docker Buildx uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 @@ -41,7 +41,7 @@ jobs: - name: Extract metadata id: meta - uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0 with: images: ghcr.io/${{ steps.repo_owner.outputs.OWNER }}/mcp-optimizer tags: | diff --git a/.github/workflows/releaser-helm-charts.yml b/.github/workflows/releaser-helm-charts.yml index b650381..3783425 100644 --- a/.github/workflows/releaser-helm-charts.yml +++ b/.github/workflows/releaser-helm-charts.yml @@ -20,7 +20,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 with: fetch-depth: 0 diff --git a/.github/workflows/update-thv-models.yml b/.github/workflows/update-thv-models.yml index 1d37257..154678a 100644 --- a/.github/workflows/update-thv-models.yml +++ b/.github/workflows/update-thv-models.yml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Install ToolHive uses: StacklokLabs/toolhive-actions/install@6a095f99aa2fd6cd92cf0bb94bdf509b99820c06 # v0.0.3 @@ -115,7 +115,7 @@ jobs: - name: Create Pull Request if: steps.check-changes.outputs.has_changes == 'true' id: create-pr - uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 + uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9 with: # Ensure PR related actions (quality checks) are triggered, see # https://github.com/peter-evans/create-pull-request/issues/48#issuecomment-536204092 diff --git a/Taskfile.yml b/Taskfile.yml index c6eec9c..9519fab 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -120,3 +120,18 @@ tasks: desc: Run container offline mode tests cmds: - ./scripts/test-offline.sh + + k8s-apply-examples: + desc: Apply all MCP server examples to Kubernetes cluster + cmds: + - ./examples/mcp-servers/apply-mcp-servers.sh + + k8s-delete-examples: + desc: Delete all MCP server examples from Kubernetes cluster + cmds: + - ./examples/mcp-servers/delete-mcp-servers.sh + + k8s-status-examples: + desc: Check status of all MCP server examples + cmds: + - ./examples/mcp-servers/status-mcp-servers.sh diff --git a/examples/mcp-servers/README.md b/examples/mcp-servers/README.md index 50278fb..559378a 100644 --- a/examples/mcp-servers/README.md +++ b/examples/mcp-servers/README.md @@ -20,6 +20,46 @@ Before deploying these example servers, you must: ## Quick Start +### 0. Create GitHub Secrets (Required for pulling images and GitHub API access) + +Before deploying any servers, create the required GitHub secrets. You can use the same GitHub Personal Access Token for both secrets. + +**Option 1: Use the convenience script (Recommended)** + +```bash +# Set your GitHub token and username +export GITHUB_TOKEN=your_token_here +export GITHUB_USERNAME=your_username # Optional, will prompt if not set + +# Run the script to create both secrets +./examples/mcp-servers/create-github-secrets.sh +``` + +**Option 2: Create secrets manually** + +```bash +# Set your token and username +GITHUB_TOKEN=your_token_here +GITHUB_USERNAME=your_username + +# Create the pull secret for ghcr.io +kubectl create secret docker-registry ghcr-pull-secret \ + --docker-server=ghcr.io \ + --docker-username=$GITHUB_USERNAME \ + --docker-password=$GITHUB_TOKEN \ + -n toolhive-system + +# Create the GitHub API token secret +kubectl create secret generic github-token -n toolhive-system \ + --from-literal=token=$GITHUB_TOKEN +``` + +**Note:** You need a GitHub Personal Access Token with: +- `read:packages` scope for pulling images from ghcr.io +- GitHub API scopes (repo, read:org, etc.) for MCP server access + +The `shared-serviceaccount.yaml` will automatically reference the pull secret, making it available to all MCP servers that use the shared service account. + ### 1. Install Fetch Server ```bash @@ -30,16 +70,52 @@ kubectl get mcpserver fetch -n toolhive-system ### 2. Install GitHub Server ```bash -# Create GitHub token secret first -kubectl create secret generic github-token -n toolhive-system \ - --from-literal=token=YOUR_GITHUB_TOKEN_HERE +# Note: If you used the create-github-secrets.sh script in step 0, +# the github-token secret already exists. You can skip creating it again. # Deploy GitHub server kubectl apply -f examples/mcp-servers/mcpserver_github.yaml kubectl get mcpserver github -n toolhive-system ``` -### 3. Verify Deployment +**Alternative:** If you didn't use the script, create the github-token secret manually: +```bash +kubectl create secret generic github-token -n toolhive-system \ + --from-literal=token=YOUR_GITHUB_TOKEN_HERE +``` + +### 3. Install ToolHive Doc MCP Server + +The ToolHive Doc MCP server provides documentation search and retrieval capabilities. + +```bash +# Note: This server uses the same github-token secret as the GitHub server +# If you've already created github-token secret in step 2, you can skip creating it again + +# Deploy ToolHive Doc MCP server +kubectl apply -f examples/mcp-servers/mcpserver_toolhive-doc-mcp.yaml +kubectl get mcpserver toolhive-doc-mcp -n toolhive-system +``` + +### 4. Install MCP Optimizer + +MCP Optimizer aggregates tools from all MCP servers in the cluster and provides unified tool discovery. + +```bash +# Deploy MCP Optimizer (includes ServiceAccount and RBAC) +kubectl apply -f examples/mcp-servers/mcpserver_mcp-optimizer.yaml + +# Verify deployment +kubectl get mcpserver mcp-optimizer -n toolhive-system +kubectl get pods -n toolhive-system | grep mcp-optimizer + +# Check logs to see tool discovery +kubectl logs -n toolhive-system -l app.kubernetes.io/name=mcp-optimizer --tail=50 +``` + +**Note:** MCP Optimizer requires RBAC permissions to discover MCPServer resources in the cluster. The example includes the necessary ServiceAccount, ClusterRole, and ClusterRoleBinding. + +### 5. Verify Deployment Check that MCP Optimizer discovers the deployed servers: @@ -87,8 +163,12 @@ For client configuration (Cursor, VSCode, Claude Desktop), see [Connecting Clien ## Files -- **`mcpserver_fetch.yaml`** - Fetch server for web scraping and URL fetching -- **`mcpserver_github.yaml`** - GitHub API integration server +- **`create-github-secrets.sh`** - Convenience script to create both GitHub secrets from GITHUB_TOKEN environment variable +- **`shared-serviceaccount.yaml`** - Shared ServiceAccount with cluster-wide imagePullSecrets for ghcr.io (applied automatically) +- **`mcpserver_fetch.yaml`** - Fetch server for web scraping and URL fetching (uses shared ServiceAccount) +- **`mcpserver_github.yaml`** - GitHub API integration server (uses shared ServiceAccount) +- **`mcpserver_toolhive-doc-mcp.yaml`** - ToolHive documentation search and retrieval server (uses shared ServiceAccount, shares github-token secret) +- **`mcpserver_mcp-optimizer.yaml`** - MCP Optimizer server that aggregates tools from all MCP servers (includes its own ServiceAccount with imagePullSecrets and RBAC) ## Complete Documentation diff --git a/examples/mcp-servers/apply-mcp-servers.sh b/examples/mcp-servers/apply-mcp-servers.sh new file mode 100755 index 0000000..bed5c48 --- /dev/null +++ b/examples/mcp-servers/apply-mcp-servers.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# Apply all MCP server examples to Kubernetes cluster + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +EXAMPLES_DIR="${SCRIPT_DIR}" + +echo "Applying MCP server examples..." +echo "" + +# Check if kubectl is available +if ! command -v kubectl &> /dev/null; then + echo "Error: kubectl is not installed or not in PATH" + exit 1 +fi + +# Check if namespace exists +if ! kubectl get namespace toolhive-system &> /dev/null; then + echo "Creating toolhive-system namespace..." + kubectl create namespace toolhive-system +fi + +# Check if GitHub secrets exist, prompt to create if not +echo "Checking for GitHub secrets..." +if ! kubectl get secret ghcr-pull-secret -n toolhive-system &> /dev/null; then + echo " Warning: ghcr-pull-secret does not exist" + echo " Images from ghcr.io may fail to pull without this secret" + echo " Create it with:" + echo " export GITHUB_TOKEN=your_token_here" + echo " export GITHUB_USERNAME=your_username" + echo " ./examples/mcp-servers/create-github-secrets.sh" + echo " Continuing anyway..." +else + echo " ✓ ghcr-pull-secret found" +fi + +if ! kubectl get secret github-token -n toolhive-system &> /dev/null; then + echo " Warning: github-token secret does not exist" + echo " GitHub MCP servers may fail without this secret" + echo " Create it with:" + echo " export GITHUB_TOKEN=your_token_here" + echo " export GITHUB_USERNAME=your_username" + echo " ./examples/mcp-servers/create-github-secrets.sh" + echo " Continuing anyway..." +else + echo " ✓ github-token found" +fi + +# Apply shared ServiceAccount with imagePullSecrets +echo "" +echo "Applying shared-serviceaccount.yaml..." +kubectl apply -f "${EXAMPLES_DIR}/shared-serviceaccount.yaml" + +# Apply MCP servers +echo "" +echo "Applying MCP servers..." +kubectl apply -f "${EXAMPLES_DIR}/mcpserver_fetch.yaml" +kubectl apply -f "${EXAMPLES_DIR}/mcpserver_github.yaml" +kubectl apply -f "${EXAMPLES_DIR}/mcpserver_toolhive-doc-mcp.yaml" +kubectl apply -f "${EXAMPLES_DIR}/mcpserver_mcp-optimizer.yaml" + +echo "" +echo "✓ Applied all MCP server examples!" +echo "" +echo "Check status with: kubectl get mcpservers -n toolhive-system" +echo "Check pods with: kubectl get pods -n toolhive-system" + diff --git a/examples/mcp-servers/create-github-secrets.sh b/examples/mcp-servers/create-github-secrets.sh new file mode 100755 index 0000000..4ebd607 --- /dev/null +++ b/examples/mcp-servers/create-github-secrets.sh @@ -0,0 +1,97 @@ +#!/bin/bash +# Create GitHub secrets from GITHUB_TOKEN environment variable +# +# This script creates both: +# 1. ghcr-pull-secret (docker-registry type) for pulling images from ghcr.io +# 2. github-token (Opaque type) for GitHub API access by MCP servers +# +# Usage: +# export GITHUB_TOKEN=your_token_here +# export GITHUB_USERNAME=your_username # Optional, will prompt if not set +# ./create-github-secrets.sh + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +NAMESPACE="toolhive-system" + +echo "Creating GitHub secrets..." +echo "" + +# Check if kubectl is available +if ! command -v kubectl &> /dev/null; then + echo "Error: kubectl is not installed or not in PATH" + exit 1 +fi + +# Check if namespace exists +if ! kubectl get namespace "${NAMESPACE}" &> /dev/null; then + echo "Creating ${NAMESPACE} namespace..." + kubectl create namespace "${NAMESPACE}" +fi + +# Check for GITHUB_TOKEN environment variable +if [ -z "${GITHUB_TOKEN}" ]; then + echo "Error: GITHUB_TOKEN environment variable is not set" + echo "" + echo "Please set it with:" + echo " export GITHUB_TOKEN=your_token_here" + echo "" + echo "The token needs:" + echo " - 'read:packages' scope for pulling images from ghcr.io" + echo " - GitHub API scopes (repo, read:org, etc.) for MCP server access" + exit 1 +fi + +# Check for GITHUB_USERNAME, prompt if not set +if [ -z "${GITHUB_USERNAME}" ]; then + echo "GITHUB_USERNAME not set. Please enter your GitHub username:" + read -r GITHUB_USERNAME + if [ -z "${GITHUB_USERNAME}" ]; then + echo "Error: GitHub username is required" + exit 1 + fi +fi + +echo "Using GitHub username: ${GITHUB_USERNAME}" +echo "" + +# Create or update ghcr-pull-secret +echo "Creating/updating ghcr-pull-secret..." +if kubectl get secret ghcr-pull-secret -n "${NAMESPACE}" &> /dev/null; then + echo " Secret already exists, deleting it first..." + kubectl delete secret ghcr-pull-secret -n "${NAMESPACE}" --ignore-not-found=true +fi + +kubectl create secret docker-registry ghcr-pull-secret \ + --docker-server=ghcr.io \ + --docker-username="${GITHUB_USERNAME}" \ + --docker-password="${GITHUB_TOKEN}" \ + -n "${NAMESPACE}" + +echo " ✓ Created ghcr-pull-secret" +echo "" + +# Create or update github-token secret +echo "Creating/updating github-token secret..." +if kubectl get secret github-token -n "${NAMESPACE}" &> /dev/null; then + echo " Secret already exists, deleting it first..." + kubectl delete secret github-token -n "${NAMESPACE}" --ignore-not-found=true +fi + +kubectl create secret generic github-token \ + --from-literal=token="${GITHUB_TOKEN}" \ + -n "${NAMESPACE}" + +echo " ✓ Created github-token secret" +echo "" + +echo "✓ Successfully created both GitHub secrets!" +echo "" +echo "Both secrets use the same token value but serve different purposes:" +echo " - ghcr-pull-secret: Used by ServiceAccount imagePullSecrets to pull images" +echo " - github-token: Used by MCP servers as environment variable for API access" +echo "" +echo "Verify secrets:" +echo " kubectl get secrets -n ${NAMESPACE} | grep -E 'ghcr-pull-secret|github-token'" + diff --git a/examples/mcp-servers/delete-mcp-servers.sh b/examples/mcp-servers/delete-mcp-servers.sh new file mode 100755 index 0000000..c04afca --- /dev/null +++ b/examples/mcp-servers/delete-mcp-servers.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# Delete all MCP server examples from Kubernetes cluster + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +EXAMPLES_DIR="${SCRIPT_DIR}" + +echo "Deleting MCP server examples..." +echo "" + +# Check if kubectl is available +if ! command -v kubectl &> /dev/null; then + echo "Error: kubectl is not installed or not in PATH" + exit 1 +fi + +# Delete in reverse order (dependencies first) +kubectl delete -f "${EXAMPLES_DIR}/mcpserver_mcp-optimizer.yaml" --ignore-not-found=true +kubectl delete -f "${EXAMPLES_DIR}/mcpserver_toolhive-doc-mcp.yaml" --ignore-not-found=true +kubectl delete -f "${EXAMPLES_DIR}/mcpserver_github.yaml" --ignore-not-found=true +kubectl delete -f "${EXAMPLES_DIR}/mcpserver_fetch.yaml" --ignore-not-found=true +kubectl delete -f "${EXAMPLES_DIR}/shared-serviceaccount.yaml" --ignore-not-found=true + +# Note: GitHub secrets (ghcr-pull-secret and github-token) are not deleted +# as they may be used by other resources. Delete them manually if needed: +# kubectl delete secret ghcr-pull-secret github-token -n toolhive-system --ignore-not-found=true + +echo "✓ Deleted all MCP server examples!" + diff --git a/examples/mcp-servers/ingress.yaml b/examples/mcp-servers/ingress.yaml new file mode 100644 index 0000000..e611119 --- /dev/null +++ b/examples/mcp-servers/ingress.yaml @@ -0,0 +1,41 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: mcp-servers-ingress + namespace: toolhive-system + annotations: + nginx.ingress.kubernetes.io/rewrite-target: /$2 +spec: + ingressClassName: nginx + rules: + - http: + paths: + - path: /fetch(/|$)(.*) + pathType: ImplementationSpecific + backend: + service: + name: mcp-fetch-proxy + port: + number: 8080 + - path: /github(/|$)(.*) + pathType: ImplementationSpecific + backend: + service: + name: mcp-github-proxy + port: + number: 8080 + - path: /toolhive-doc-mcp(/|$)(.*) + pathType: ImplementationSpecific + backend: + service: + name: mcp-toolhive-doc-mcp-proxy + port: + number: 8080 + - path: /mcp-optimizer(/|$)(.*) + pathType: ImplementationSpecific + backend: + service: + name: mcp-mcp-optimizer-proxy + port: + number: 8080 + diff --git a/examples/mcp-servers/mcpserver_fetch.yaml b/examples/mcp-servers/mcpserver_fetch.yaml index 244834a..1deeb4c 100644 --- a/examples/mcp-servers/mcpserver_fetch.yaml +++ b/examples/mcp-servers/mcpserver_fetch.yaml @@ -9,6 +9,7 @@ spec: proxyMode: streamable-http port: 8080 targetPort: 8080 + serviceAccount: mcp-shared-sa permissionProfile: name: network type: builtin diff --git a/examples/mcp-servers/mcpserver_github.yaml b/examples/mcp-servers/mcpserver_github.yaml index 107b4d7..97994a3 100644 --- a/examples/mcp-servers/mcpserver_github.yaml +++ b/examples/mcp-servers/mcpserver_github.yaml @@ -9,6 +9,7 @@ spec: proxyMode: streamable-http port: 8080 targetPort: 8080 + serviceAccount: mcp-shared-sa permissionProfile: name: network type: builtin diff --git a/examples/mcp-servers/mcpserver_mcp-optimizer.yaml b/examples/mcp-servers/mcpserver_mcp-optimizer.yaml new file mode 100644 index 0000000..3bcea90 --- /dev/null +++ b/examples/mcp-servers/mcpserver_mcp-optimizer.yaml @@ -0,0 +1,93 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: mcp-optimizer + namespace: toolhive-system +imagePullSecrets: + - name: ghcr-pull-secret +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: mcp-optimizer-reader +rules: + # Allow reading MCPServer CRDs to discover MCP servers in the cluster + - apiGroups: ["toolhive.stacklok.dev"] + resources: ["mcpservers"] + verbs: ["get", "list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: mcp-optimizer-reader +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: mcp-optimizer-reader +subjects: + - kind: ServiceAccount + name: mcp-optimizer + namespace: toolhive-system +--- +apiVersion: toolhive.stacklok.dev/v1alpha1 +kind: MCPServer +metadata: + name: mcp-optimizer + namespace: toolhive-system +spec: + image: ghcr.io/stackloklabs/mcp-optimizer:0.1.3 + transport: streamable-http + proxyMode: streamable-http + port: 9900 + targetPort: 9900 + serviceAccount: mcp-optimizer + permissionProfile: + name: network + type: builtin + podTemplateSpec: + spec: + securityContext: + fsGroup: 1000 + volumes: + - name: data + emptyDir: {} + - name: tmp + emptyDir: {} + containers: + - name: mcp + volumeMounts: + - name: data + mountPath: /data + - name: tmp + mountPath: /tmp + env: + - name: SQLITE_TMPDIR + value: "/tmp" + - name: RUNTIME_MODE + value: "k8s" + - name: K8S_ALL_NAMESPACES + value: "true" + - name: LOG_LEVEL + value: "INFO" + - name: WORKLOAD_POLLING_INTERVAL + value: "60" + - name: REGISTRY_POLLING_INTERVAL + value: "300" + - name: MAX_TOOLS_TO_RETURN + value: "8" + - name: MAX_SERVERS_TO_RETURN + value: "5" + - name: HYBRID_SEARCH_SEMANTIC_RATIO + value: "0.5" + - name: ASYNC_DB_URL + value: "sqlite+aiosqlite:///data/mcp_optimizer.db" + - name: DB_URL + value: "sqlite:///data/mcp_optimizer.db" + resources: + limits: + cpu: 1000m + memory: 1Gi + requests: + cpu: 250m + memory: 256Mi + diff --git a/examples/mcp-servers/mcpserver_toolhive-doc-mcp.yaml b/examples/mcp-servers/mcpserver_toolhive-doc-mcp.yaml new file mode 100644 index 0000000..a78579a --- /dev/null +++ b/examples/mcp-servers/mcpserver_toolhive-doc-mcp.yaml @@ -0,0 +1,46 @@ +apiVersion: toolhive.stacklok.dev/v1alpha1 +kind: MCPServer +metadata: + name: toolhive-doc-mcp + namespace: toolhive-system +spec: + image: ghcr.io/stackloklabs/toolhive-doc-mcp:v0.0.7 + transport: streamable-http + proxyMode: streamable-http + port: 8080 + targetPort: 8080 + permissionProfile: + name: network + type: builtin + serviceAccount: mcp-shared-sa + podTemplateSpec: + spec: + containers: + - name: mcp + env: + - name: HF_HOME + value: /app/.cache/huggingface + - name: TRANSFORMERS_CACHE + value: /app/.cache/huggingface + volumeMounts: + - name: tmp + mountPath: /tmp + - name: cache + mountPath: /app/.cache + volumes: + - name: tmp + emptyDir: {} + - name: cache + emptyDir: {} + secrets: + - name: github-token + key: token + targetEnvName: GITHUB_TOKEN + resources: + limits: + cpu: 200m + memory: 512Mi + requests: + cpu: 100m + memory: 256Mi + diff --git a/examples/mcp-servers/shared-serviceaccount.yaml b/examples/mcp-servers/shared-serviceaccount.yaml new file mode 100644 index 0000000..d23f04b --- /dev/null +++ b/examples/mcp-servers/shared-serviceaccount.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: mcp-shared-sa + namespace: toolhive-system +imagePullSecrets: + - name: ghcr-pull-secret + diff --git a/examples/mcp-servers/status-mcp-servers.sh b/examples/mcp-servers/status-mcp-servers.sh new file mode 100755 index 0000000..78fc486 --- /dev/null +++ b/examples/mcp-servers/status-mcp-servers.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# Check status of all MCP server examples + +set -e + +# Check if kubectl is available +if ! command -v kubectl &> /dev/null; then + echo "Error: kubectl is not installed or not in PATH" + exit 1 +fi + +echo "MCP Server Status:" +echo "==================" +kubectl get mcpservers -n toolhive-system 2>/dev/null || echo "No MCPServer resources found or namespace doesn't exist" + +echo "" +echo "Pods Status:" +echo "============" +kubectl get pods -n toolhive-system -l app.kubernetes.io/managed-by=toolhive-operator 2>/dev/null || echo "No pods found" + +echo "" +echo "Services Status:" +echo "================" +kubectl get svc -n toolhive-system -l app.kubernetes.io/managed-by=toolhive-operator 2>/dev/null || echo "No services found" + diff --git a/renovate.json b/renovate.json index 5db72dd..b02099d 100644 --- a/renovate.json +++ b/renovate.json @@ -2,5 +2,14 @@ "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ "config:recommended" + ], + "lockFileMaintenance": { + "enabled": true + }, + "packageRules": [ + { + "matchManagers": ["pep621"], + "rangeStrategy": "bump" + } ] } diff --git a/src/mcp_optimizer/ingestion.py b/src/mcp_optimizer/ingestion.py index 0a08fd3..9df587d 100644 --- a/src/mcp_optimizer/ingestion.py +++ b/src/mcp_optimizer/ingestion.py @@ -1713,6 +1713,16 @@ async def ingest_registry(self, toolhive_client: ToolhiveClient) -> None: Returns: List of MCP servers with their associated tools """ + # Skip registry ingestion in K8s mode - registry is not available via ToolHive HTTP API + # In K8s mode, workloads come from Kubernetes CRDs, not ToolHive's registry API + if self.runtime_mode == "k8s": + logger.info( + "Skipping registry ingestion in K8s mode " + "(registry not available via ToolHive HTTP API)", + runtime_mode=self.runtime_mode, + ) + return + logger.info( "Starting registry ingestion", host=toolhive_client.thv_host, diff --git a/src/mcp_optimizer/mcp_client.py b/src/mcp_optimizer/mcp_client.py index 80bdf82..365cd70 100644 --- a/src/mcp_optimizer/mcp_client.py +++ b/src/mcp_optimizer/mcp_client.py @@ -4,6 +4,7 @@ import asyncio from typing import Any, Awaitable, Callable +from urllib.parse import urlparse, urlunparse import structlog from mcp import ClientSession @@ -38,6 +39,65 @@ def __init__(self, workload: Workload, timeout: float): self.workload = workload self.timeout = timeout + def _normalize_url(self, url: str, proxy_mode: ToolHiveProxyMode) -> str: + """ + Normalize URL for the given proxy mode. + + For streamable-http: + - Fragments must be stripped as they're not supported + - Path must be /mcp (not /sse) as streamable-http uses /mcp endpoint + For SSE, fragments are preserved as they're used for container identification. + + Args: + url: Original URL from ToolHive + proxy_mode: The proxy mode being used + + Returns: + Normalized URL without fragments and with correct path for streamable-http, + original URL for SSE + """ + if proxy_mode == ToolHiveProxyMode.STREAMABLE: + # Strip fragments for streamable-http + # (fragments not supported by streamable-http client) + parsed = urlparse(url) + + # Fix path: streamable-http uses /mcp endpoint, not /sse + path = parsed.path + if path.endswith("/sse"): + path = path.replace("/sse", "/mcp") + elif not path.endswith("/mcp"): + # Only add /mcp if the path doesn't already contain /mcp + # This prevents double-adding /mcp to URLs like /mcp/test-server + if "/mcp" not in path: + # If path doesn't end with /mcp or /sse, and doesn't contain /mcp, + # ensure it ends with /mcp + if path.endswith("/"): + path = path + "mcp" + else: + path = path + "/mcp" + + # Reconstruct URL without fragment and with corrected path + normalized_tuple = ( + parsed.scheme, + parsed.netloc, + path, + parsed.params, + parsed.query, + "", # Empty fragment + ) + normalized = str(urlunparse(normalized_tuple)) + if normalized != url: + logger.debug( + "Normalized URL for streamable-http", + original_url=url, + normalized_url=normalized, + workload=self.workload.name, + ) + return normalized + else: + # SSE preserves fragments (used for container identification) + return url + def _extract_error_from_exception_group(self, eg: ExceptionGroup) -> str: """ Extract meaningful error message from ExceptionGroup. @@ -121,24 +181,27 @@ async def _execute_with_session(self, operation: Callable[[ClientSession], Await logger.debug(f"Workload URL: {self.workload.url}") - # Determine proxy mode and prepare URL + # Determine proxy mode and normalize URL proxy_mode = self._determine_proxy_mode() + normalized_url = self._normalize_url(self.workload.url, proxy_mode) logger.info( f"Using {proxy_mode} client for workload '{self.workload.name}'", workload=self.workload.name, proxy_mode_field=self.workload.proxy_mode, - url=self.workload.url, + original_url=self.workload.url, + normalized_url=normalized_url, ) try: if proxy_mode == ToolHiveProxyMode.STREAMABLE: return await asyncio.wait_for( - self._execute_streamable_session(operation), timeout=self.timeout + self._execute_streamable_session(operation, normalized_url), + timeout=self.timeout, ) elif proxy_mode == ToolHiveProxyMode.SSE: return await asyncio.wait_for( - self._execute_sse_session(operation), timeout=self.timeout + self._execute_sse_session(operation, normalized_url), timeout=self.timeout ) else: logger.error(f"Unsupported transport type: {proxy_mode}", workload=self.workload) @@ -170,15 +233,15 @@ async def _execute_with_session(self, operation: Callable[[ClientSession], Await raise WorkloadConnectionError(f"MCP protocol error: {e}") from e async def _execute_streamable_session( - self, operation: Callable[[ClientSession], Awaitable] + self, operation: Callable[[ClientSession], Awaitable], url: str ) -> Any: """Execute operation with streamable HTTP session.""" logger.debug( f"Establishing streamable HTTP session for workload '{self.workload.name}'", workload=self.workload.name, - url=self.workload.url, + url=url, ) - async with streamablehttp_client(self.workload.url) as (read_stream, write_stream, _): + async with streamablehttp_client(url) as (read_stream, write_stream, _): async with ClientSession(read_stream, write_stream) as session: logger.info( f"Initializing MCP session for workload '{self.workload.name}'", @@ -191,14 +254,16 @@ async def _execute_streamable_session( ) return await operation(session) - async def _execute_sse_session(self, operation: Callable[[ClientSession], Awaitable]) -> Any: + async def _execute_sse_session( + self, operation: Callable[[ClientSession], Awaitable], url: str + ) -> Any: """Execute operation with SSE session.""" logger.debug( f"Establishing SSE session for workload '{self.workload.name}'", workload=self.workload.name, - url=self.workload.url, + url=url, ) - async with sse_client(self.workload.url) as (read_stream, write_stream): + async with sse_client(url) as (read_stream, write_stream): async with ClientSession(read_stream, write_stream) as session: logger.info( f"Initializing MCP session for workload '{self.workload.name}'", diff --git a/src/mcp_optimizer/toolhive/api_models/__init__.py b/src/mcp_optimizer/toolhive/api_models/__init__.py index 49be486..8e4ea8d 100644 --- a/src/mcp_optimizer/toolhive/api_models/__init__.py +++ b/src/mcp_optimizer/toolhive/api_models/__init__.py @@ -1,3 +1,3 @@ # generated by datamodel-codegen: # filename: http://127.0.0.1:8080/api/openapi.json -# timestamp: 2025-11-02T00:37:46+00:00 +# timestamp: 2025-12-02T00:36:43+00:00 diff --git a/src/mcp_optimizer/toolhive/api_models/audit.py b/src/mcp_optimizer/toolhive/api_models/audit.py index efc4b3e..0a8f590 100644 --- a/src/mcp_optimizer/toolhive/api_models/audit.py +++ b/src/mcp_optimizer/toolhive/api_models/audit.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: http://127.0.0.1:8080/api/openapi.json -# timestamp: 2025-11-02T00:37:46+00:00 +# timestamp: 2025-12-02T00:36:43+00:00 from __future__ import annotations diff --git a/src/mcp_optimizer/toolhive/api_models/auth.py b/src/mcp_optimizer/toolhive/api_models/auth.py index ac6c288..a55c0e1 100644 --- a/src/mcp_optimizer/toolhive/api_models/auth.py +++ b/src/mcp_optimizer/toolhive/api_models/auth.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: http://127.0.0.1:8080/api/openapi.json -# timestamp: 2025-11-02T00:37:46+00:00 +# timestamp: 2025-12-02T00:36:43+00:00 from __future__ import annotations diff --git a/src/mcp_optimizer/toolhive/api_models/authz.py b/src/mcp_optimizer/toolhive/api_models/authz.py index 2a42525..70cbf50 100644 --- a/src/mcp_optimizer/toolhive/api_models/authz.py +++ b/src/mcp_optimizer/toolhive/api_models/authz.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: http://127.0.0.1:8080/api/openapi.json -# timestamp: 2025-11-02T00:37:46+00:00 +# timestamp: 2025-12-02T00:36:43+00:00 from __future__ import annotations diff --git a/src/mcp_optimizer/toolhive/api_models/client.py b/src/mcp_optimizer/toolhive/api_models/client.py index cb9fc4d..8079800 100644 --- a/src/mcp_optimizer/toolhive/api_models/client.py +++ b/src/mcp_optimizer/toolhive/api_models/client.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: http://127.0.0.1:8080/api/openapi.json -# timestamp: 2025-11-02T00:37:46+00:00 +# timestamp: 2025-12-02T00:36:43+00:00 from __future__ import annotations diff --git a/src/mcp_optimizer/toolhive/api_models/core.py b/src/mcp_optimizer/toolhive/api_models/core.py index c3f209c..288cda7 100644 --- a/src/mcp_optimizer/toolhive/api_models/core.py +++ b/src/mcp_optimizer/toolhive/api_models/core.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: http://127.0.0.1:8080/api/openapi.json -# timestamp: 2025-11-02T00:37:46+00:00 +# timestamp: 2025-12-02T00:36:43+00:00 from __future__ import annotations diff --git a/src/mcp_optimizer/toolhive/api_models/groups.py b/src/mcp_optimizer/toolhive/api_models/groups.py index 5a0ad8b..40dc9ae 100644 --- a/src/mcp_optimizer/toolhive/api_models/groups.py +++ b/src/mcp_optimizer/toolhive/api_models/groups.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: http://127.0.0.1:8080/api/openapi.json -# timestamp: 2025-11-02T00:37:46+00:00 +# timestamp: 2025-12-02T00:36:43+00:00 from __future__ import annotations diff --git a/src/mcp_optimizer/toolhive/api_models/ignore.py b/src/mcp_optimizer/toolhive/api_models/ignore.py index 325eed6..38c928c 100644 --- a/src/mcp_optimizer/toolhive/api_models/ignore.py +++ b/src/mcp_optimizer/toolhive/api_models/ignore.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: http://127.0.0.1:8080/api/openapi.json -# timestamp: 2025-11-02T00:37:46+00:00 +# timestamp: 2025-12-02T00:36:43+00:00 from __future__ import annotations diff --git a/src/mcp_optimizer/toolhive/api_models/permissions.py b/src/mcp_optimizer/toolhive/api_models/permissions.py index 4030d1e..0239774 100644 --- a/src/mcp_optimizer/toolhive/api_models/permissions.py +++ b/src/mcp_optimizer/toolhive/api_models/permissions.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: http://127.0.0.1:8080/api/openapi.json -# timestamp: 2025-11-02T00:37:46+00:00 +# timestamp: 2025-12-02T00:36:43+00:00 from __future__ import annotations diff --git a/src/mcp_optimizer/toolhive/api_models/registry.py b/src/mcp_optimizer/toolhive/api_models/registry.py index 3a7d108..3a606ce 100644 --- a/src/mcp_optimizer/toolhive/api_models/registry.py +++ b/src/mcp_optimizer/toolhive/api_models/registry.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: http://127.0.0.1:8080/api/openapi.json -# timestamp: 2025-11-02T00:37:46+00:00 +# timestamp: 2025-12-02T00:36:43+00:00 from __future__ import annotations @@ -94,6 +94,9 @@ class OAuthConfig(BaseModel): None, description='OAuthParams contains additional OAuth parameters to include in the authorization request\nThese are server-specific parameters like "prompt", "response_mode", etc.', ) + resource: Optional[str] = Field( + None, description='Resource is the OAuth 2.0 resource indicator (RFC 8707)' + ) scopes: Optional[list[str]] = Field( None, description='Scopes are the OAuth scopes to request\nIf not specified, defaults to ["openid", "profile", "email"] for OIDC', diff --git a/src/mcp_optimizer/toolhive/api_models/remote.py b/src/mcp_optimizer/toolhive/api_models/remote.py new file mode 100644 index 0000000..232424e --- /dev/null +++ b/src/mcp_optimizer/toolhive/api_models/remote.py @@ -0,0 +1,39 @@ +# generated by datamodel-codegen: +# filename: http://127.0.0.1:8080/api/openapi.json +# timestamp: 2025-12-02T00:36:43+00:00 + +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel, Field + +from . import registry + + +class Config(BaseModel): + authorize_url: Optional[str] = None + callback_port: Optional[int] = None + client_id: Optional[str] = None + client_secret: Optional[str] = None + client_secret_file: Optional[str] = None + env_vars: Optional[list[registry.EnvVar]] = Field( + None, description='Environment variables for the client' + ) + headers: Optional[list[registry.Header]] = Field( + None, description='Headers for HTTP requests' + ) + issuer: Optional[str] = Field( + None, description='OAuth endpoint configuration (from registry)' + ) + oauth_params: Optional[dict[str, str]] = Field( + None, description='OAuth parameters for server-specific customization' + ) + resource: Optional[str] = Field( + None, description='Resource is the OAuth 2.0 resource indicator (RFC 8707).' + ) + scopes: Optional[list[str]] = None + skip_browser: Optional[bool] = None + timeout: Optional[str] = Field(None, examples=['5m']) + token_url: Optional[str] = None + use_pkce: Optional[bool] = None diff --git a/src/mcp_optimizer/toolhive/api_models/runner.py b/src/mcp_optimizer/toolhive/api_models/runner.py index 8e19d4a..8e40209 100644 --- a/src/mcp_optimizer/toolhive/api_models/runner.py +++ b/src/mcp_optimizer/toolhive/api_models/runner.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: http://127.0.0.1:8080/api/openapi.json -# timestamp: 2025-11-02T00:37:46+00:00 +# timestamp: 2025-12-02T00:36:43+00:00 from __future__ import annotations @@ -8,32 +8,17 @@ from pydantic import BaseModel, Field -from . import audit, auth, authz, ignore, permissions, registry, telemetry, types - - -class RemoteAuthConfig(BaseModel): - authorize_url: Optional[str] = None - callback_port: Optional[int] = None - client_id: Optional[str] = None - client_secret: Optional[str] = None - client_secret_file: Optional[str] = None - env_vars: Optional[list[registry.EnvVar]] = Field( - None, description='Environment variables for the client' - ) - headers: Optional[list[registry.Header]] = Field( - None, description='Headers for HTTP requests' - ) - issuer: Optional[str] = Field( - None, description='OAuth endpoint configuration (from registry)' - ) - oauth_params: Optional[dict[str, str]] = Field( - None, description='OAuth parameters for server-specific customization' - ) - scopes: Optional[list[str]] = None - skip_browser: Optional[bool] = None - timeout: Optional[str] = Field(None, examples=['5m']) - token_url: Optional[str] = None - use_pkce: Optional[bool] = None +from . import ( + audit, + auth, + authz, + ignore, + permissions, + remote, + telemetry, + tokenexchange, + types, +) class ToolOverride(BaseModel): @@ -116,7 +101,7 @@ class RunConfig(BaseModel): None, description='ProxyMode is the proxy mode for stdio transport ("sse" or "streamable-http")', ) - remote_auth_config: Optional[RemoteAuthConfig] = None + remote_auth_config: Optional[remote.Config] = None remote_url: Optional[str] = Field( None, description='RemoteURL is the URL of the remote MCP server (if running remotely)', @@ -141,6 +126,7 @@ class RunConfig(BaseModel): None, description='ThvCABundle is the path to the CA certificate bundle for ToolHive HTTP operations', ) + token_exchange_config: Optional[tokenexchange.Config] = None tools_filter: Optional[list[str]] = Field( None, description='ToolsFilter is the list of tools to filter' ) diff --git a/src/mcp_optimizer/toolhive/api_models/secrets.py b/src/mcp_optimizer/toolhive/api_models/secrets.py index fdd255b..de3555b 100644 --- a/src/mcp_optimizer/toolhive/api_models/secrets.py +++ b/src/mcp_optimizer/toolhive/api_models/secrets.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: http://127.0.0.1:8080/api/openapi.json -# timestamp: 2025-11-02T00:37:46+00:00 +# timestamp: 2025-12-02T00:36:43+00:00 from __future__ import annotations diff --git a/src/mcp_optimizer/toolhive/api_models/telemetry.py b/src/mcp_optimizer/toolhive/api_models/telemetry.py index 22baef1..949e2c6 100644 --- a/src/mcp_optimizer/toolhive/api_models/telemetry.py +++ b/src/mcp_optimizer/toolhive/api_models/telemetry.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: http://127.0.0.1:8080/api/openapi.json -# timestamp: 2025-11-02T00:37:46+00:00 +# timestamp: 2025-12-02T00:36:43+00:00 from __future__ import annotations diff --git a/src/mcp_optimizer/toolhive/api_models/tokenexchange.py b/src/mcp_optimizer/toolhive/api_models/tokenexchange.py new file mode 100644 index 0000000..e6ae47c --- /dev/null +++ b/src/mcp_optimizer/toolhive/api_models/tokenexchange.py @@ -0,0 +1,40 @@ +# generated by datamodel-codegen: +# filename: http://127.0.0.1:8080/api/openapi.json +# timestamp: 2025-12-02T00:36:43+00:00 + +from __future__ import annotations + +from typing import Optional + +from pydantic import BaseModel, Field + + +class Config(BaseModel): + audience: Optional[str] = Field( + None, description='Audience is the target audience for the exchanged token' + ) + client_id: Optional[str] = Field( + None, description='ClientID is the OAuth 2.0 client identifier' + ) + client_secret: Optional[str] = Field( + None, description='ClientSecret is the OAuth 2.0 client secret' + ) + external_token_header_name: Optional[str] = Field( + None, + description='ExternalTokenHeaderName is the name of the custom header to use when HeaderStrategy is "custom"', + ) + header_strategy: Optional[str] = Field( + None, + description='HeaderStrategy determines how to inject the token\nValid values: HeaderStrategyReplace (default), HeaderStrategyCustom', + ) + scopes: Optional[list[str]] = Field( + None, + description='Scopes is the list of scopes to request for the exchanged token', + ) + subject_token_type: Optional[str] = Field( + None, + description='SubjectTokenType specifies the type of the subject token being exchanged.\nCommon values: tokenTypeAccessToken (default), tokenTypeIDToken, tokenTypeJWT.\nIf empty, defaults to tokenTypeAccessToken.', + ) + token_url: Optional[str] = Field( + None, description='TokenURL is the OAuth 2.0 token endpoint URL' + ) diff --git a/src/mcp_optimizer/toolhive/api_models/types.py b/src/mcp_optimizer/toolhive/api_models/types.py index 1cb259d..cbf8f67 100644 --- a/src/mcp_optimizer/toolhive/api_models/types.py +++ b/src/mcp_optimizer/toolhive/api_models/types.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: http://127.0.0.1:8080/api/openapi.json -# timestamp: 2025-11-02T00:37:46+00:00 +# timestamp: 2025-12-02T00:36:43+00:00 from __future__ import annotations diff --git a/src/mcp_optimizer/toolhive/api_models/v1.py b/src/mcp_optimizer/toolhive/api_models/v1.py index 1abc279..a46f8e4 100644 --- a/src/mcp_optimizer/toolhive/api_models/v1.py +++ b/src/mcp_optimizer/toolhive/api_models/v1.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: http://127.0.0.1:8080/api/openapi.json -# timestamp: 2025-11-02T00:37:46+00:00 +# timestamp: 2025-12-02T00:36:43+00:00 from __future__ import annotations @@ -17,8 +17,9 @@ class UpdateRegistryRequest(BaseModel): allow_private_ip: Optional[bool] = Field( - None, description='Allow private IP addresses for registry URL' + None, description='Allow private IP addresses for registry URL or API URL' ) + api_url: Optional[str] = Field(None, description='MCP Registry API URL') local_path: Optional[str] = Field(None, description='Local registry file path') url: Optional[str] = Field(None, description='Registry URL (for remote registries)') @@ -165,6 +166,9 @@ class RemoteOAuthConfig(BaseModel): None, description='Additional OAuth parameters for server-specific customization', ) + resource: Optional[str] = Field( + None, description='OAuth 2.0 resource indicator (RFC 8707)' + ) scopes: Optional[list[str]] = Field(None, description='OAuth scopes to request') skip_browser: Optional[bool] = Field( None, diff --git a/src/mcp_optimizer/toolhive/toolhive_client.py b/src/mcp_optimizer/toolhive/toolhive_client.py index 12e8cf6..cc1ab44 100644 --- a/src/mcp_optimizer/toolhive/toolhive_client.py +++ b/src/mcp_optimizer/toolhive/toolhive_client.py @@ -162,7 +162,7 @@ def _parse_toolhive_version(self, version_str: str) -> Version: logger.info( "Using default version for non-semver ToolHive build", version=version_str, - default_version="0.0.0-dev" + default_version="0.0.0-dev", ) return Version.parse("0.0.0-dev") diff --git a/tests/test_mcp_client.py b/tests/test_mcp_client.py index 2cced53..6518de3 100644 --- a/tests/test_mcp_client.py +++ b/tests/test_mcp_client.py @@ -325,24 +325,26 @@ async def test_workload_url_unchanged_during_list_tools( @pytest.mark.asyncio @pytest.mark.parametrize( - "url,proxy_mode,client_mock_name,context_return", + "url,proxy_mode,client_mock_name,context_return,expected_normalized_url", [ ( "http://localhost:8080/mcp/test-server", None, "streamablehttp_client", (AsyncMock(), AsyncMock(), AsyncMock()), + "http://localhost:8080/mcp/test-server", # Already contains /mcp, no normalization ), ( "http://localhost:8080/custom/endpoint", "streamable-http", "streamablehttp_client", (AsyncMock(), AsyncMock(), AsyncMock()), + "http://localhost:8080/custom/endpoint/mcp", # Normalized to add /mcp ), ], ) async def test_workload_url_unchanged_during_call_tool( - url, proxy_mode, client_mock_name, context_return, mock_mcp_session + url, proxy_mode, client_mock_name, context_return, expected_normalized_url, mock_mcp_session ): """Test that workload URL remains unchanged during call_tool.""" workload = Workload( @@ -368,11 +370,11 @@ async def test_workload_url_unchanged_during_call_tool( # Call tool await client.call_tool("test_tool", {"param": "value"}) - # Verify URL is unchanged in workload + # Verify URL is unchanged in workload (we don't mutate the workload object) assert workload.url == url - # Verify the client was called with the original URL - mock_client.assert_called_once_with(url) + # Verify the client was called with the normalized URL (normalization happens internally) + mock_client.assert_called_once_with(expected_normalized_url) @pytest.mark.asyncio