├── .codespellrc ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── add-an-mcp-server.md │ └── kubernetes-issue.md ├── dependabot.yml └── workflows │ ├── e2e-tests.yml │ ├── image-build-and-publish.yml │ ├── lint-helm-charts.yml │ ├── lint.yml │ ├── operator-ci.yml │ ├── releaser-helm-charts.yml │ ├── releaser.yml │ ├── run-on-main-charts.yml │ ├── run-on-main.yml │ ├── run-on-pr-charts.yml │ ├── run-on-pr.yml │ ├── spellcheck.yml │ ├── test-helm-charts.yml │ ├── test.yml │ ├── update-registry.yml │ ├── verify-docgen.yml │ └── verify-swagger.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yaml ├── .pre-commit-config.yaml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── PROJECT ├── README.md ├── SECURITY.MD ├── Taskfile.yml ├── cmd ├── help │ ├── main.go │ └── verify.sh ├── regup │ ├── app │ │ ├── root.go │ │ └── update.go │ └── main.go ├── thv-operator │ ├── DESIGN.md │ ├── README.md │ ├── Taskfile.yml │ ├── api │ │ └── v1alpha1 │ │ │ ├── groupversion_info.go │ │ │ ├── mcpserver_types.go │ │ │ └── zz_generated.deepcopy.go │ ├── controllers │ │ ├── mcpserver_controller.go │ │ ├── mcpserver_oidc_test.go │ │ ├── mcpserver_pod_template_test.go │ │ └── mcpserver_resource_overrides_test.go │ └── main.go └── thv │ ├── app │ ├── commands.go │ ├── common.go │ ├── config.go │ ├── constants.go │ ├── inspector.go │ ├── list.go │ ├── logs.go │ ├── proxy.go │ ├── registry.go │ ├── restart.go │ ├── rm.go │ ├── run.go │ ├── run_common.go │ ├── search.go │ ├── secret.go │ ├── server.go │ ├── stop.go │ └── version.go │ └── main.go ├── cr.yaml ├── ct-install.yaml ├── ct.yaml ├── dco.md ├── deploy └── charts │ ├── _templates.gotmpl │ ├── operator-crds │ ├── .helmignore │ ├── Chart.yaml │ ├── README.md │ ├── README.md.gotmpl │ ├── ci │ │ └── default-values.yaml │ ├── templates │ │ └── toolhive.stacklok.dev_mcpservers.yaml │ └── values.yaml │ └── operator │ ├── .helmignore │ ├── Chart.yaml │ ├── README.md │ ├── README.md.gotmpl │ ├── ci │ ├── autoScalingEnabled-values.yaml │ ├── default-values.yaml │ ├── extraEnvVars-values.yaml │ ├── extraPodAndContainerSecurityContext-values.yaml │ ├── extraPodAnnotationsAndLabels-values.yaml │ └── extraVolumes-values.yaml │ ├── templates │ ├── _helpers.tpl │ ├── clusterrole │ │ ├── role.yaml │ │ └── rolebinding.yaml │ ├── deployment.yaml │ ├── hpa.yaml │ ├── leader-election-role.yaml │ ├── serviceaccount.yaml │ └── toolhive-rbac.yaml │ └── values.yaml ├── docs ├── authz.md ├── cli │ ├── thv.md │ ├── thv_config.md │ ├── thv_config_auto-discovery.md │ ├── thv_config_get-ca-cert.md │ ├── thv_config_get-registry-url.md │ ├── thv_config_list-registered-clients.md │ ├── thv_config_register-client.md │ ├── thv_config_remove-client.md │ ├── thv_config_secrets-provider.md │ ├── thv_config_set-ca-cert.md │ ├── thv_config_set-registry-url.md │ ├── thv_config_unset-ca-cert.md │ ├── thv_config_unset-registry-url.md │ ├── thv_inspector.md │ ├── thv_list.md │ ├── thv_logs.md │ ├── thv_logs_prune.md │ ├── thv_proxy.md │ ├── thv_registry.md │ ├── thv_registry_info.md │ ├── thv_registry_list.md │ ├── thv_restart.md │ ├── thv_rm.md │ ├── thv_run.md │ ├── thv_search.md │ ├── thv_secret.md │ ├── thv_secret_delete.md │ ├── thv_secret_get.md │ ├── thv_secret_list.md │ ├── thv_secret_provider.md │ ├── thv_secret_reset-keyring.md │ ├── thv_secret_set.md │ ├── thv_serve.md │ ├── thv_stop.md │ └── thv_version.md ├── images │ ├── thv-readme-demo.svg │ └── toolhive.png ├── kind │ ├── deploying-mcp-server-with-operator.md │ ├── deploying-toolhive-operator.md │ ├── ingress-port-forward.md │ ├── ingress.md │ └── setup-kind-cluster.md ├── middleware.md ├── registry │ ├── heuristics.md │ └── management.md └── server │ ├── README.md │ ├── docs.go │ ├── swagger.json │ └── swagger.yaml ├── examples ├── authz-config-with-entities.json ├── authz-config.json └── operator │ └── mcp-servers │ ├── mcpserver_fetch.yaml │ ├── mcpserver_github.yaml │ ├── mcpserver_mkp.yaml │ ├── mcpserver_with_configmap_oidc.yaml │ ├── mcpserver_with_inline_oidc.yaml │ ├── mcpserver_with_kubernetes_oidc.yaml │ ├── mcpserver_with_pod_template.yaml │ └── mcpserver_with_resource_overrides.yaml ├── go.mod ├── go.sum ├── hack └── boilerplate.go.txt ├── pkg ├── api │ ├── docs.go │ ├── openapi.go │ ├── scalar.go │ ├── server.go │ └── v1 │ │ ├── discovery.go │ │ ├── healtcheck_test.go │ │ ├── healthcheck.go │ │ ├── registry.go │ │ ├── servers.go │ │ ├── version.go │ │ └── version_test.go ├── audit │ ├── auditor.go │ ├── auditor_test.go │ ├── config.go │ ├── config_test.go │ ├── event.go │ ├── event_test.go │ └── mcp_events.go ├── auth │ ├── anonymous.go │ ├── anonymous_test.go │ ├── jwt.go │ ├── jwt_test.go │ ├── local.go │ ├── local_test.go │ ├── oauth │ │ ├── flow.go │ │ ├── flow_test.go │ │ ├── oidc.go │ │ └── oidc_test.go │ ├── utils.go │ └── utils_test.go ├── authz │ ├── cedar.go │ ├── cedar_entities_test.go │ ├── cedar_entity.go │ ├── cedar_record_test.go │ ├── cedar_test.go │ ├── config.go │ ├── config_test.go │ ├── integration_test.go │ ├── middleware.go │ ├── middleware_test.go │ ├── response_filter.go │ └── response_filter_test.go ├── certs │ ├── validation.go │ └── validation_test.go ├── client │ ├── config.go │ ├── config_editor.go │ ├── config_editor_test.go │ ├── config_test.go │ ├── discovery.go │ └── discovery_test.go ├── config │ ├── config.go │ ├── config_test.go │ └── singleton.go ├── container │ ├── docker │ │ ├── client.go │ │ ├── client_unix.go │ │ ├── client_windows.go │ │ └── monitor.go │ ├── factory.go │ ├── kubernetes │ │ ├── client.go │ │ └── client_test.go │ ├── name.go │ ├── runtime │ │ └── types.go │ ├── templates │ │ ├── go.tmpl │ │ ├── npx.tmpl │ │ ├── templates.go │ │ ├── templates_test.go │ │ └── uvx.tmpl │ └── verifier │ │ ├── attestations.go │ │ ├── sigstore.go │ │ ├── tufroots │ │ └── tuf-repo.github.com │ │ │ └── root.json │ │ ├── utils.go │ │ └── verifier.go ├── environment │ ├── environment.go │ └── environment_test.go ├── errors │ └── errors.go ├── labels │ ├── labels.go │ └── labels_test.go ├── lifecycle │ ├── manager.go │ ├── permissions.go │ ├── permissions_test.go │ ├── sysproc_unix.go │ └── sysproc_windows.go ├── logger │ ├── logger.go │ ├── logger_test.go │ └── logr.go ├── mcp │ ├── parser.go │ └── parser_test.go ├── networking │ ├── http_client.go │ └── port.go ├── operator │ └── telemetry │ │ ├── telemetry.go │ │ └── telemetry_test.go ├── permissions │ ├── profile.go │ └── profile_test.go ├── process │ ├── detached.go │ ├── find_unix.go │ ├── find_windows.go │ ├── kill_unix.go │ ├── kill_windows.go │ └── pid.go ├── registry │ ├── data │ │ └── registry.json │ ├── registry.go │ ├── registry_test.go │ └── types.go ├── runner │ ├── config.go │ ├── config_test.go │ ├── protocol.go │ ├── protocol_test.go │ ├── runner.go │ └── state_helpers.go ├── secrets │ ├── 1password.go │ ├── 1password_test.go │ ├── aes │ │ ├── aes.go │ │ └── aes_test.go │ ├── encrypted.go │ ├── encrypted_test.go │ ├── factory.go │ ├── mocks │ │ └── mock_onepassword.go │ └── types.go ├── state │ ├── factory.go │ ├── interface.go │ └── local.go ├── transport │ ├── errors.go │ ├── errors │ │ └── errors.go │ ├── factory.go │ ├── proxy │ │ ├── httpsse │ │ │ └── http_proxy.go │ │ ├── manager.go │ │ └── transparent │ │ │ └── transparent_proxy.go │ ├── sse.go │ ├── ssecommon │ │ └── sse_common.go │ ├── stdio.go │ ├── stdio_test.go │ └── types │ │ └── transport.go ├── updates │ ├── checker.go │ ├── checker_test.go │ ├── client.go │ └── client_test.go └── versions │ └── version.go ├── renovate.json └── test └── e2e ├── e2e_suite_test.go ├── fetch_mcp_server_test.go ├── helpers.go ├── inspector_test.go ├── mcp_client_helpers.go ├── oidc_mock.go ├── osv_mcp_server_test.go ├── proxy_oauth_test.go └── run_tests.sh /.codespellrc: -------------------------------------------------------------------------------- 1 | [codespell] 2 | ignore-words-list = NotIn,notin,AfterAll,ND 3 | skip = *.svg,*.mod,*.sum 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # This file is documented at https://git-scm.com/docs/gitattributes. 2 | # Linguist-specific attributes are documented at 3 | # https://github.com/github/linguist. 4 | 5 | docs/cli/thv*.md linguist-generated=true 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/add-an-mcp-server.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Add an MCP server 3 | about: Requests for adding an MCP server to the registry list 4 | title: '' 5 | labels: mcp 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## MCP Server Information 11 | 12 | **Server Name:** 13 | **Repository URL:** 14 | **Brief Description:** 15 | 16 | ## Basic Requirements 17 | 18 | - [ ] **Open Source**: Uses acceptable license (Apache-2.0, MIT, BSD-2-Clause, BSD-3-Clause or other permissive license) 19 | - [ ] **MCP Compliant**: Implements MCP API specification 20 | - [ ] **Active Development**: Recent commits and maintained 21 | - [ ] **Documentation**: Basic README and setup instructions 22 | - [ ] **Security Contact**: Method for reporting security issues 23 | 24 | ## Submitter Checklist 25 | 26 | - [ ] I have read the [MCP Server Registry Inclusion Heuristics](https://github.com/stacklok/toolhive/blob/main/docs/registry/heuristics.md) 27 | - [ ] This server meets the basic requirements listed above 28 | - [ ] I understand this will undergo automated and manual review based on [Registry Management](https://github.com/stacklok/toolhive/blob/main/docs/registry/management.md) 29 | 30 | --- 31 | 32 | ## For Registry Maintainers Only 33 | 34 | Evaluate the request following the [MCP Server Registry Inclusion Heuristics](https://github.com/stacklok/toolhive/blob/main/docs/registry/heuristics.md) document 35 | 36 | **Review Checklist:** 37 | - [ ] License verified 38 | - [ ] MCP compliance confirmed 39 | - [ ] Security practices adequate 40 | - [ ] Community health acceptable 41 | - [ ] Documentation sufficient 42 | - [ ] No significant duplication 43 | 44 | **Decision:** [ ] Approved [ ] Rejected [ ] Needs Changes 45 | 46 | **Notes:** 47 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/kubernetes-issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Kubernetes Issue / Feature Request 3 | about: Issues or feature requests relating to ToolHive a Kubernetes Context (ToolHive Operator, Helm Charts, general Kubernetes etc) 4 | title: '' 5 | labels: kubernetes 6 | assignees: ChrisJBurns 7 | --- -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Enable version updates for Go modules 4 | - package-ecosystem: "gomod" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | open-pull-requests-limit: 10 9 | 10 | # Enable version updates for GitHub Actions 11 | - package-ecosystem: "github-actions" 12 | directory: "/" 13 | schedule: 14 | interval: "weekly" 15 | open-pull-requests-limit: 10 -------------------------------------------------------------------------------- /.github/workflows/e2e-tests.yml: -------------------------------------------------------------------------------- 1 | name: E2E Tests 2 | 3 | on: 4 | workflow_call: 5 | 6 | permissions: 7 | contents: read 8 | 9 | jobs: 10 | e2e-tests: 11 | name: E2E Tests 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 18 | with: 19 | go-version-file: 'go.mod' 20 | cache: true 21 | 22 | - name: Install Task 23 | uses: arduino/setup-task@b91d5d2c96a56797b48ac1e0e89220bf64044611 # v2 24 | with: 25 | version: 3.x 26 | repo-token: ${{ secrets.GITHUB_TOKEN }} 27 | 28 | - name: Install Ginkgo CLI 29 | run: go install github.com/onsi/ginkgo/v2/ginkgo@latest 30 | 31 | - name: Build ToolHive binary 32 | run: | 33 | task build 34 | # Verify the binary was created and is executable 35 | ls -la ./bin/ 36 | chmod +x ./bin/thv 37 | 38 | - name: Set up container runtime (Docker) 39 | run: | 40 | # Docker is already installed on ubuntu-latest 41 | docker --version 42 | # Start Docker daemon if not running 43 | sudo systemctl start docker 44 | 45 | - name: Run E2E tests 46 | env: 47 | THV_BINARY: ${{ github.workspace }}/bin/thv 48 | TEST_TIMEOUT: 15m 49 | run: ./test/e2e/run_tests.sh 50 | 51 | - name: Upload test results 52 | if: always() 53 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 54 | with: 55 | name: e2e-test-results 56 | path: | 57 | test/e2e/ginkgo-report.xml 58 | test/e2e/junit-report.xml 59 | retention-days: 7 -------------------------------------------------------------------------------- /.github/workflows/lint-helm-charts.yml: -------------------------------------------------------------------------------- 1 | name: Lint Helm Charts 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | workflow_call: 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | env: 13 | GO111MODULE: on 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 17 | 18 | - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 19 | with: 20 | python-version: 3.12 21 | 22 | - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 23 | with: 24 | go-version: ^1 25 | 26 | - name: Setup helm-docs 27 | run: go install github.com/norwoodj/helm-docs/cmd/helm-docs@latest 28 | 29 | - name: Run pre-commit 30 | uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Linting 2 | 3 | on: 4 | workflow_call: 5 | 6 | permissions: 7 | contents: read 8 | 9 | jobs: 10 | lint: 11 | name: Lint 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 18 | with: 19 | go-version-file: 'go.mod' 20 | cache: true 21 | 22 | - name: Run golangci-lint 23 | uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 24 | with: 25 | args: --timeout=5m 26 | -------------------------------------------------------------------------------- /.github/workflows/releaser-helm-charts.yml: -------------------------------------------------------------------------------- 1 | name: Publish Helm Charts 2 | on: 3 | workflow_call: 4 | 5 | jobs: 6 | release: 7 | runs-on: ubuntu-latest 8 | 9 | permissions: 10 | contents: write 11 | packages: write 12 | id-token: write 13 | 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Configure Git 21 | run: | 22 | git config user.name "$GITHUB_ACTOR" 23 | git config user.email "$GITHUB_ACTOR@users.noreply.github.com" 24 | 25 | - name: Run chart-releaser 26 | uses: helm/chart-releaser-action@cae68fefc6b5f367a0275617c9f83181ba54714f # v1.7.0 27 | with: 28 | config: cr.yaml 29 | charts_dir: deploy/charts 30 | mark_as_latest: false 31 | env: 32 | CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}" 33 | 34 | - name: Login to GitHub Container Registry 35 | uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 36 | with: 37 | registry: ghcr.io 38 | username: ${{ github.actor }} 39 | password: ${{ secrets.GITHUB_TOKEN }} 40 | 41 | - name: Install Cosign 42 | uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb # v3.8.2 43 | 44 | - name: Publish and Sign OCI Charts 45 | run: | 46 | for chart in `find .cr-release-packages -name '*.tgz' -print`; do 47 | helm push ${chart} oci://ghcr.io/${GITHUB_REPOSITORY} |& tee helm-push-output.log 48 | file_name=${chart##*/} 49 | chart_name=${file_name%-*} 50 | digest=$(awk -F "[, ]+" '/Digest/{print $NF}' < helm-push-output.log) 51 | cosign sign -y "ghcr.io/${GITHUB_REPOSITORY}/${chart_name}@${digest}" 52 | done 53 | env: 54 | COSIGN_EXPERIMENTAL: 1 55 | -------------------------------------------------------------------------------- /.github/workflows/run-on-main-charts.yml: -------------------------------------------------------------------------------- 1 | # These set of workflows run on every push to the main branch 2 | name: Main build for Helm Charts 3 | 4 | on: 5 | workflow_dispatch: 6 | push: 7 | branches: [ main ] 8 | paths: 9 | - deploy/charts/** 10 | 11 | jobs: 12 | publish-charts: 13 | name: Publish Helm Charts 14 | permissions: 15 | contents: write 16 | packages: write 17 | id-token: write 18 | uses: ./.github/workflows/releaser-helm-charts.yml 19 | -------------------------------------------------------------------------------- /.github/workflows/run-on-main.yml: -------------------------------------------------------------------------------- 1 | # These set of workflows run on every push to the main branch 2 | name: Main build 3 | 4 | on: 5 | workflow_dispatch: 6 | push: 7 | branches: [ main ] 8 | 9 | jobs: 10 | linting: 11 | name: Linting 12 | uses: ./.github/workflows/lint.yml 13 | tests: 14 | name: Tests 15 | uses: ./.github/workflows/test.yml 16 | e2e-tests: 17 | name: E2E Tests 18 | uses: ./.github/workflows/e2e-tests.yml 19 | swagger: 20 | name: Swagger 21 | uses: ./.github/workflows/verify-swagger.yml 22 | image-build-and-push: 23 | name: Build and Sign Image 24 | needs: [ linting, tests, e2e-tests, swagger ] 25 | permissions: 26 | contents: write 27 | packages: write 28 | id-token: write 29 | uses: ./.github/workflows/image-build-and-publish.yml 30 | operator-ci: 31 | name: Operator CI 32 | permissions: 33 | contents: read 34 | uses: ./.github/workflows/operator-ci.yml -------------------------------------------------------------------------------- /.github/workflows/run-on-pr-charts.yml: -------------------------------------------------------------------------------- 1 | # These set of workflows run on every push to the main branch 2 | name: PR Checks Helm Charts 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | workflow_dispatch: 8 | pull_request: 9 | paths: 10 | - deploy/charts/** 11 | 12 | jobs: 13 | spellcheck: 14 | name: Spellcheck 15 | uses: ./.github/workflows/spellcheck.yml 16 | linting: 17 | name: Linting 18 | uses: ./.github/workflows/lint-helm-charts.yml 19 | tests: 20 | name: Tests 21 | uses: ./.github/workflows/test-helm-charts.yml 22 | -------------------------------------------------------------------------------- /.github/workflows/run-on-pr.yml: -------------------------------------------------------------------------------- 1 | # These set of workflows run on every push to the main branch 2 | name: PR Checks 3 | 4 | on: 5 | workflow_dispatch: 6 | pull_request: 7 | paths-ignore: 8 | - deploy/charts/** 9 | 10 | jobs: 11 | spellcheck: 12 | name: Spellcheck 13 | uses: ./.github/workflows/spellcheck.yml 14 | linting: 15 | name: Linting 16 | uses: ./.github/workflows/lint.yml 17 | tests: 18 | name: Tests 19 | uses: ./.github/workflows/test.yml 20 | e2e-tests: 21 | name: E2E Tests 22 | uses: ./.github/workflows/e2e-tests.yml 23 | docs: 24 | name: Docs 25 | uses: ./.github/workflows/verify-docgen.yml 26 | swagger: 27 | name: Swagger 28 | uses: ./.github/workflows/verify-swagger.yml 29 | operator-ci: 30 | name: Operator CI 31 | permissions: 32 | contents: read 33 | uses: ./.github/workflows/operator-ci.yml 34 | -------------------------------------------------------------------------------- /.github/workflows/spellcheck.yml: -------------------------------------------------------------------------------- 1 | name: Spellcheck 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | workflow_call: 8 | 9 | jobs: 10 | codespell: 11 | name: Codespell 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout Code 15 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 16 | - name: Codespell 17 | uses: codespell-project/actions-codespell@406322ec52dd7b488e48c1c4b82e2a8b3a1bf630 # v2 18 | with: 19 | skip: .git 20 | check_filenames: true 21 | check_hidden: true -------------------------------------------------------------------------------- /.github/workflows/test-helm-charts.yml: -------------------------------------------------------------------------------- 1 | name: Test Charts 2 | 3 | permissions: 4 | contents: read 5 | 6 | on: 7 | workflow_call: 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Set up Helm 19 | uses: azure/setup-helm@b9e51907a09c216f16ebe8536097933489208112 # v4.3.0 20 | with: 21 | version: v3.10.0 22 | 23 | - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 24 | with: 25 | python-version: 3.12 26 | 27 | - name: Set up chart-testing 28 | uses: helm/chart-testing-action@0d28d3144d3a25ea2cc349d6e59901c4ff469b3b # v2.7.0 29 | 30 | - name: Run chart-testing (lint) 31 | run: ct lint --config ct.yaml 32 | 33 | - name: Create KIND Cluster 34 | uses: helm/kind-action@a1b0e391336a6ee6713a0583f8c6240d70863de3 # v1.12.0 35 | 36 | - name: Run chart-testing (install) 37 | run: ct install --config ct-install.yaml -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | workflow_call: 5 | 6 | permissions: 7 | contents: read 8 | 9 | jobs: 10 | test: 11 | name: Test 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 18 | with: 19 | go-version-file: 'go.mod' 20 | cache: true 21 | 22 | - name: Install Task 23 | uses: arduino/setup-task@v2 24 | with: 25 | version: 3.x 26 | repo-token: ${{ secrets.GITHUB_TOKEN }} 27 | 28 | - name: Run tests 29 | run: task test 30 | -------------------------------------------------------------------------------- /.github/workflows/update-registry.yml: -------------------------------------------------------------------------------- 1 | name: Update Registry 2 | 3 | on: 4 | schedule: 5 | # Run once a day at midnight UTC 6 | - cron: '0 0 * * *' 7 | workflow_dispatch: 8 | # Allow manual triggering 9 | inputs: 10 | count: 11 | description: 'Number of entries to update' 12 | required: false 13 | default: 150 14 | type: number 15 | 16 | permissions: 17 | contents: write 18 | pull-requests: write 19 | 20 | jobs: 21 | update-registry: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Checkout repository 25 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 26 | 27 | - name: Set up Go 28 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 29 | with: 30 | go-version-file: 'go.mod' 31 | cache: true 32 | 33 | - name: Build regup command 34 | run: go build -o bin/regup ./cmd/regup 35 | 36 | - name: Set count 37 | id: set-count 38 | run: | 39 | if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then 40 | echo "count=${{ github.event.inputs.count }}" >> $GITHUB_OUTPUT 41 | else 42 | echo "count=150" >> $GITHUB_OUTPUT 43 | fi 44 | 45 | - name: Update registry 46 | id: update 47 | run: | 48 | # Run regup with the specified count 49 | ./bin/regup update --count ${{ steps.set-count.outputs.count }} 50 | 51 | # Check if there are changes 52 | if git diff --exit-code pkg/registry/data/registry.json; then 53 | echo "changes=false" >> $GITHUB_OUTPUT 54 | echo "No changes to the registry" 55 | else 56 | echo "changes=true" >> $GITHUB_OUTPUT 57 | echo "Changes detected in the registry" 58 | fi 59 | env: 60 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 61 | 62 | - name: Create Pull Request 63 | if: steps.update.outputs.changes == 'true' 64 | uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7 65 | with: 66 | token: ${{ secrets.GITHUB_TOKEN }} 67 | commit-message: "Update registry with latest stars and pulls" 68 | title: "Update registry with latest stars and pulls" 69 | body: | 70 | This PR updates the registry with the latest GitHub stars and pulls information. 71 | 72 | The update was performed automatically by the `regup` command. 73 | branch: update-registry 74 | base: main 75 | delete-branch: true -------------------------------------------------------------------------------- /.github/workflows/verify-docgen.yml: -------------------------------------------------------------------------------- 1 | name: Docgen 2 | 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | docgen: 8 | name: Verify Docgen 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 13 | - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 14 | with: 15 | go-version-file: go.mod 16 | - run: ./cmd/help/verify.sh 17 | -------------------------------------------------------------------------------- /.github/workflows/verify-swagger.yml: -------------------------------------------------------------------------------- 1 | name: Swagger 2 | 3 | on: 4 | workflow_call: 5 | 6 | permissions: 7 | contents: read 8 | 9 | jobs: 10 | swagger: 11 | name: Verify Swagger 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 16 | - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 17 | with: 18 | go-version-file: go.mod 19 | - name: Install Task 20 | uses: arduino/setup-task@v2 21 | with: 22 | version: 3.x 23 | repo-token: ${{ secrets.GITHUB_TOKEN }} 24 | - name: Install swagger 25 | run: task swagger-install 26 | - name: Generate swagger files 27 | run: task swagger-gen 28 | - name: Check for changes 29 | run: | 30 | if ! git diff --exit-code docs/server/; then 31 | echo "❌ Swagger files are not up to date!" 32 | echo "Please run 'task swagger-gen' or 'swag init -g pkg/api/server.go --v3.1 -o docs/server' and commit the changes." 33 | exit 1 34 | else 35 | echo "✅ Swagger files are up to date!" 36 | fi 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | # Go workspace file 18 | go.work 19 | 20 | # IDE specific files 21 | .idea/ 22 | .vscode/ 23 | *.swp 24 | *.swo 25 | 26 | # Build output 27 | /bin/ 28 | /dist/ 29 | /coverage/ 30 | 31 | .roo/ 32 | ^thv$ 33 | 34 | .claude/ 35 | kconfig.yaml 36 | 37 | .DS_Store 38 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | run: 3 | issues-exit-code: 1 4 | output: 5 | formats: 6 | text: 7 | path: stdout 8 | print-linter-name: true 9 | print-issued-lines: true 10 | linters: 11 | default: none 12 | enable: 13 | - depguard 14 | - exhaustive 15 | - goconst 16 | - gocyclo 17 | - gosec 18 | - govet 19 | - ineffassign 20 | - lll 21 | - paralleltest 22 | - promlinter 23 | - revive 24 | - staticcheck 25 | - thelper 26 | - tparallel 27 | - unparam 28 | - unused 29 | settings: 30 | depguard: 31 | rules: 32 | prevent_unmaintained_packages: 33 | list-mode: lax 34 | files: 35 | - $all 36 | - '!$test' 37 | deny: 38 | - pkg: io/ioutil 39 | desc: this is deprecated 40 | gocyclo: 41 | min-complexity: 15 42 | gosec: 43 | excludes: 44 | - G601 45 | lll: 46 | line-length: 130 47 | revive: 48 | severity: warning 49 | rules: 50 | - name: blank-imports 51 | severity: warning 52 | - name: context-as-argument 53 | - name: context-keys-type 54 | - name: duplicated-imports 55 | - name: error-naming 56 | - name: error-return 57 | - name: exported 58 | severity: error 59 | - name: if-return 60 | - name: identical-branches 61 | - name: indent-error-flow 62 | - name: import-shadowing 63 | - name: package-comments 64 | - name: redefines-builtin-id 65 | - name: struct-tag 66 | - name: unconditional-recursion 67 | - name: unnecessary-stmt 68 | - name: unreachable-code 69 | - name: unused-parameter 70 | - name: unused-receiver 71 | - name: unhandled-error 72 | disabled: true 73 | exclusions: 74 | generated: lax 75 | rules: 76 | - linters: 77 | - lll 78 | - gocyclo 79 | - errcheck 80 | - dupl 81 | - gosec 82 | - paralleltest 83 | path: (.+)_test\.go 84 | - linters: 85 | - lll 86 | path: .golangci.yml 87 | paths: 88 | - third_party$ 89 | - builtin$ 90 | - examples$ 91 | formatters: 92 | enable: 93 | - gci 94 | - gofmt 95 | settings: 96 | gci: 97 | sections: 98 | - standard 99 | - default 100 | - prefix(github.com/stacklok/toolhive) 101 | exclusions: 102 | generated: lax 103 | paths: 104 | - third_party$ 105 | - builtin$ 106 | - examples$ 107 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/norwoodj/helm-docs 3 | rev: v1.2.0 4 | hooks: 5 | - id: helm-docs 6 | args: 7 | # Make the tool search for charts only under the ``charts` directory 8 | - --chart-search-root=deploy/charts 9 | # The `./` makes it relative to the chart-search-root set above 10 | - --template-files=./_templates.gotmpl 11 | # A base filename makes it relative to each chart directory found 12 | - --template-files=README.md.gotmpl 13 | - repo: https://github.com/codespell-project/codespell 14 | rev: v2.4.1 15 | hooks: 16 | - id: codespell -------------------------------------------------------------------------------- /PROJECT: -------------------------------------------------------------------------------- 1 | domain: toolhive.stacklok.dev 2 | layout: 3 | - go.kubebuilder.io/v3 4 | projectName: thv-operator 5 | repo: github.com/stacklok/toolhive 6 | resources: 7 | - api: 8 | crdVersion: v1 9 | namespaced: true 10 | controller: true 11 | domain: toolhive.stacklok.dev 12 | group: toolhive 13 | kind: MCPServer 14 | path: github.com/stacklok/toolhive/cmd/thv-operator/api/v1alpha1 15 | version: v1alpha1 16 | version: "3" -------------------------------------------------------------------------------- /cmd/help/main.go: -------------------------------------------------------------------------------- 1 | // Package main is the entry point for the ToolHive CLI Doc Generator. 2 | package main 3 | 4 | import ( 5 | "fmt" 6 | "os" 7 | 8 | "github.com/spf13/cobra" 9 | "github.com/spf13/cobra/doc" 10 | 11 | cli "github.com/stacklok/toolhive/cmd/thv/app" 12 | ) 13 | 14 | func main() { 15 | var dir string 16 | root := &cobra.Command{ 17 | Use: "gendoc", 18 | Short: "Generate ToolHive's help docs", 19 | SilenceUsage: true, 20 | Args: cobra.NoArgs, 21 | RunE: func(*cobra.Command, []string) error { 22 | return doc.GenMarkdownTree(cli.NewRootCmd(false), dir) 23 | }, 24 | } 25 | root.Flags().StringVarP(&dir, "dir", "d", "doc", "Path to directory in which to generate docs") 26 | if err := root.Execute(); err != nil { 27 | fmt.Println(err) 28 | os.Exit(1) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /cmd/help/verify.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | # Verify that generated Markdown docs are up-to-date. 5 | tmpdir=$(mktemp -d) 6 | go run cmd/help/main.go --dir "$tmpdir" 7 | diff -Naur "$tmpdir" docs/cli/ 8 | echo "######################################################################################" 9 | echo "If diffs are found, please run: \`task docs\` to regenerate the docs." 10 | echo "######################################################################################" 11 | -------------------------------------------------------------------------------- /cmd/regup/app/root.go: -------------------------------------------------------------------------------- 1 | // Package app provides the entry point for the regup command-line application. 2 | package app 3 | 4 | import ( 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | var rootCmd = &cobra.Command{ 9 | Use: "regup", 10 | DisableAutoGenTag: true, 11 | Short: "Update MCP server registry entries with latest information", 12 | Long: `regup is a utility for updating MCP server registry entries with the latest information. 13 | It identifies the oldest entries in the registry and updates them with the latest GitHub stars and pulls data. 14 | This tool is designed to be run as a GitHub action to keep the registry up-to-date.`, 15 | Run: func(cmd *cobra.Command, _ []string) { 16 | // If no flags are provided, run the update command 17 | if err := updateCmd.RunE(cmd, nil); err != nil { 18 | cmd.PrintErrf("Error: %v\n", err) 19 | } 20 | }, 21 | } 22 | 23 | // NewRootCmd creates a new root command for the regup CLI. 24 | func NewRootCmd() *cobra.Command { 25 | // Add persistent flags 26 | rootCmd.PersistentFlags().Bool("debug", false, "Enable debug mode") 27 | 28 | // Add subcommands 29 | rootCmd.AddCommand(updateCmd) 30 | 31 | return rootCmd 32 | } 33 | -------------------------------------------------------------------------------- /cmd/regup/main.go: -------------------------------------------------------------------------------- 1 | // Package main is the entry point for the regup command 2 | package main 3 | 4 | import ( 5 | "os" 6 | 7 | "github.com/stacklok/toolhive/cmd/regup/app" 8 | "github.com/stacklok/toolhive/pkg/logger" 9 | ) 10 | 11 | func main() { 12 | // Initialize the logger system 13 | logger.Initialize() 14 | 15 | if err := app.NewRootCmd().Execute(); err != nil { 16 | logger.Errorf("%v", err) 17 | os.Exit(1) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /cmd/thv-operator/DESIGN.md: -------------------------------------------------------------------------------- 1 | # Design & Decisions 2 | 3 | This document aims to help fill in gaps of any decision that are made around the design of the ToolHive Operator. 4 | 5 | ## CRD Attribute vs `PodTemplateSpec` 6 | 7 | When building operators, the decision of when to use a `podTemplateSpec` and when to use a CRD attribute is always disputed. For the ToolHive Operator we have a defined rule of thumb. 8 | 9 | ### Use Dedicated CRD Attributes For: 10 | - **Business logic** that affects your operator's behavior 11 | - **Validation requirements** (ranges, formats, constraints) 12 | - **Cross-resource coordination** (affects Services, ConfigMaps, etc.) 13 | - **Operator decision making** (triggers different reconciliation paths) 14 | 15 | ```yaml 16 | spec: 17 | version: "13.4" # Affects operator logic 18 | replicas: 3 # Affects scaling behavior 19 | backupSchedule: "0 2 * * *" # Needs validation 20 | ``` 21 | 22 | ### Use PodTemplateSpec For: 23 | - **Infrastructure concerns** (node selection, resources, affinity) 24 | - **Sidecar containers** 25 | - **Standard Kubernetes pod configuration** 26 | - **Things a cluster admin would typically configure** 27 | 28 | ```yaml 29 | spec: 30 | podTemplate: 31 | spec: 32 | nodeSelector: 33 | disktype: ssd 34 | containers: 35 | - name: sidecar 36 | image: monitoring:latest 37 | ``` 38 | 39 | ## Quick Decision Test: 40 | 1. **"Does this affect my operator's reconciliation logic?"** -> Dedicated attribute 41 | 2. **"Is this standard Kubernetes pod configuration?"** -> PodTemplateSpec 42 | 3. **"Do I need to validate this beyond basic Kubernetes validation?"** -> Dedicated attribute 43 | 44 | This gives you a clean API for core functionality while maintaining flexibility for infrastructure concerns. -------------------------------------------------------------------------------- /cmd/thv-operator/api/v1alpha1/groupversion_info.go: -------------------------------------------------------------------------------- 1 | // Package v1alpha1 contains API Schema definitions for the toolhive v1alpha1 API group 2 | // +kubebuilder:object:generate=true 3 | // +groupName=toolhive.stacklok.dev 4 | package v1alpha1 5 | 6 | import ( 7 | "k8s.io/apimachinery/pkg/runtime/schema" 8 | "sigs.k8s.io/controller-runtime/pkg/scheme" 9 | ) 10 | 11 | var ( 12 | // GroupVersion is group version used to register these objects 13 | GroupVersion = schema.GroupVersion{Group: "toolhive.stacklok.dev", Version: "v1alpha1"} 14 | 15 | // SchemeBuilder is used to add go types to the GroupVersionKind scheme 16 | SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} 17 | 18 | // AddToScheme adds the types in this group-version to the given scheme. 19 | AddToScheme = SchemeBuilder.AddToScheme 20 | ) 21 | -------------------------------------------------------------------------------- /cmd/thv/app/commands.go: -------------------------------------------------------------------------------- 1 | // Package app provides the entry point for the toolhive command-line application. 2 | package app 3 | 4 | import ( 5 | "github.com/spf13/cobra" 6 | "github.com/spf13/viper" 7 | 8 | "github.com/stacklok/toolhive/pkg/logger" 9 | "github.com/stacklok/toolhive/pkg/updates" 10 | ) 11 | 12 | var rootCmd = &cobra.Command{ 13 | Use: "thv", 14 | DisableAutoGenTag: true, 15 | Short: "ToolHive (thv) is a lightweight, secure, and fast manager for MCP servers", 16 | Long: `ToolHive (thv) is a lightweight, secure, and fast manager for MCP (Model Context Protocol) servers. 17 | It is written in Go and has extensive test coverage—including input validation—to ensure reliability and security. 18 | 19 | Under the hood, ToolHive acts as a very thin client for the Docker/Podman Unix socket API. 20 | This design choice allows it to remain both efficient and lightweight while still providing powerful, 21 | container-based isolation for running MCP servers.`, 22 | Run: func(cmd *cobra.Command, _ []string) { 23 | // If no subcommand is provided, print help 24 | if err := cmd.Help(); err != nil { 25 | logger.Errorf("Error displaying help: %v", err) 26 | } 27 | }, 28 | PersistentPreRun: func(_ *cobra.Command, _ []string) { 29 | logger.Initialize() 30 | }, 31 | } 32 | 33 | // NewRootCmd creates a new root command for the ToolHive CLI. 34 | func NewRootCmd(enableUpdates bool) *cobra.Command { 35 | // Add persistent flags 36 | rootCmd.PersistentFlags().Bool("debug", false, "Enable debug mode") 37 | err := viper.BindPFlag("debug", rootCmd.PersistentFlags().Lookup("debug")) 38 | if err != nil { 39 | logger.Errorf("Error binding debug flag: %v", err) 40 | } 41 | 42 | // Add subcommands 43 | rootCmd.AddCommand(runCmd) 44 | rootCmd.AddCommand(listCmd) 45 | rootCmd.AddCommand(stopCmd) 46 | rootCmd.AddCommand(rmCmd) 47 | rootCmd.AddCommand(proxyCmd) 48 | rootCmd.AddCommand(restartCmd) 49 | rootCmd.AddCommand(serveCmd) 50 | rootCmd.AddCommand(newVersionCmd()) 51 | rootCmd.AddCommand(logsCommand()) 52 | rootCmd.AddCommand(newSecretCommand()) 53 | rootCmd.AddCommand(inspectorCommand()) 54 | 55 | // Silence printing the usage on error 56 | rootCmd.SilenceUsage = true 57 | 58 | if enableUpdates { 59 | checkForUpdates() 60 | } 61 | 62 | return rootCmd 63 | } 64 | 65 | // IsCompletionCommand checks if the command being run is the completion command 66 | func IsCompletionCommand(args []string) bool { 67 | if len(args) > 1 { 68 | return args[1] == "completion" 69 | } 70 | return false 71 | } 72 | 73 | func checkForUpdates() { 74 | versionClient := updates.NewVersionClient() 75 | updateChecker, err := updates.NewUpdateChecker(versionClient) 76 | // treat update-related errors as non-fatal 77 | if err != nil { 78 | logger.Warnf("unable to create update client: %s", err) 79 | return 80 | } 81 | 82 | err = updateChecker.CheckLatestVersion() 83 | if err != nil { 84 | logger.Warnf("could not check for updates: %s", err) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /cmd/thv/app/common.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/stacklok/toolhive/pkg/config" 9 | "github.com/stacklok/toolhive/pkg/secrets" 10 | ) 11 | 12 | // AddOIDCFlags adds OIDC validation flags to the provided command. 13 | func AddOIDCFlags(cmd *cobra.Command) { 14 | cmd.Flags().String("oidc-issuer", "", "OIDC issuer URL (e.g., https://accounts.google.com)") 15 | cmd.Flags().String("oidc-audience", "", "Expected audience for the token") 16 | cmd.Flags().String("oidc-jwks-url", "", "URL to fetch the JWKS from") 17 | cmd.Flags().String("oidc-client-id", "", "OIDC client ID") 18 | } 19 | 20 | // GetStringFlagOrEmpty tries to get the string value of the given flag. 21 | // If the flag doesn't exist or there's an error, it returns an empty string. 22 | func GetStringFlagOrEmpty(cmd *cobra.Command, flagName string) string { 23 | value, err := cmd.Flags().GetString(flagName) 24 | if err != nil { 25 | return "" 26 | } 27 | return value 28 | } 29 | 30 | // IsOIDCEnabled returns true if OIDC validation is enabled for the given command. 31 | // OIDC validation is considered enabled if either the OIDC issuer or the JWKS URL flag is provided. 32 | func IsOIDCEnabled(cmd *cobra.Command) bool { 33 | jwksURL := GetStringFlagOrEmpty(cmd, "oidc-jwks-url") 34 | issuer := GetStringFlagOrEmpty(cmd, "oidc-issuer") 35 | 36 | return jwksURL != "" || issuer != "" 37 | } 38 | 39 | // SetSecretsProvider sets the secrets provider type in the configuration. 40 | // It validates the input and updates the configuration. 41 | // Choices are `encrypted` and `1password`. 42 | func SetSecretsProvider(provider secrets.ProviderType) error { 43 | 44 | // Validate input 45 | if provider == "" { 46 | fmt.Println("validation error: provider cannot be empty") 47 | return fmt.Errorf("validation error: provider cannot be empty") 48 | } 49 | 50 | // Validate the provider type 51 | switch provider { 52 | case secrets.EncryptedType: 53 | case secrets.OnePasswordType: 54 | // Valid provider type 55 | default: 56 | return fmt.Errorf("invalid secrets provider type: %s (valid types: encrypted, 1password)", provider) 57 | } 58 | 59 | // Update the secrets provider type 60 | err := config.UpdateConfig(func(c *config.Config) { 61 | c.Secrets.ProviderType = string(provider) 62 | }) 63 | if err != nil { 64 | return fmt.Errorf("failed to update configuration: %w", err) 65 | } 66 | 67 | fmt.Printf("Secrets provider type updated to: %s\n", provider) 68 | return nil 69 | } 70 | -------------------------------------------------------------------------------- /cmd/thv/app/constants.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | // Output format constants 4 | const ( 5 | // FormatJSON is the JSON output format 6 | FormatJSON = "json" 7 | // FormatText is the text output format 8 | FormatText = "text" 9 | ) 10 | -------------------------------------------------------------------------------- /cmd/thv/app/restart.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/spf13/cobra" 8 | 9 | "github.com/stacklok/toolhive/pkg/labels" 10 | "github.com/stacklok/toolhive/pkg/lifecycle" 11 | ) 12 | 13 | var ( 14 | restartAll bool 15 | ) 16 | 17 | var restartCmd = &cobra.Command{ 18 | Use: "restart [container-name]", 19 | Short: "Restart a tooling server", 20 | Long: `Restart a running tooling server managed by ToolHive. If the server is not running, it will be started.`, 21 | Args: cobra.RangeArgs(0, 1), 22 | RunE: restartCmdFunc, 23 | } 24 | 25 | func init() { 26 | restartCmd.Flags().BoolVarP(&restartAll, "all", "a", false, "Restart all MCP servers") 27 | } 28 | 29 | func restartCmdFunc(cmd *cobra.Command, args []string) error { 30 | ctx := cmd.Context() 31 | 32 | // Validate arguments 33 | if restartAll && len(args) > 0 { 34 | return fmt.Errorf("cannot specify both --all flag and container name") 35 | } 36 | if !restartAll && len(args) == 0 { 37 | return fmt.Errorf("must specify either --all flag or container name") 38 | } 39 | 40 | // Create lifecycle manager. 41 | manager, err := lifecycle.NewManager(ctx) 42 | if err != nil { 43 | return fmt.Errorf("failed to create lifecycle manager: %v", err) 44 | } 45 | 46 | if restartAll { 47 | return restartAllContainers(ctx, manager) 48 | } 49 | 50 | // Restart single container 51 | containerName := args[0] 52 | err = manager.RestartContainer(ctx, containerName) 53 | if err != nil { 54 | return err 55 | } 56 | 57 | fmt.Printf("Container %s restarted successfully\n", containerName) 58 | return nil 59 | } 60 | 61 | func restartAllContainers(ctx context.Context, manager lifecycle.Manager) error { 62 | // Get all containers (including stopped ones since restart can start stopped containers) 63 | containers, err := manager.ListContainers(ctx, true) 64 | if err != nil { 65 | return fmt.Errorf("failed to list containers: %v", err) 66 | } 67 | 68 | if len(containers) == 0 { 69 | fmt.Println("No MCP servers found to restart") 70 | return nil 71 | } 72 | 73 | var restartedCount int 74 | var failedCount int 75 | var errors []string 76 | 77 | fmt.Printf("Restarting %d MCP server(s)...\n", len(containers)) 78 | 79 | for _, container := range containers { 80 | // Get container name from labels 81 | containerName := labels.GetContainerName(container.Labels) 82 | if containerName == "" { 83 | containerName = container.Name // Fallback to container name 84 | } 85 | 86 | fmt.Printf("Restarting %s...", containerName) 87 | err := manager.RestartContainer(ctx, containerName) 88 | if err != nil { 89 | fmt.Printf(" failed: %v\n", err) 90 | failedCount++ 91 | errors = append(errors, fmt.Sprintf("%s: %v", containerName, err)) 92 | } else { 93 | fmt.Printf(" success\n") 94 | restartedCount++ 95 | } 96 | } 97 | 98 | // Print summary 99 | fmt.Printf("\nRestart summary: %d succeeded, %d failed\n", restartedCount, failedCount) 100 | 101 | if failedCount > 0 { 102 | fmt.Println("\nFailed restarts:") 103 | for _, errMsg := range errors { 104 | fmt.Printf(" - %s\n", errMsg) 105 | } 106 | return fmt.Errorf("%d container(s) failed to restart", failedCount) 107 | } 108 | 109 | return nil 110 | } 111 | -------------------------------------------------------------------------------- /cmd/thv/app/rm.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | 8 | "github.com/stacklok/toolhive/pkg/lifecycle" 9 | ) 10 | 11 | var rmCmd = &cobra.Command{ 12 | Use: "rm [container-name]", 13 | Short: "Remove an MCP server", 14 | Long: `Remove an MCP server managed by ToolHive.`, 15 | Args: cobra.ExactArgs(1), 16 | RunE: rmCmdFunc, 17 | } 18 | 19 | var ( 20 | rmForce bool 21 | ) 22 | 23 | func init() { 24 | rmCmd.Flags().BoolVarP(&rmForce, "force", "f", false, "Force removal of a running container") 25 | } 26 | 27 | //nolint:gocyclo // This function is complex but manageable 28 | func rmCmdFunc(cmd *cobra.Command, args []string) error { 29 | ctx := cmd.Context() 30 | // Get container name 31 | containerName := args[0] 32 | 33 | // Create container manager. 34 | manager, err := lifecycle.NewManager(ctx) 35 | if err != nil { 36 | return fmt.Errorf("failed to create container manager: %v", err) 37 | } 38 | 39 | // Delete container. 40 | if err := manager.DeleteContainer(ctx, containerName, rmForce); err != nil { 41 | return fmt.Errorf("failed to delete container: %v", err) 42 | } 43 | 44 | fmt.Printf("Container %s removed successfully\n", containerName) 45 | return nil 46 | } 47 | -------------------------------------------------------------------------------- /cmd/thv/app/run_common.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/stacklok/toolhive/pkg/authz" 9 | "github.com/stacklok/toolhive/pkg/lifecycle" 10 | "github.com/stacklok/toolhive/pkg/runner" 11 | ) 12 | 13 | // RunMCPServer runs an MCP server with the specified configuration. 14 | func RunMCPServer(ctx context.Context, config *runner.RunConfig, foreground bool) error { 15 | manager, err := lifecycle.NewManager(ctx) 16 | if err != nil { 17 | return fmt.Errorf("failed to create lifecycle manager: %v", err) 18 | } 19 | 20 | // If we are running the container in the foreground - call the RunContainer method directly. 21 | if foreground { 22 | return manager.RunContainer(ctx, config) 23 | } 24 | 25 | return manager.RunContainerDetached(config) 26 | } 27 | 28 | // configureRunConfig configures a RunConfig with transport, ports, permissions, etc. 29 | func configureRunConfig( 30 | config *runner.RunConfig, 31 | transport string, 32 | port int, 33 | targetPort int, 34 | envVarStrings []string, 35 | ) error { 36 | var err error 37 | 38 | // Set transport 39 | if _, err = config.WithTransport(transport); err != nil { 40 | return err 41 | } 42 | 43 | // Configure ports and target host 44 | if _, err = config.WithPorts(port, targetPort); err != nil { 45 | return err 46 | } 47 | 48 | // Set permission profile (mandatory) 49 | if _, err = config.ParsePermissionProfile(); err != nil { 50 | return err 51 | } 52 | 53 | // Process volume mounts 54 | if err = config.ProcessVolumeMounts(); err != nil { 55 | return err 56 | } 57 | 58 | // Parse and set environment variables 59 | if _, err = config.WithEnvironmentVariables(envVarStrings); err != nil { 60 | return err 61 | } 62 | 63 | // Generate container name if not already set 64 | config.WithContainerName() 65 | 66 | // Add standard labels 67 | config.WithStandardLabels() 68 | 69 | // Add authorization configuration if provided 70 | if config.AuthzConfigPath != "" { 71 | authzConfig, err := authz.LoadConfig(config.AuthzConfigPath) 72 | if err != nil { 73 | return fmt.Errorf("failed to load authorization configuration: %v", err) 74 | } 75 | config.WithAuthz(authzConfig) 76 | } 77 | 78 | return nil 79 | } 80 | 81 | func findEnvironmentVariableFromSecrets(secs []string, envVarName string) bool { 82 | for _, secret := range secs { 83 | if isSecretReferenceEnvVar(secret, envVarName) { 84 | return true 85 | } 86 | } 87 | 88 | return false 89 | } 90 | 91 | func isSecretReferenceEnvVar(secret, envVarName string) bool { 92 | parts := strings.Split(secret, ",") 93 | if len(parts) != 2 { 94 | return false 95 | } 96 | 97 | targetSplit := strings.Split(parts[1], "=") 98 | if len(targetSplit) != 2 { 99 | return false 100 | } 101 | 102 | if targetSplit[1] == envVarName { 103 | return true 104 | } 105 | 106 | return false 107 | } 108 | -------------------------------------------------------------------------------- /cmd/thv/app/search.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "sort" 8 | "text/tabwriter" 9 | 10 | "github.com/spf13/cobra" 11 | 12 | "github.com/stacklok/toolhive/pkg/registry" 13 | ) 14 | 15 | var searchCmd = &cobra.Command{ 16 | Use: "search [query]", 17 | Short: "Search for MCP servers", 18 | Long: `Search for MCP servers in the registry by name, description, or tags.`, 19 | Args: cobra.ExactArgs(1), 20 | RunE: searchCmdFunc, 21 | } 22 | 23 | var ( 24 | searchFormat string 25 | ) 26 | 27 | func init() { 28 | // Add search command to root command 29 | rootCmd.AddCommand(searchCmd) 30 | 31 | // Add flags for search command 32 | searchCmd.Flags().StringVar(&searchFormat, "format", FormatText, "Output format (json or text)") 33 | } 34 | 35 | func searchCmdFunc(_ *cobra.Command, args []string) error { 36 | // Search for servers 37 | query := args[0] 38 | servers, err := registry.SearchServers(query) 39 | if err != nil { 40 | return fmt.Errorf("failed to search servers: %v", err) 41 | } 42 | 43 | if len(servers) == 0 { 44 | fmt.Printf("No servers found matching query: %s\n", query) 45 | return nil 46 | } 47 | 48 | // Sort servers by name 49 | sort.Slice(servers, func(i, j int) bool { 50 | return servers[i].Name < servers[j].Name 51 | }) 52 | 53 | // Output based on format 54 | switch searchFormat { 55 | case FormatJSON: 56 | return printJSONSearchResults(servers) 57 | default: 58 | fmt.Printf("Found %d servers matching query: %s\n", len(servers), query) 59 | printTextSearchResults(servers) 60 | return nil 61 | } 62 | } 63 | 64 | // printJSONSearchResults prints servers in JSON format 65 | func printJSONSearchResults(servers []*registry.Server) error { 66 | // Marshal to JSON 67 | jsonData, err := json.MarshalIndent(servers, "", " ") 68 | if err != nil { 69 | return fmt.Errorf("failed to marshal JSON: %v", err) 70 | } 71 | 72 | // Print JSON 73 | fmt.Println(string(jsonData)) 74 | return nil 75 | } 76 | 77 | // printTextSearchResults prints servers in text format 78 | func printTextSearchResults(servers []*registry.Server) { 79 | // Create a tabwriter for pretty output 80 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) 81 | fmt.Fprintln(w, "NAME\tDESCRIPTION\tTRANSPORT\tSTARS\tPULLS") 82 | 83 | // Print server information 84 | for _, server := range servers { 85 | // Print server information 86 | fmt.Fprintf(w, "%s\t%s\t%s\t%d\t%d\n", 87 | server.Name, 88 | truncateSearchString(server.Description, 60), 89 | server.Transport, 90 | server.Metadata.Stars, 91 | server.Metadata.Pulls, 92 | ) 93 | } 94 | 95 | // Flush the tabwriter 96 | if err := w.Flush(); err != nil { 97 | fmt.Fprintf(os.Stderr, "Warning: Failed to flush tabwriter: %v\n", err) 98 | } 99 | } 100 | 101 | // truncateSearchString truncates a string to the specified length and adds "..." if truncated 102 | func truncateSearchString(s string, maxLen int) string { 103 | return truncateString(s, maxLen) 104 | } 105 | -------------------------------------------------------------------------------- /cmd/thv/app/server.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/signal" 7 | 8 | "github.com/spf13/cobra" 9 | 10 | s "github.com/stacklok/toolhive/pkg/api" 11 | "github.com/stacklok/toolhive/pkg/auth" 12 | ) 13 | 14 | var ( 15 | host string 16 | port int 17 | enableDocs bool 18 | socketPath string 19 | ) 20 | 21 | var serveCmd = &cobra.Command{ 22 | Use: "serve", 23 | Short: "Start the ToolHive API server", 24 | Long: `Starts the ToolHive API server and listen for HTTP requests.`, 25 | RunE: func(cmd *cobra.Command, _ []string) error { 26 | // Ensure server is shutdown gracefully on Ctrl+C. 27 | ctx, cancel := signal.NotifyContext(cmd.Context(), os.Interrupt) 28 | defer cancel() 29 | 30 | // Get debug mode flag 31 | debugMode, _ := cmd.Flags().GetBool("debug") 32 | 33 | // If socket path is provided, use it; otherwise use host:port 34 | address := fmt.Sprintf("%s:%d", host, port) 35 | isUnixSocket := false 36 | if socketPath != "" { 37 | address = socketPath 38 | isUnixSocket = true 39 | } 40 | 41 | // Get OIDC configuration if enabled 42 | var oidcConfig *auth.JWTValidatorConfig 43 | if IsOIDCEnabled(cmd) { 44 | // Get OIDC flag values 45 | issuer := GetStringFlagOrEmpty(cmd, "oidc-issuer") 46 | audience := GetStringFlagOrEmpty(cmd, "oidc-audience") 47 | jwksURL := GetStringFlagOrEmpty(cmd, "oidc-jwks-url") 48 | clientID := GetStringFlagOrEmpty(cmd, "oidc-client-id") 49 | 50 | oidcConfig = &auth.JWTValidatorConfig{ 51 | Issuer: issuer, 52 | Audience: audience, 53 | JWKSURL: jwksURL, 54 | ClientID: clientID, 55 | } 56 | } 57 | 58 | return s.Serve(ctx, address, isUnixSocket, debugMode, enableDocs, oidcConfig) 59 | }, 60 | } 61 | 62 | func init() { 63 | serveCmd.Flags().StringVar(&host, "host", "127.0.0.1", "Host address to bind the server to") 64 | serveCmd.Flags().IntVar(&port, "port", 8080, "Port to bind the server to") 65 | serveCmd.Flags().BoolVar(&enableDocs, "openapi", false, 66 | "Enable OpenAPI documentation endpoints (/api/openapi.json and /api/doc)") 67 | serveCmd.Flags().StringVar(&socketPath, "socket", "", "UNIX socket path to bind the "+ 68 | "server to (overrides host and port if provided)") 69 | 70 | // Add OIDC validation flags 71 | AddOIDCFlags(serveCmd) 72 | } 73 | -------------------------------------------------------------------------------- /cmd/thv/app/stop.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/spf13/cobra" 8 | 9 | "github.com/stacklok/toolhive/pkg/lifecycle" 10 | ) 11 | 12 | var stopCmd = &cobra.Command{ 13 | Use: "stop [container-name]", 14 | Short: "Stop an MCP server", 15 | Long: `Stop a running MCP server managed by ToolHive.`, 16 | Args: cobra.ExactArgs(1), 17 | RunE: stopCmdFunc, 18 | } 19 | 20 | var ( 21 | stopTimeout int 22 | ) 23 | 24 | func init() { 25 | stopCmd.Flags().IntVar(&stopTimeout, "timeout", 30, "Timeout in seconds before forcibly stopping the container") 26 | } 27 | 28 | func stopCmdFunc(cmd *cobra.Command, args []string) error { 29 | ctx := cmd.Context() 30 | // Get container name 31 | containerName := args[0] 32 | 33 | manager, err := lifecycle.NewManager(ctx) 34 | if err != nil { 35 | return fmt.Errorf("failed to create container manager: %v", err) 36 | } 37 | 38 | err = manager.StopContainer(ctx, containerName) 39 | if err != nil { 40 | // If the container is not found, treat as a non-fatal error. 41 | if errors.Is(err, lifecycle.ErrContainerNotFound) { 42 | fmt.Printf("Container %s is not running\n", containerName) 43 | } else { 44 | return fmt.Errorf("failed to delete container: %v", err) 45 | } 46 | } else { 47 | fmt.Printf("Container %s stopped successfully\n", containerName) 48 | } 49 | 50 | return nil 51 | } 52 | -------------------------------------------------------------------------------- /cmd/thv/app/version.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/spf13/cobra" 8 | 9 | "github.com/stacklok/toolhive/pkg/versions" 10 | ) 11 | 12 | // newVersionCmd creates a new version command 13 | func newVersionCmd() *cobra.Command { 14 | var outputFormat string 15 | var jsonOutput bool 16 | 17 | cmd := &cobra.Command{ 18 | Use: "version", 19 | Short: "Show the version of ToolHive", 20 | Long: `Display detailed version information about ToolHive, including version number, git commit, build date, and Go version.`, 21 | Run: func(_ *cobra.Command, _ []string) { 22 | info := versions.GetVersionInfo() 23 | 24 | if outputFormat == FormatJSON { 25 | printJSONVersionInfo(info) 26 | } else { 27 | printVersionInfo(info) 28 | } 29 | }, 30 | } 31 | 32 | // Keep the --json flag for backward compatibility 33 | cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output version information as JSON (deprecated, use --format instead)") 34 | // Add the --format flag for consistency with other commands 35 | cmd.Flags().StringVar(&outputFormat, "format", FormatText, "Output format (json or text)") 36 | 37 | // If --json is set, override the format 38 | cmd.PreRun = func(_ *cobra.Command, _ []string) { 39 | if jsonOutput { 40 | outputFormat = "json" 41 | } 42 | } 43 | 44 | return cmd 45 | } 46 | 47 | // printVersionInfo prints the version information 48 | func printVersionInfo(info versions.VersionInfo) { 49 | fmt.Printf("ToolHive %s\n", info.Version) 50 | fmt.Printf("Commit: %s\n", info.Commit) 51 | fmt.Printf("Built: %s\n", info.BuildDate) 52 | fmt.Printf("Go version: %s\n", info.GoVersion) 53 | fmt.Printf("Platform: %s\n", info.Platform) 54 | } 55 | 56 | // printJSONVersionInfo prints the version information as JSON 57 | func printJSONVersionInfo(info versions.VersionInfo) { 58 | // Use encoding/json for proper JSON formatting 59 | jsonData, err := json.MarshalIndent(info, "", " ") 60 | if err != nil { 61 | fmt.Printf("Error marshaling JSON: %v\n", err) 62 | return 63 | } 64 | 65 | fmt.Printf("%s", jsonData) 66 | } 67 | -------------------------------------------------------------------------------- /cmd/thv/main.go: -------------------------------------------------------------------------------- 1 | // Package main is the entry point for the ToolHive CLI. 2 | package main 3 | 4 | import ( 5 | "os" 6 | 7 | "github.com/stacklok/toolhive/cmd/thv/app" 8 | "github.com/stacklok/toolhive/pkg/container" 9 | "github.com/stacklok/toolhive/pkg/logger" 10 | ) 11 | 12 | func main() { 13 | // Initialize the logger 14 | logger.Initialize() 15 | 16 | // Skip update check for completion command or if we are running in kubernetes 17 | if err := app.NewRootCmd(!app.IsCompletionCommand(os.Args) && !container.IsKubernetesRuntime()).Execute(); err != nil { 18 | os.Exit(1) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /cr.yaml: -------------------------------------------------------------------------------- 1 | generate-release-notes: true 2 | charts_dir: deploy/charts -------------------------------------------------------------------------------- /ct-install.yaml: -------------------------------------------------------------------------------- 1 | chart-dirs: 2 | - deploy/charts 3 | validate-maintainers: false 4 | remote: origin 5 | target-branch: main 6 | -------------------------------------------------------------------------------- /ct.yaml: -------------------------------------------------------------------------------- 1 | chart-dirs: 2 | - deploy/charts 3 | validate-maintainers: false 4 | remote: origin 5 | target-branch: main 6 | -------------------------------------------------------------------------------- /dco.md: -------------------------------------------------------------------------------- 1 | # Developer Certificate of Origin (DCO) 2 | In order to contribute to the project, you must agree to the Developer Certificate of Origin. A [Developer Certificate of Origin (DCO)](https://developercertificate.org/) 3 | is an affirmation that the developer contributing the proposed changes has the necessary rights to submit those changes. 4 | A DCO provides some additional legal protections while being relatively easy to do. 5 | 6 | The entire DCO can be summarized as: 7 | - Certify that the submitted code can be submitted under the open source license of the project (e.g. Apache 2.0) 8 | - I understand that what I am contributing is public and will be redistributed indefinitely 9 | 10 | 11 | ## How to Use Developer Certificate of Origin 12 | In order to contribute to the project, you must agree to the Developer Certificate of Origin. To confirm that you agree, your commit message must include a Signed-off-by trailer at the bottom of the commit message. 13 | 14 | For example, it might look like the following: 15 | ```bash 16 | A commit message 17 | 18 | Closes gh-345 19 | 20 | Signed-off-by: jane marmot 21 | ``` 22 | 23 | The Signed-off-by [trailer](https://git-scm.com/docs/git-interpret-trailers) can be added automatically by using the [-s or –signoff command line option](https://git-scm.com/docs/git-commit/2.13.7#Documentation/git-commit.txt--s) when specifying your commit message: 24 | ```bash 25 | git commit -s -m 26 | ``` 27 | If you have chosen the [Keep my email address private](https://docs.github.com/en/account-and-profile/setting-up-and-managing-your-personal-account-on-github/managing-email-preferences/setting-your-commit-email-address#about-commit-email-addresses) option within GitHub, the Signed-off-by trailer might look something like: 28 | ```bash 29 | A commit message 30 | 31 | Closes gh-345 32 | 33 | Signed-off-by: jane marmot <462403+jmarmot@users.noreply.github.com> 34 | ``` 35 | -------------------------------------------------------------------------------- /deploy/charts/_templates.gotmpl: -------------------------------------------------------------------------------- 1 | {{ define "chart.valuesTable" }} 2 | | Key | Type | Default | Description | 3 | |-----|-------------|------|---------| 4 | {{- range .Values }} 5 | | {{ .Key }} | {{ .Type }} | {{ if .Default }}{{ .Default }}{{ else }}{{ .AutoDefault }}{{ end }} | {{ if .Description }}{{ .Description }}{{ else }}{{ .AutoDescription }}{{ end }} | 6 | {{- end }} 7 | {{ end }} 8 | -------------------------------------------------------------------------------- /deploy/charts/operator-crds/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /deploy/charts/operator-crds/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: toolhive-operator-crds 3 | description: A Helm chart for installing the ToolHive Operator CRDs into Kubernetes. 4 | type: application 5 | version: 0.0.6 6 | appVersion: "0.0.1" 7 | -------------------------------------------------------------------------------- /deploy/charts/operator-crds/README.md: -------------------------------------------------------------------------------- 1 | 2 | # ToolHive Operator CRDs Helm Chart 3 | 4 | ![Version: 0.0.6](https://img.shields.io/badge/Version-0.0.6-informational?style=flat-square) 5 | ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) 6 | 7 | A Helm chart for installing the ToolHive Operator CRDs into Kubernetes. 8 | 9 | --- 10 | 11 | ToolHive Operator CRDs 12 | 13 | ## TL;DR 14 | 15 | ```console 16 | helm upgrade -i toolhive-operator-crds oci://ghcr.io/stacklok/toolhive/toolhive-operator-crds 17 | ``` 18 | 19 | ## Prerequisites 20 | 21 | - Kubernetes 1.25+ 22 | - Helm 3.10+ minimum, 3.14+ recommended 23 | 24 | ## Usage 25 | 26 | ### Installing from the Chart 27 | 28 | Install one of the available versions: 29 | 30 | ```shell 31 | helm upgrade -i oci://ghcr.io/stacklok/toolhive/toolhive-operator-crds --version= 32 | ``` 33 | 34 | > **Tip**: List all releases using `helm list` 35 | 36 | ### Uninstalling the Chart 37 | 38 | To uninstall/delete the `toolhive-operator-crds` deployment: 39 | 40 | ```console 41 | helm uninstall 42 | ``` 43 | 44 | -------------------------------------------------------------------------------- /deploy/charts/operator-crds/README.md.gotmpl: -------------------------------------------------------------------------------- 1 | # ToolHive Operator CRDs Helm Chart 2 | 3 | {{ template "chart.deprecationWarning" . }} 4 | 5 | {{ template "chart.versionBadge" . }} 6 | {{ template "chart.typeBadge" . }} 7 | 8 | {{ template "chart.description" . }} 9 | 10 | {{ template "chart.homepageLine" . }} 11 | 12 | {{ template "chart.maintainersSection" . }} 13 | 14 | {{ template "chart.sourcesSection" . }} 15 | 16 | --- 17 | 18 | ToolHive Operator CRDs 19 | 20 | ## TL;DR 21 | 22 | ```console 23 | helm upgrade -i toolhive-operator-crds oci://ghcr.io/stacklok/toolhive/toolhive-operator-crds 24 | ``` 25 | 26 | ## Prerequisites 27 | 28 | - Kubernetes 1.25+ 29 | - Helm 3.10+ minimum, 3.14+ recommended 30 | 31 | ## Usage 32 | 33 | ### Installing from the Chart 34 | 35 | Install one of the available versions: 36 | 37 | ```shell 38 | helm upgrade -i oci://ghcr.io/stacklok/toolhive/toolhive-operator-crds --version= 39 | ``` 40 | 41 | > **Tip**: List all releases using `helm list` 42 | 43 | ### Uninstalling the Chart 44 | 45 | To uninstall/delete the `toolhive-operator-crds` deployment: 46 | 47 | ```console 48 | helm uninstall 49 | ``` 50 | 51 | {{ template "chart.requirementsSection" . }} 52 | 53 | {{ template "chart.valuesSection" . }} 54 | 55 | -------------------------------------------------------------------------------- /deploy/charts/operator-crds/ci/default-values.yaml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stacklok/toolhive/9f52ce7ed5a2639ba78ebf2db75cdb6ba04b402a/deploy/charts/operator-crds/ci/default-values.yaml -------------------------------------------------------------------------------- /deploy/charts/operator-crds/values.yaml: -------------------------------------------------------------------------------- 1 | # empty values file as we do not need any values for the CRDs at the moment 2 | -------------------------------------------------------------------------------- /deploy/charts/operator/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /deploy/charts/operator/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: toolhive-operator 3 | description: A Helm chart for deploying the ToolHive Operator into Kubernetes. 4 | type: application 5 | version: 0.0.9 6 | appVersion: "0.0.39" 7 | -------------------------------------------------------------------------------- /deploy/charts/operator/README.md.gotmpl: -------------------------------------------------------------------------------- 1 | # ToolHive Operator Helm Chart 2 | 3 | {{ template "chart.deprecationWarning" . }} 4 | 5 | {{ template "chart.versionBadge" . }} 6 | {{ template "chart.typeBadge" . }} 7 | 8 | {{ template "chart.description" . }} 9 | 10 | {{ template "chart.homepageLine" . }} 11 | 12 | {{ template "chart.maintainersSection" . }} 13 | 14 | {{ template "chart.sourcesSection" . }} 15 | 16 | --- 17 | 18 | ## TL;DR 19 | 20 | ```console 21 | helm upgrade -i toolhive-operator oci://ghcr.io/stacklok/toolhive/toolhive-operator -n toolhive-system --create-namespace 22 | ``` 23 | 24 | ## Prerequisites 25 | 26 | - Kubernetes 1.25+ 27 | - Helm 3.10+ minimum, 3.14+ recommended 28 | 29 | ## Usage 30 | 31 | ### Installing from the Chart 32 | 33 | Install one of the available versions: 34 | 35 | ```shell 36 | helm upgrade -i oci://ghcr.io/stacklok/toolhive/toolhive-operator --version= -n toolhive-system --create-namespace 37 | ``` 38 | 39 | > **Tip**: List all releases using `helm list` 40 | 41 | ### Uninstalling the Chart 42 | 43 | To uninstall/delete the `toolhive-operator` deployment: 44 | 45 | ```console 46 | helm uninstall 47 | ``` 48 | 49 | The command removes all the Kubernetes components associated with the chart and deletes the release. You will have to delete the namespace manually if you used Helm to create it. 50 | 51 | {{ template "chart.requirementsSection" . }} 52 | 53 | {{ template "chart.valuesSection" . }} 54 | 55 | -------------------------------------------------------------------------------- /deploy/charts/operator/ci/autoScalingEnabled-values.yaml: -------------------------------------------------------------------------------- 1 | operator: 2 | autoscaling: 3 | enabled: true 4 | minReplicas: 5 5 | maxReplicas: 10 6 | targetCPUUtilizationPercentage: 80 7 | targetMemoryUtilizationPercentage: 80 8 | -------------------------------------------------------------------------------- /deploy/charts/operator/ci/default-values.yaml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stacklok/toolhive/9f52ce7ed5a2639ba78ebf2db75cdb6ba04b402a/deploy/charts/operator/ci/default-values.yaml -------------------------------------------------------------------------------- /deploy/charts/operator/ci/extraEnvVars-values.yaml: -------------------------------------------------------------------------------- 1 | env: 2 | - name: TEST_ENV_VAR 3 | value: "my-test-env-var" 4 | - name: ANOTHER_TEST_ENV_VAR 5 | value: "another-test-env-var" 6 | -------------------------------------------------------------------------------- /deploy/charts/operator/ci/extraPodAndContainerSecurityContext-values.yaml: -------------------------------------------------------------------------------- 1 | operator: 2 | podSecurityContext: 3 | runAsNonRoot: true 4 | 5 | containerSecurityContext: 6 | runAsUser: 2000 7 | capabilities: 8 | drop: 9 | - ALL 10 | -------------------------------------------------------------------------------- /deploy/charts/operator/ci/extraPodAnnotationsAndLabels-values.yaml: -------------------------------------------------------------------------------- 1 | operator: 2 | podAnnotations: 3 | testFoo: testFooValue 4 | podLabels: 5 | testBar: testBarValue 6 | -------------------------------------------------------------------------------- /deploy/charts/operator/ci/extraVolumes-values.yaml: -------------------------------------------------------------------------------- 1 | operator: 2 | extraVolumeMounts: 3 | - name: test 4 | mountPath: /somepath 5 | readOnly: true 6 | extraVolumes: 7 | - name: test 8 | emptyDir: 9 | sizeLimit: 5Mi 10 | -------------------------------------------------------------------------------- /deploy/charts/operator/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "operator.name" -}} 5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "operator.fullname" -}} 14 | {{- if .Values.fullnameOverride }} 15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 16 | {{- else }} 17 | {{- $name := default .Chart.Name .Values.nameOverride }} 18 | {{- if contains $name .Release.Name }} 19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 20 | {{- else }} 21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 22 | {{- end }} 23 | {{- end }} 24 | {{- end }} 25 | 26 | {{/* 27 | Create chart name and version as used by the chart label. 28 | */}} 29 | {{- define "operator.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Common labels 35 | */}} 36 | {{- define "operator.labels" -}} 37 | helm.sh/chart: {{ include "operator.chart" . }} 38 | {{ include "operator.selectorLabels" . }} 39 | {{- if .Chart.AppVersion }} 40 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 41 | {{- end }} 42 | app.kubernetes.io/managed-by: {{ .Release.Service }} 43 | {{- end }} 44 | 45 | {{/* 46 | Selector labels 47 | */}} 48 | {{- define "operator.selectorLabels" -}} 49 | app.kubernetes.io/name: {{ include "operator.name" . }} 50 | app.kubernetes.io/instance: {{ .Release.Name }} 51 | app.kubernetes.io/part-of: {{ include "operator.name" . }} 52 | {{- end }} 53 | 54 | {{/* 55 | Create the name of the service account to use 56 | */}} 57 | {{- define "operator.serviceAccountName" -}} 58 | {{- if .Values.operator.serviceAccount.create }} 59 | {{- default (include "operator.fullname" .) .Values.operator.serviceAccount.name }} 60 | {{- else }} 61 | {{- default "default" .Values.operator.serviceAccount.name }} 62 | {{- end }} 63 | {{- end }} 64 | 65 | {{/* 66 | Common labels for the toolhive resources 67 | */}} 68 | {{- define "toolhive.labels" -}} 69 | app: toolhive 70 | app.kubernetes.io/name: toolhive 71 | {{- end }} -------------------------------------------------------------------------------- /deploy/charts/operator/templates/clusterrole/role.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | name: toolhive-operator-manager-role 6 | rules: 7 | - apiGroups: 8 | - "" 9 | resources: 10 | - configmaps 11 | - services 12 | verbs: 13 | - create 14 | - delete 15 | - get 16 | - list 17 | - patch 18 | - update 19 | - watch 20 | - apiGroups: 21 | - "" 22 | resources: 23 | - events 24 | verbs: 25 | - create 26 | - patch 27 | - apiGroups: 28 | - "" 29 | resources: 30 | - pods 31 | - secrets 32 | verbs: 33 | - get 34 | - list 35 | - watch 36 | - apiGroups: 37 | - apps 38 | resources: 39 | - deployments 40 | verbs: 41 | - create 42 | - delete 43 | - get 44 | - list 45 | - patch 46 | - update 47 | - watch 48 | - apiGroups: 49 | - toolhive.stacklok.dev 50 | resources: 51 | - mcpservers 52 | verbs: 53 | - create 54 | - delete 55 | - get 56 | - list 57 | - patch 58 | - update 59 | - watch 60 | - apiGroups: 61 | - toolhive.stacklok.dev 62 | resources: 63 | - mcpservers/finalizers 64 | verbs: 65 | - update 66 | - apiGroups: 67 | - toolhive.stacklok.dev 68 | resources: 69 | - mcpservers/status 70 | verbs: 71 | - get 72 | - patch 73 | - update 74 | -------------------------------------------------------------------------------- /deploy/charts/operator/templates/clusterrole/rolebinding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRoleBinding 3 | metadata: 4 | name: toolhive-operator-manager-rolebinding 5 | labels: 6 | app.kubernetes.io/name: toolhive-operator 7 | app.kubernetes.io/part-of: toolhive-operator 8 | roleRef: 9 | apiGroup: rbac.authorization.k8s.io 10 | kind: ClusterRole 11 | name: toolhive-operator-manager-role 12 | subjects: 13 | - kind: ServiceAccount 14 | name: toolhive-operator 15 | namespace: toolhive-system -------------------------------------------------------------------------------- /deploy/charts/operator/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: {{ include "operator.fullname" . }} 5 | namespace: {{ .Release.Namespace }} 6 | labels: 7 | {{- include "operator.labels" . | nindent 4 }} 8 | spec: 9 | {{- if not .Values.operator.autoscaling.enabled }} 10 | replicas: {{ .Values.operator.replicaCount }} 11 | {{- end }} 12 | selector: 13 | matchLabels: 14 | {{- include "operator.selectorLabels" . | nindent 6 }} 15 | template: 16 | metadata: 17 | {{- with .Values.operator.podAnnotations }} 18 | annotations: 19 | {{- toYaml . | nindent 8 }} 20 | {{- end }} 21 | labels: 22 | {{- include "operator.labels" . | nindent 8 }} 23 | {{- with .Values.operator.podLabels }} 24 | {{- toYaml . | nindent 8 }} 25 | {{- end }} 26 | spec: 27 | {{- with .Values.operator.imagePullSecrets }} 28 | imagePullSecrets: 29 | {{- toYaml . | nindent 8 }} 30 | {{- end }} 31 | serviceAccountName: {{ include "operator.serviceAccountName" . }} 32 | securityContext: 33 | {{- toYaml .Values.operator.podSecurityContext | nindent 8 }} 34 | terminationGracePeriodSeconds: 10 35 | containers: 36 | - name: manager 37 | securityContext: 38 | {{- toYaml .Values.operator.containerSecurityContext | nindent 12 }} 39 | image: "{{ .Values.operator.image }}" 40 | imagePullPolicy: {{ .Values.operator.imagePullPolicy }} 41 | args: 42 | - --leader-elect 43 | ports: 44 | {{- toYaml .Values.operator.ports | nindent 12 }} 45 | env: 46 | - name: TOOLHIVE_RUNNER_IMAGE 47 | value: "{{ .Values.operator.toolhiveRunnerImage }}" 48 | - name: TOOLHIVE_PROXY_HOST 49 | value: "{{ .Values.operator.proxyHost }}" 50 | {{- if .Values.operator.env }} 51 | {{- toYaml .Values.operator.env | nindent 12 }} 52 | {{- end }} 53 | livenessProbe: 54 | {{- toYaml .Values.operator.livenessProbe | nindent 12 }} 55 | readinessProbe: 56 | {{- toYaml .Values.operator.readinessProbe | nindent 12 }} 57 | resources: 58 | {{- toYaml .Values.operator.resources | nindent 12 }} 59 | {{- with .Values.operator.volumeMounts }} 60 | volumeMounts: 61 | {{- toYaml . | nindent 12 }} 62 | {{- end }} 63 | {{- with .Values.operator.volumes }} 64 | volumes: 65 | {{- toYaml . | nindent 8 }} 66 | {{- end }} 67 | {{- with .Values.operator.nodeSelector }} 68 | nodeSelector: 69 | {{- toYaml . | nindent 8 }} 70 | {{- end }} 71 | {{- with .Values.operator.affinity }} 72 | affinity: 73 | {{- toYaml . | nindent 8 }} 74 | {{- end }} 75 | {{- with .Values.operator.tolerations }} 76 | tolerations: 77 | {{- toYaml . | nindent 8 }} 78 | {{- end }} 79 | -------------------------------------------------------------------------------- /deploy/charts/operator/templates/hpa.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.operator.autoscaling.enabled }} 2 | apiVersion: autoscaling/v2 3 | kind: HorizontalPodAutoscaler 4 | metadata: 5 | name: {{ include "operator.fullname" . }} 6 | labels: 7 | {{- include "operator.labels" . | nindent 4 }} 8 | spec: 9 | scaleTargetRef: 10 | apiVersion: apps/v1 11 | kind: Deployment 12 | name: {{ include "operator.fullname" . }} 13 | minReplicas: {{ .Values.operator.autoscaling.minReplicas }} 14 | maxReplicas: {{ .Values.operator.autoscaling.maxReplicas }} 15 | metrics: 16 | {{- if .Values.operator.autoscaling.targetCPUUtilizationPercentage }} 17 | - type: Resource 18 | resource: 19 | name: cpu 20 | target: 21 | type: Utilization 22 | averageUtilization: {{ .Values.operator.autoscaling.targetCPUUtilizationPercentage }} 23 | {{- end }} 24 | {{- if .Values.operator.autoscaling.targetMemoryUtilizationPercentage }} 25 | - type: Resource 26 | resource: 27 | name: memory 28 | target: 29 | type: Utilization 30 | averageUtilization: {{ .Values.operator.autoscaling.targetMemoryUtilizationPercentage }} 31 | {{- end }} 32 | {{- end }} 33 | -------------------------------------------------------------------------------- /deploy/charts/operator/templates/leader-election-role.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: Role 4 | metadata: 5 | name: {{ .Values.operator.leaderElectionRole.name }} 6 | namespace: {{ .Release.Namespace }} 7 | labels: 8 | {{- include "operator.labels" . | nindent 4 }} 9 | rules: 10 | {{- toYaml .Values.operator.leaderElectionRole.rules | nindent 2 }} 11 | --- 12 | apiVersion: rbac.authorization.k8s.io/v1 13 | kind: RoleBinding 14 | metadata: 15 | name: {{ .Values.operator.leaderElectionRole.binding.name }} 16 | namespace: {{ .Release.Namespace }} 17 | labels: 18 | {{- include "operator.labels" . | nindent 4 }} 19 | roleRef: 20 | apiGroup: rbac.authorization.k8s.io 21 | kind: Role 22 | name: {{ .Values.operator.leaderElectionRole.name }} 23 | subjects: 24 | - kind: ServiceAccount 25 | name: {{ .Values.operator.serviceAccount.name }} 26 | namespace: {{ .Release.Namespace }} -------------------------------------------------------------------------------- /deploy/charts/operator/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.operator.serviceAccount.create }} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "operator.fullname" . }} 6 | namespace: {{ .Release.Namespace }} 7 | labels: 8 | app.kubernetes.io/name: toolhive-operator 9 | app.kubernetes.io/part-of: toolhive-operator 10 | {{- if .Values.operator.serviceAccount.labels }} 11 | {{- toYaml .Values.operator.serviceAccount.labels | nindent 4 }} 12 | {{- end }} 13 | {{- if .Values.operator.serviceAccount.annotations }} 14 | annotations: 15 | {{- toYaml .Values.operator.serviceAccount.annotations | nindent 12 }} 16 | {{- end }} 17 | automountServiceAccountToken: {{ .Values.operator.serviceAccount.automountServiceAccountToken }} 18 | {{- end }} 19 | -------------------------------------------------------------------------------- /deploy/charts/operator/templates/toolhive-rbac.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.toolhive.rbac.enabled }} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ .Values.toolhive.rbac.serviceAccount.name }} 6 | namespace: {{ .Release.Namespace }} 7 | labels: 8 | {{- include "toolhive.labels" . | nindent 4 }} 9 | --- 10 | apiVersion: rbac.authorization.k8s.io/v1 11 | kind: Role 12 | metadata: 13 | name: {{ .Values.toolhive.rbac.role.name }} 14 | namespace: {{ .Release.Namespace }} 15 | labels: 16 | {{- include "toolhive.labels" . | nindent 4 }} 17 | rules: 18 | {{- toYaml .Values.toolhive.rbac.role.rules | nindent 2 }} 19 | --- 20 | apiVersion: rbac.authorization.k8s.io/v1 21 | kind: RoleBinding 22 | metadata: 23 | name: {{ .Values.toolhive.rbac.role.bindingName }} 24 | namespace: {{ .Release.Namespace }} 25 | labels: 26 | {{- include "toolhive.labels" . | nindent 4 }} 27 | subjects: 28 | - kind: ServiceAccount 29 | name: {{ .Values.toolhive.rbac.serviceAccount.name }} 30 | roleRef: 31 | kind: Role 32 | name: {{ .Values.toolhive.rbac.role.name }} 33 | apiGroup: rbac.authorization.k8s.io 34 | {{- end }} -------------------------------------------------------------------------------- /docs/cli/thv.md: -------------------------------------------------------------------------------- 1 | ## thv 2 | 3 | ToolHive (thv) is a lightweight, secure, and fast manager for MCP servers 4 | 5 | ### Synopsis 6 | 7 | ToolHive (thv) is a lightweight, secure, and fast manager for MCP (Model Context Protocol) servers. 8 | It is written in Go and has extensive test coverage—including input validation—to ensure reliability and security. 9 | 10 | Under the hood, ToolHive acts as a very thin client for the Docker/Podman Unix socket API. 11 | This design choice allows it to remain both efficient and lightweight while still providing powerful, 12 | container-based isolation for running MCP servers. 13 | 14 | ``` 15 | thv [flags] 16 | ``` 17 | 18 | ### Options 19 | 20 | ``` 21 | --debug Enable debug mode 22 | -h, --help help for thv 23 | ``` 24 | 25 | ### SEE ALSO 26 | 27 | * [thv config](thv_config.md) - Manage application configuration 28 | * [thv inspector](thv_inspector.md) - Launches the MCP Inspector UI and connects it to the specified MCP server 29 | * [thv list](thv_list.md) - List running MCP servers 30 | * [thv logs](thv_logs.md) - Output the logs of an MCP server or manage log files 31 | * [thv proxy](thv_proxy.md) - Create a transparent proxy for an MCP server with authentication support 32 | * [thv registry](thv_registry.md) - Manage MCP server registry 33 | * [thv restart](thv_restart.md) - Restart a tooling server 34 | * [thv rm](thv_rm.md) - Remove an MCP server 35 | * [thv run](thv_run.md) - Run an MCP server 36 | * [thv search](thv_search.md) - Search for MCP servers 37 | * [thv secret](thv_secret.md) - Manage secrets 38 | * [thv serve](thv_serve.md) - Start the ToolHive API server 39 | * [thv stop](thv_stop.md) - Stop an MCP server 40 | * [thv version](thv_version.md) - Show the version of ToolHive 41 | 42 | -------------------------------------------------------------------------------- /docs/cli/thv_config.md: -------------------------------------------------------------------------------- 1 | ## thv config 2 | 3 | Manage application configuration 4 | 5 | ### Synopsis 6 | 7 | The config command provides subcommands to manage application configuration settings. 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for config 13 | ``` 14 | 15 | ### Options inherited from parent commands 16 | 17 | ``` 18 | --debug Enable debug mode 19 | ``` 20 | 21 | ### SEE ALSO 22 | 23 | * [thv](thv.md) - ToolHive (thv) is a lightweight, secure, and fast manager for MCP servers 24 | * [thv config auto-discovery](thv_config_auto-discovery.md) - Set whether to enable auto-discovery of MCP clients 25 | * [thv config get-ca-cert](thv_config_get-ca-cert.md) - Get the currently configured CA certificate path 26 | * [thv config get-registry-url](thv_config_get-registry-url.md) - Get the currently configured registry URL 27 | * [thv config list-registered-clients](thv_config_list-registered-clients.md) - List all registered MCP clients 28 | * [thv config register-client](thv_config_register-client.md) - Register a client for MCP server configuration 29 | * [thv config remove-client](thv_config_remove-client.md) - Remove a client from MCP server configuration 30 | * [thv config secrets-provider](thv_config_secrets-provider.md) - Set the secrets provider type 31 | * [thv config set-ca-cert](thv_config_set-ca-cert.md) - Set the default CA certificate for container builds 32 | * [thv config set-registry-url](thv_config_set-registry-url.md) - Set the MCP server registry URL 33 | * [thv config unset-ca-cert](thv_config_unset-ca-cert.md) - Remove the configured CA certificate 34 | * [thv config unset-registry-url](thv_config_unset-registry-url.md) - Remove the configured registry URL 35 | 36 | -------------------------------------------------------------------------------- /docs/cli/thv_config_auto-discovery.md: -------------------------------------------------------------------------------- 1 | ## thv config auto-discovery 2 | 3 | Set whether to enable auto-discovery of MCP clients 4 | 5 | ### Synopsis 6 | 7 | Set whether to enable auto-discovery and configuration of MCP clients. 8 | When enabled, ToolHive will automatically update client configuration files 9 | with the URLs of running MCP servers. 10 | 11 | ``` 12 | thv config auto-discovery [true|false] [flags] 13 | ``` 14 | 15 | ### Options 16 | 17 | ``` 18 | -h, --help help for auto-discovery 19 | ``` 20 | 21 | ### Options inherited from parent commands 22 | 23 | ``` 24 | --debug Enable debug mode 25 | ``` 26 | 27 | ### SEE ALSO 28 | 29 | * [thv config](thv_config.md) - Manage application configuration 30 | 31 | -------------------------------------------------------------------------------- /docs/cli/thv_config_get-ca-cert.md: -------------------------------------------------------------------------------- 1 | ## thv config get-ca-cert 2 | 3 | Get the currently configured CA certificate path 4 | 5 | ### Synopsis 6 | 7 | Display the path to the CA certificate file that is currently configured for container builds. 8 | 9 | ``` 10 | thv config get-ca-cert [flags] 11 | ``` 12 | 13 | ### Options 14 | 15 | ``` 16 | -h, --help help for get-ca-cert 17 | ``` 18 | 19 | ### Options inherited from parent commands 20 | 21 | ``` 22 | --debug Enable debug mode 23 | ``` 24 | 25 | ### SEE ALSO 26 | 27 | * [thv config](thv_config.md) - Manage application configuration 28 | 29 | -------------------------------------------------------------------------------- /docs/cli/thv_config_get-registry-url.md: -------------------------------------------------------------------------------- 1 | ## thv config get-registry-url 2 | 3 | Get the currently configured registry URL 4 | 5 | ### Synopsis 6 | 7 | Display the URL of the remote registry that is currently configured. 8 | 9 | ``` 10 | thv config get-registry-url [flags] 11 | ``` 12 | 13 | ### Options 14 | 15 | ``` 16 | -h, --help help for get-registry-url 17 | ``` 18 | 19 | ### Options inherited from parent commands 20 | 21 | ``` 22 | --debug Enable debug mode 23 | ``` 24 | 25 | ### SEE ALSO 26 | 27 | * [thv config](thv_config.md) - Manage application configuration 28 | 29 | -------------------------------------------------------------------------------- /docs/cli/thv_config_list-registered-clients.md: -------------------------------------------------------------------------------- 1 | ## thv config list-registered-clients 2 | 3 | List all registered MCP clients 4 | 5 | ### Synopsis 6 | 7 | List all clients that are registered for MCP server configuration. 8 | 9 | ``` 10 | thv config list-registered-clients [flags] 11 | ``` 12 | 13 | ### Options 14 | 15 | ``` 16 | -h, --help help for list-registered-clients 17 | ``` 18 | 19 | ### Options inherited from parent commands 20 | 21 | ``` 22 | --debug Enable debug mode 23 | ``` 24 | 25 | ### SEE ALSO 26 | 27 | * [thv config](thv_config.md) - Manage application configuration 28 | 29 | -------------------------------------------------------------------------------- /docs/cli/thv_config_register-client.md: -------------------------------------------------------------------------------- 1 | ## thv config register-client 2 | 3 | Register a client for MCP server configuration 4 | 5 | ### Synopsis 6 | 7 | Register a client for MCP server configuration. 8 | Valid clients are: 9 | - claude-code: Claude Code CLI 10 | - cline: Cline extension for VS Code 11 | - cursor: Cursor editor 12 | - roo-code: Roo Code extension for VS Code 13 | - vscode: Visual Studio Code 14 | - vscode-insider: Visual Studio Code Insiders edition 15 | 16 | ``` 17 | thv config register-client [client] [flags] 18 | ``` 19 | 20 | ### Options 21 | 22 | ``` 23 | -h, --help help for register-client 24 | ``` 25 | 26 | ### Options inherited from parent commands 27 | 28 | ``` 29 | --debug Enable debug mode 30 | ``` 31 | 32 | ### SEE ALSO 33 | 34 | * [thv config](thv_config.md) - Manage application configuration 35 | 36 | -------------------------------------------------------------------------------- /docs/cli/thv_config_remove-client.md: -------------------------------------------------------------------------------- 1 | ## thv config remove-client 2 | 3 | Remove a client from MCP server configuration 4 | 5 | ### Synopsis 6 | 7 | Remove a client from MCP server configuration. 8 | Valid clients are: 9 | - claude-code: Claude Code CLI 10 | - cline: Cline extension for VS Code 11 | - cursor: Cursor editor 12 | - roo-code: Roo Code extension for VS Code 13 | - vscode: Visual Studio Code 14 | - vscode-insider: Visual Studio Code Insiders edition 15 | 16 | ``` 17 | thv config remove-client [client] [flags] 18 | ``` 19 | 20 | ### Options 21 | 22 | ``` 23 | -h, --help help for remove-client 24 | ``` 25 | 26 | ### Options inherited from parent commands 27 | 28 | ``` 29 | --debug Enable debug mode 30 | ``` 31 | 32 | ### SEE ALSO 33 | 34 | * [thv config](thv_config.md) - Manage application configuration 35 | 36 | -------------------------------------------------------------------------------- /docs/cli/thv_config_secrets-provider.md: -------------------------------------------------------------------------------- 1 | ## thv config secrets-provider 2 | 3 | Set the secrets provider type 4 | 5 | ### Synopsis 6 | 7 | Set the secrets provider type for storing and retrieving secrets. 8 | Valid providers are: 9 | - encrypted: Stores secrets in an encrypted file using AES-256-GCM 10 | 11 | ``` 12 | thv config secrets-provider [provider] [flags] 13 | ``` 14 | 15 | ### Options 16 | 17 | ``` 18 | -h, --help help for secrets-provider 19 | ``` 20 | 21 | ### Options inherited from parent commands 22 | 23 | ``` 24 | --debug Enable debug mode 25 | ``` 26 | 27 | ### SEE ALSO 28 | 29 | * [thv config](thv_config.md) - Manage application configuration 30 | 31 | -------------------------------------------------------------------------------- /docs/cli/thv_config_set-ca-cert.md: -------------------------------------------------------------------------------- 1 | ## thv config set-ca-cert 2 | 3 | Set the default CA certificate for container builds 4 | 5 | ### Synopsis 6 | 7 | Set the default CA certificate file path that will be used for all container builds. 8 | This is useful in corporate environments with TLS inspection where custom CA certificates are required. 9 | 10 | Example: 11 | thv config set-ca-cert /path/to/corporate-ca.crt 12 | 13 | ``` 14 | thv config set-ca-cert [flags] 15 | ``` 16 | 17 | ### Options 18 | 19 | ``` 20 | -h, --help help for set-ca-cert 21 | ``` 22 | 23 | ### Options inherited from parent commands 24 | 25 | ``` 26 | --debug Enable debug mode 27 | ``` 28 | 29 | ### SEE ALSO 30 | 31 | * [thv config](thv_config.md) - Manage application configuration 32 | 33 | -------------------------------------------------------------------------------- /docs/cli/thv_config_set-registry-url.md: -------------------------------------------------------------------------------- 1 | ## thv config set-registry-url 2 | 3 | Set the MCP server registry URL 4 | 5 | ### Synopsis 6 | 7 | Set the URL for the remote MCP server registry. 8 | This allows you to use a custom registry instead of the built-in one. 9 | 10 | Example: 11 | thv config set-registry-url https://example.com/registry.json 12 | 13 | ``` 14 | thv config set-registry-url [flags] 15 | ``` 16 | 17 | ### Options 18 | 19 | ``` 20 | -h, --help help for set-registry-url 21 | ``` 22 | 23 | ### Options inherited from parent commands 24 | 25 | ``` 26 | --debug Enable debug mode 27 | ``` 28 | 29 | ### SEE ALSO 30 | 31 | * [thv config](thv_config.md) - Manage application configuration 32 | 33 | -------------------------------------------------------------------------------- /docs/cli/thv_config_unset-ca-cert.md: -------------------------------------------------------------------------------- 1 | ## thv config unset-ca-cert 2 | 3 | Remove the configured CA certificate 4 | 5 | ### Synopsis 6 | 7 | Remove the CA certificate configuration, reverting to default behavior without custom CA certificates. 8 | 9 | ``` 10 | thv config unset-ca-cert [flags] 11 | ``` 12 | 13 | ### Options 14 | 15 | ``` 16 | -h, --help help for unset-ca-cert 17 | ``` 18 | 19 | ### Options inherited from parent commands 20 | 21 | ``` 22 | --debug Enable debug mode 23 | ``` 24 | 25 | ### SEE ALSO 26 | 27 | * [thv config](thv_config.md) - Manage application configuration 28 | 29 | -------------------------------------------------------------------------------- /docs/cli/thv_config_unset-registry-url.md: -------------------------------------------------------------------------------- 1 | ## thv config unset-registry-url 2 | 3 | Remove the configured registry URL 4 | 5 | ### Synopsis 6 | 7 | Remove the registry URL configuration, reverting to the built-in registry. 8 | 9 | ``` 10 | thv config unset-registry-url [flags] 11 | ``` 12 | 13 | ### Options 14 | 15 | ``` 16 | -h, --help help for unset-registry-url 17 | ``` 18 | 19 | ### Options inherited from parent commands 20 | 21 | ``` 22 | --debug Enable debug mode 23 | ``` 24 | 25 | ### SEE ALSO 26 | 27 | * [thv config](thv_config.md) - Manage application configuration 28 | 29 | -------------------------------------------------------------------------------- /docs/cli/thv_inspector.md: -------------------------------------------------------------------------------- 1 | ## thv inspector 2 | 3 | Launches the MCP Inspector UI and connects it to the specified MCP server 4 | 5 | ### Synopsis 6 | 7 | Launches the MCP Inspector UI and connects it to the specified MCP server 8 | 9 | ``` 10 | thv inspector [container-name] [flags] 11 | ``` 12 | 13 | ### Options 14 | 15 | ``` 16 | -h, --help help for inspector 17 | -p, --mcp-proxy-port int Port to run the MCP Proxy on (default 6277) 18 | -u, --ui-port int Port to run the MCP Inspector UI on (default 6274) 19 | ``` 20 | 21 | ### Options inherited from parent commands 22 | 23 | ``` 24 | --debug Enable debug mode 25 | ``` 26 | 27 | ### SEE ALSO 28 | 29 | * [thv](thv.md) - ToolHive (thv) is a lightweight, secure, and fast manager for MCP servers 30 | 31 | -------------------------------------------------------------------------------- /docs/cli/thv_list.md: -------------------------------------------------------------------------------- 1 | ## thv list 2 | 3 | List running MCP servers 4 | 5 | ### Synopsis 6 | 7 | List all MCP servers managed by ToolHive, including their status and configuration. 8 | 9 | ``` 10 | thv list [flags] 11 | ``` 12 | 13 | ### Options 14 | 15 | ``` 16 | -a, --all Show all containers (default shows just running) 17 | --format string Output format (json, text, or mcpservers) (default "text") 18 | -h, --help help for list 19 | ``` 20 | 21 | ### Options inherited from parent commands 22 | 23 | ``` 24 | --debug Enable debug mode 25 | ``` 26 | 27 | ### SEE ALSO 28 | 29 | * [thv](thv.md) - ToolHive (thv) is a lightweight, secure, and fast manager for MCP servers 30 | 31 | -------------------------------------------------------------------------------- /docs/cli/thv_logs.md: -------------------------------------------------------------------------------- 1 | ## thv logs 2 | 3 | Output the logs of an MCP server or manage log files 4 | 5 | ### Synopsis 6 | 7 | Output the logs of an MCP server managed by ToolHive, or manage log files. 8 | 9 | ``` 10 | thv logs [container-name|prune] [flags] 11 | ``` 12 | 13 | ### Options 14 | 15 | ``` 16 | -f, --follow Follow log output (only for container logs) 17 | -h, --help help for logs 18 | ``` 19 | 20 | ### Options inherited from parent commands 21 | 22 | ``` 23 | --debug Enable debug mode 24 | ``` 25 | 26 | ### SEE ALSO 27 | 28 | * [thv](thv.md) - ToolHive (thv) is a lightweight, secure, and fast manager for MCP servers 29 | * [thv logs prune](thv_logs_prune.md) - Delete log files from servers not currently managed by ToolHive 30 | 31 | -------------------------------------------------------------------------------- /docs/cli/thv_logs_prune.md: -------------------------------------------------------------------------------- 1 | ## thv logs prune 2 | 3 | Delete log files from servers not currently managed by ToolHive 4 | 5 | ### Synopsis 6 | 7 | Delete log files from servers that are not currently managed by ToolHive (running or stopped). 8 | This helps clean up old log files that accumulate over time from removed servers. 9 | 10 | ``` 11 | thv logs prune [flags] 12 | ``` 13 | 14 | ### Options 15 | 16 | ``` 17 | -h, --help help for prune 18 | ``` 19 | 20 | ### Options inherited from parent commands 21 | 22 | ``` 23 | --debug Enable debug mode 24 | ``` 25 | 26 | ### SEE ALSO 27 | 28 | * [thv logs](thv_logs.md) - Output the logs of an MCP server or manage log files 29 | 30 | -------------------------------------------------------------------------------- /docs/cli/thv_registry.md: -------------------------------------------------------------------------------- 1 | ## thv registry 2 | 3 | Manage MCP server registry 4 | 5 | ### Synopsis 6 | 7 | Manage the MCP server registry, including listing and getting information about available MCP servers. 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for registry 13 | ``` 14 | 15 | ### Options inherited from parent commands 16 | 17 | ``` 18 | --debug Enable debug mode 19 | ``` 20 | 21 | ### SEE ALSO 22 | 23 | * [thv](thv.md) - ToolHive (thv) is a lightweight, secure, and fast manager for MCP servers 24 | * [thv registry info](thv_registry_info.md) - Get information about an MCP server 25 | * [thv registry list](thv_registry_list.md) - List available MCP servers 26 | 27 | -------------------------------------------------------------------------------- /docs/cli/thv_registry_info.md: -------------------------------------------------------------------------------- 1 | ## thv registry info 2 | 3 | Get information about an MCP server 4 | 5 | ### Synopsis 6 | 7 | Get detailed information about a specific MCP server in the registry. 8 | 9 | ``` 10 | thv registry info [server] [flags] 11 | ``` 12 | 13 | ### Options 14 | 15 | ``` 16 | --format string Output format (json or text) (default "text") 17 | -h, --help help for info 18 | ``` 19 | 20 | ### Options inherited from parent commands 21 | 22 | ``` 23 | --debug Enable debug mode 24 | ``` 25 | 26 | ### SEE ALSO 27 | 28 | * [thv registry](thv_registry.md) - Manage MCP server registry 29 | 30 | -------------------------------------------------------------------------------- /docs/cli/thv_registry_list.md: -------------------------------------------------------------------------------- 1 | ## thv registry list 2 | 3 | List available MCP servers 4 | 5 | ### Synopsis 6 | 7 | List all available MCP servers in the registry. 8 | 9 | ``` 10 | thv registry list [flags] 11 | ``` 12 | 13 | ### Options 14 | 15 | ``` 16 | --format string Output format (json or text) (default "text") 17 | -h, --help help for list 18 | ``` 19 | 20 | ### Options inherited from parent commands 21 | 22 | ``` 23 | --debug Enable debug mode 24 | ``` 25 | 26 | ### SEE ALSO 27 | 28 | * [thv registry](thv_registry.md) - Manage MCP server registry 29 | 30 | -------------------------------------------------------------------------------- /docs/cli/thv_restart.md: -------------------------------------------------------------------------------- 1 | ## thv restart 2 | 3 | Restart a tooling server 4 | 5 | ### Synopsis 6 | 7 | Restart a running tooling server managed by ToolHive. If the server is not running, it will be started. 8 | 9 | ``` 10 | thv restart [container-name] [flags] 11 | ``` 12 | 13 | ### Options 14 | 15 | ``` 16 | -a, --all Restart all MCP servers 17 | -h, --help help for restart 18 | ``` 19 | 20 | ### Options inherited from parent commands 21 | 22 | ``` 23 | --debug Enable debug mode 24 | ``` 25 | 26 | ### SEE ALSO 27 | 28 | * [thv](thv.md) - ToolHive (thv) is a lightweight, secure, and fast manager for MCP servers 29 | 30 | -------------------------------------------------------------------------------- /docs/cli/thv_rm.md: -------------------------------------------------------------------------------- 1 | ## thv rm 2 | 3 | Remove an MCP server 4 | 5 | ### Synopsis 6 | 7 | Remove an MCP server managed by ToolHive. 8 | 9 | ``` 10 | thv rm [container-name] [flags] 11 | ``` 12 | 13 | ### Options 14 | 15 | ``` 16 | -f, --force Force removal of a running container 17 | -h, --help help for rm 18 | ``` 19 | 20 | ### Options inherited from parent commands 21 | 22 | ``` 23 | --debug Enable debug mode 24 | ``` 25 | 26 | ### SEE ALSO 27 | 28 | * [thv](thv.md) - ToolHive (thv) is a lightweight, secure, and fast manager for MCP servers 29 | 30 | -------------------------------------------------------------------------------- /docs/cli/thv_search.md: -------------------------------------------------------------------------------- 1 | ## thv search 2 | 3 | Search for MCP servers 4 | 5 | ### Synopsis 6 | 7 | Search for MCP servers in the registry by name, description, or tags. 8 | 9 | ``` 10 | thv search [query] [flags] 11 | ``` 12 | 13 | ### Options 14 | 15 | ``` 16 | --format string Output format (json or text) (default "text") 17 | -h, --help help for search 18 | ``` 19 | 20 | ### Options inherited from parent commands 21 | 22 | ``` 23 | --debug Enable debug mode 24 | ``` 25 | 26 | ### SEE ALSO 27 | 28 | * [thv](thv.md) - ToolHive (thv) is a lightweight, secure, and fast manager for MCP servers 29 | 30 | -------------------------------------------------------------------------------- /docs/cli/thv_secret.md: -------------------------------------------------------------------------------- 1 | ## thv secret 2 | 3 | Manage secrets 4 | 5 | ### Synopsis 6 | 7 | The secret command provides subcommands to set, get, delete, and list secrets. 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for secret 13 | ``` 14 | 15 | ### Options inherited from parent commands 16 | 17 | ``` 18 | --debug Enable debug mode 19 | ``` 20 | 21 | ### SEE ALSO 22 | 23 | * [thv](thv.md) - ToolHive (thv) is a lightweight, secure, and fast manager for MCP servers 24 | * [thv secret delete](thv_secret_delete.md) - Delete a secret 25 | * [thv secret get](thv_secret_get.md) - Get a secret 26 | * [thv secret list](thv_secret_list.md) - List all available secrets 27 | * [thv secret provider](thv_secret_provider.md) - Configure the secrets provider 28 | * [thv secret reset-keyring](thv_secret_reset-keyring.md) - Reset the keyring secret 29 | * [thv secret set](thv_secret_set.md) - Set a secret 30 | 31 | -------------------------------------------------------------------------------- /docs/cli/thv_secret_delete.md: -------------------------------------------------------------------------------- 1 | ## thv secret delete 2 | 3 | Delete a secret 4 | 5 | ``` 6 | thv secret delete [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for delete 13 | ``` 14 | 15 | ### Options inherited from parent commands 16 | 17 | ``` 18 | --debug Enable debug mode 19 | ``` 20 | 21 | ### SEE ALSO 22 | 23 | * [thv secret](thv_secret.md) - Manage secrets 24 | 25 | -------------------------------------------------------------------------------- /docs/cli/thv_secret_get.md: -------------------------------------------------------------------------------- 1 | ## thv secret get 2 | 3 | Get a secret 4 | 5 | ``` 6 | thv secret get [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for get 13 | ``` 14 | 15 | ### Options inherited from parent commands 16 | 17 | ``` 18 | --debug Enable debug mode 19 | ``` 20 | 21 | ### SEE ALSO 22 | 23 | * [thv secret](thv_secret.md) - Manage secrets 24 | 25 | -------------------------------------------------------------------------------- /docs/cli/thv_secret_list.md: -------------------------------------------------------------------------------- 1 | ## thv secret list 2 | 3 | List all available secrets 4 | 5 | ``` 6 | thv secret list [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for list 13 | ``` 14 | 15 | ### Options inherited from parent commands 16 | 17 | ``` 18 | --debug Enable debug mode 19 | ``` 20 | 21 | ### SEE ALSO 22 | 23 | * [thv secret](thv_secret.md) - Manage secrets 24 | 25 | -------------------------------------------------------------------------------- /docs/cli/thv_secret_provider.md: -------------------------------------------------------------------------------- 1 | ## thv secret provider 2 | 3 | Configure the secrets provider 4 | 5 | ### Synopsis 6 | 7 | Configure the secrets provider. 8 | Valid secrets providers are: 9 | - encrypted: Encrypted secrets provider 10 | - 1password: 1Password secrets provider (currently only supports getting secrets) 11 | 12 | ``` 13 | thv secret provider [flags] 14 | ``` 15 | 16 | ### Options 17 | 18 | ``` 19 | -h, --help help for provider 20 | ``` 21 | 22 | ### Options inherited from parent commands 23 | 24 | ``` 25 | --debug Enable debug mode 26 | ``` 27 | 28 | ### SEE ALSO 29 | 30 | * [thv secret](thv_secret.md) - Manage secrets 31 | 32 | -------------------------------------------------------------------------------- /docs/cli/thv_secret_reset-keyring.md: -------------------------------------------------------------------------------- 1 | ## thv secret reset-keyring 2 | 3 | Reset the keyring secret 4 | 5 | ``` 6 | thv secret reset-keyring [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | -h, --help help for reset-keyring 13 | ``` 14 | 15 | ### Options inherited from parent commands 16 | 17 | ``` 18 | --debug Enable debug mode 19 | ``` 20 | 21 | ### SEE ALSO 22 | 23 | * [thv secret](thv_secret.md) - Manage secrets 24 | 25 | -------------------------------------------------------------------------------- /docs/cli/thv_secret_set.md: -------------------------------------------------------------------------------- 1 | ## thv secret set 2 | 3 | Set a secret 4 | 5 | ### Synopsis 6 | 7 | Set a secret with the given name. 8 | 9 | Input Methods: 10 | - Piped Input: If data is piped to the command, the secret value will be read from stdin. 11 | Examples: 12 | echo "my-secret-value" | thv secret set my-secret 13 | cat secret-file.txt | thv secret set my-secret 14 | 15 | - Interactive Input: If no data is piped, you will be prompted to enter the secret value securely 16 | (input will be hidden). 17 | Example: 18 | thv secret set my-secret 19 | Enter secret value (input will be hidden): _ 20 | 21 | The secret will be stored securely using the configured secrets provider. 22 | 23 | ``` 24 | thv secret set [flags] 25 | ``` 26 | 27 | ### Options 28 | 29 | ``` 30 | -h, --help help for set 31 | ``` 32 | 33 | ### Options inherited from parent commands 34 | 35 | ``` 36 | --debug Enable debug mode 37 | ``` 38 | 39 | ### SEE ALSO 40 | 41 | * [thv secret](thv_secret.md) - Manage secrets 42 | 43 | -------------------------------------------------------------------------------- /docs/cli/thv_serve.md: -------------------------------------------------------------------------------- 1 | ## thv serve 2 | 3 | Start the ToolHive API server 4 | 5 | ### Synopsis 6 | 7 | Starts the ToolHive API server and listen for HTTP requests. 8 | 9 | ``` 10 | thv serve [flags] 11 | ``` 12 | 13 | ### Options 14 | 15 | ``` 16 | -h, --help help for serve 17 | --host string Host address to bind the server to (default "127.0.0.1") 18 | --oidc-audience string Expected audience for the token 19 | --oidc-client-id string OIDC client ID 20 | --oidc-issuer string OIDC issuer URL (e.g., https://accounts.google.com) 21 | --oidc-jwks-url string URL to fetch the JWKS from 22 | --openapi Enable OpenAPI documentation endpoints (/api/openapi.json and /api/doc) 23 | --port int Port to bind the server to (default 8080) 24 | --socket string UNIX socket path to bind the server to (overrides host and port if provided) 25 | ``` 26 | 27 | ### Options inherited from parent commands 28 | 29 | ``` 30 | --debug Enable debug mode 31 | ``` 32 | 33 | ### SEE ALSO 34 | 35 | * [thv](thv.md) - ToolHive (thv) is a lightweight, secure, and fast manager for MCP servers 36 | 37 | -------------------------------------------------------------------------------- /docs/cli/thv_stop.md: -------------------------------------------------------------------------------- 1 | ## thv stop 2 | 3 | Stop an MCP server 4 | 5 | ### Synopsis 6 | 7 | Stop a running MCP server managed by ToolHive. 8 | 9 | ``` 10 | thv stop [container-name] [flags] 11 | ``` 12 | 13 | ### Options 14 | 15 | ``` 16 | -h, --help help for stop 17 | --timeout int Timeout in seconds before forcibly stopping the container (default 30) 18 | ``` 19 | 20 | ### Options inherited from parent commands 21 | 22 | ``` 23 | --debug Enable debug mode 24 | ``` 25 | 26 | ### SEE ALSO 27 | 28 | * [thv](thv.md) - ToolHive (thv) is a lightweight, secure, and fast manager for MCP servers 29 | 30 | -------------------------------------------------------------------------------- /docs/cli/thv_version.md: -------------------------------------------------------------------------------- 1 | ## thv version 2 | 3 | Show the version of ToolHive 4 | 5 | ### Synopsis 6 | 7 | Display detailed version information about ToolHive, including version number, git commit, build date, and Go version. 8 | 9 | ``` 10 | thv version [flags] 11 | ``` 12 | 13 | ### Options 14 | 15 | ``` 16 | --format string Output format (json or text) (default "text") 17 | -h, --help help for version 18 | --json Output version information as JSON (deprecated, use --format instead) 19 | ``` 20 | 21 | ### Options inherited from parent commands 22 | 23 | ``` 24 | --debug Enable debug mode 25 | ``` 26 | 27 | ### SEE ALSO 28 | 29 | * [thv](thv.md) - ToolHive (thv) is a lightweight, secure, and fast manager for MCP servers 30 | 31 | -------------------------------------------------------------------------------- /docs/images/toolhive.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stacklok/toolhive/9f52ce7ed5a2639ba78ebf2db75cdb6ba04b402a/docs/images/toolhive.png -------------------------------------------------------------------------------- /docs/kind/deploying-mcp-server-with-operator.md: -------------------------------------------------------------------------------- 1 | # Deploying MCP Server With Operator 2 | 3 | The [ToolHive Kubernetes Operator](../../cmd/thv-operator/README.md) manages MCP (Model Context Protocol) servers in Kubernetes clusters. It allows you to define MCP servers as Kubernetes resources and automates their deployment and management. 4 | 5 | ## Prerequisites 6 | 7 | - Kind cluster with the [ToolHive Operator installed](./deploying-toolhive-operator.md) 8 | - kubectl installed 9 | 10 | ## Deploy MCP Server 11 | 12 | With the ToolHive Operator running, you can deploy an MCP server into the cluster by running the following: 13 | 14 | ```bash 15 | kubectl apply -f https://raw.githubusercontent.com/stacklok/toolhive/main/examples/operator/mcp-servers/mcpserver_mkp.yaml 16 | ``` 17 | 18 | You should now be able to see the MCP server pods being created/running: 19 | ```bash 20 | kubectl get pods -n toolhive-system 21 | ``` 22 | 23 | ## Accessing MCP Server 24 | 25 | Depending on how you want to access the created MCP server, you can follow the relevant guides: 26 | 27 | - [Access via Ingress](./ingress.md) 28 | - [Access via Port-Forward](./ingress-port-forward.md) -------------------------------------------------------------------------------- /docs/kind/deploying-toolhive-operator.md: -------------------------------------------------------------------------------- 1 | # Deploying ToolHive Kubernetes Operator 2 | 3 | The [ToolHive Kubernetes Operator](../../cmd/thv-operator/README.md) manages MCP (Model Context Protocol) servers in Kubernetes clusters. It allows you to define MCP servers as Kubernetes resources and automates their deployment and management. 4 | 5 | ## Prerequisites 6 | 7 | - [Helm](https://helm.sh/) installed 8 | - Kind installed 9 | - Optional: [Task](https://taskfile.dev/installation/) to run automated steps with a cloned copy of the ToolHive repository 10 | (`git clone https://github.com/stacklok/toolhive`) 11 | 12 | 13 | ## TL;DR 14 | 15 | To setup a kind cluster and/or deploy the Operator, we have created a Task so that you can do this with one command. You will need to clone this repository to run the command. 16 | 17 | ### Fresh Kind Cluster with Operator Install 18 | 19 | Run: 20 | ```bash 21 | task kind-with-toolhive-operator 22 | ``` 23 | 24 | This will create the kind cluster, install an nginx ingress controller and then install the latest built ToolHive Operator image. 25 | 26 | ### Existing Kind Cluster with Operator Install 27 | 28 | Run: 29 | 30 | ```bash 31 | # If you want to install the latest built operator image from Github (recommended) 32 | task operator-deploy-latest 33 | 34 | # If you want to built the operator image locally and deploy it (only recommended if you're doing development around the Operator) 35 | task operator-deploy-local 36 | ``` 37 | 38 | This will install the Operator into the existing Kind cluster that your `kconfig.yaml` file points to. 39 | 40 | ## Manual Installation 41 | 42 | ## Fresh Kind Cluster with Operator Install 43 | 44 | Follow the [Kind Cluster setup](./setup-kind-cluster.md#manual-setup-setup--destroy-a-local-kind-cluster) guide. 45 | 46 | Once the cluster is running, follow these steps: 47 | 48 | 1. Install the CRD: 49 | 50 | ```bash 51 | helm upgrade -i toolhive-operator-crds oci://ghcr.io/stacklok/toolhive/toolhive-operator-crds 52 | ``` 53 | 54 | 2. Deploy the operator: 55 | 56 | ```bash 57 | helm upgrade -i toolhive-operator oci://ghcr.io/stacklok/toolhive/toolhive-operator -n toolhive-system --create-namespace 58 | ``` 59 | 60 | ## Existing Kind Cluster with Operator Install 61 | 62 | 1. Install the CRD: 63 | 64 | ```bash 65 | helm upgrade -i toolhive-operator-crds oci://ghcr.io/stacklok/toolhive/toolhive-operator-crds 66 | ``` 67 | 68 | 2. Deploy the operator: 69 | 70 | ```bash 71 | helm upgrade -i toolhive-operator oci://ghcr.io/stacklok/toolhive/toolhive-operator -n toolhive-system --create-namespace 72 | ``` -------------------------------------------------------------------------------- /docs/kind/ingress-port-forward.md: -------------------------------------------------------------------------------- 1 | # Port-Forward to Access MCP Servers 2 | 3 | This document walks through using kubectl port-forward to access MCP servers running in a local Kind cluster. Port-forwarding provides a simple way to access services without setting up ingress controllers, making it ideal for testing and development workflows. 4 | 5 | ## Prerequisites 6 | 7 | - Kind cluster with the [ToolHive Operator installed](./deploying-toolhive-operator.md) 8 | - At least one [MCP server deployed](./deploying-mcp-server-with-operator.md) in the cluster 9 | - kubectl configured to communicate with your cluster 10 | 11 | ## Port-Forward to MCP Server 12 | 13 | ### List Available MCP Servers 14 | 15 | First, check what MCP servers are running in your cluster: 16 | 17 | ```bash 18 | kubectl get mcpservers -n toolhive-system 19 | ``` 20 | 21 | You should see output similar to: 22 | ``` 23 | NAME STATUS AGE 24 | fetch Running 2m30s 25 | ``` 26 | 27 | ### List MCP Server Services 28 | 29 | To port-forward to an MCP server, you need to identify the service that exposes it: 30 | 31 | ```bash 32 | kubectl get services -n toolhive-system 33 | ``` 34 | 35 | You should see services with names like `mcp-{server-name}-proxy`: 36 | ``` 37 | NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE 38 | mcp-fetch-proxy ClusterIP 10.96.45.123 8080/TCP 2m45s 39 | ``` 40 | 41 | ### Port-Forward to the MCP Server 42 | 43 | To access the MCP server from your local machine, use kubectl port-forward: 44 | 45 | ```bash 46 | kubectl port-forward -n toolhive-system service/mcp-fetch-proxy 8080:8080 47 | ``` 48 | 49 | This command: 50 | - Forwards local port 8080 to the service's port 8080 51 | - Keeps running in the foreground (use Ctrl+C to stop) 52 | - Allows you to access the MCP server at `http://localhost:8080` 53 | 54 | ### Access the MCP Server 55 | 56 | With the port-forward active, you can now access the MCP server: 57 | 58 | ```bash 59 | # Test connectivity 60 | curl http://localhost:8080/sse 61 | 62 | # Or use your MCP client to connect to localhost:8080 63 | ``` 64 | 65 | In your MCP config for your client you simply add the URL. 66 | 67 | The following is a Cursor MCP server entry: 68 | 69 | ```json 70 | { 71 | "mcpServers": { 72 | "fetch": {"url": "http://localhost:8080/sse"}, 73 | } 74 | } 75 | ``` -------------------------------------------------------------------------------- /docs/kind/setup-kind-cluster.md: -------------------------------------------------------------------------------- 1 | # Setup a Local Kind Cluster 2 | 3 | This document walks through setting up a local Kind cluster. There are many examples of how to do this online but the intention of this document is so that when writing future ToolHive content, we can refer back to this guide when needing to setup a local Kind cluster without polluting future content with the additional steps. 4 | 5 | ## Prerequisites 6 | 7 | - Local container runtime is installed ([Docker](https://www.docker.com/), [Podman](https://podman.io/) etc) 8 | - [Kind](https://kind.sigs.k8s.io/docs/user/quick-start/#installation) is installed 9 | - Optional: [Task](https://taskfile.dev/installation/) to run automated steps with a cloned copy of the ToolHive repository 10 | (`git clone https://github.com/stacklok/toolhive`) 11 | 12 | ## TL;DR 13 | 14 | To setup a local Kind Cluster using [Task](https://taskfile.dev/installation/), clone the ToolHive repo and run the below. 15 | 16 | ### Setup 17 | 18 | ```bash 19 | task kind-setup 20 | ``` 21 | 22 | This will create a single node Kind cluster and it will output the kubeconfig into the `kconfig.yaml` file. This file is added to the `.gitignore` of this repository, so there is no worry about checking it in. 23 | 24 | ### Destroy 25 | 26 | To destroy a local Kind cluster using Task, run: 27 | 28 | ```bash 29 | task kind-destroy 30 | ``` 31 | 32 | This will destroy the Kind cluster, as well as removing the `kconfig.yaml` kubeconfig file. 33 | 34 | ## Manual Setup: Setup & Destroy a Local Kind Cluster 35 | 36 | You can perform Kind operations manually by following the sections below. 37 | 38 | ### Setup 39 | 40 | To setup a Local Kind Cluster manually, run: 41 | 42 | ```bash 43 | kind create cluster --name toolhive 44 | ``` 45 | 46 | ### Getting Kind Config 47 | 48 | We recommend having a dedicated kubeconfig file to keep things isolated from your other cluster configs (even though Kind adds it to `~/.kube/config` automatically). 49 | 50 | To do this, run: 51 | 52 | ```bash 53 | kind get kubeconfig --name toolhive > kconfig.yaml 54 | ``` 55 | 56 | This will output the kind cluster config to a file called `kconfig.yaml` in the directory of which the command is ran in. This file is added to the `.gitignore` of this repository, so there is no worry about checking it in. 57 | 58 | ### Destroy 59 | 60 | To destroy a local Kind cluster, run: 61 | 62 | ```bash 63 | kind delete clusters toolhive 64 | ``` 65 | -------------------------------------------------------------------------------- /docs/registry/management.md: -------------------------------------------------------------------------------- 1 | # MCP Server Registry Management Process 2 | 3 | ## Overview 4 | This document outlines the processes for managing MCP (Model Context Protocol) servers within the ToolHive registry, covering adding, removing, appealing decisions, and handling duplicate submissions. 5 | 6 | ## Adding MCP Servers 7 | 1. Submit PR with required registry.json content 8 | 2. Automated technical verification 9 | 3. Manual review by registry maintainers 10 | 4. Final approval or rejection decision 11 | 12 | ## Removing MCP Servers 13 | 1. Automated non-compliance detection 14 | 2. Notification to registry maintainers 15 | 3. Grace period for remediation 16 | 4. Final review and decision 17 | 5. Public notification with reasoning 18 | 19 | ## Appeals Process 20 | - Open to MCP server users and maintainers 21 | - Based on objective criteria 22 | - Transparent communication of outcomes 23 | 24 | ## Handling Duplicates 25 | - Assess functional differentiation from existing entries 26 | - Prioritize based on: 27 | - Community adoption and activity levels 28 | - Overall code quality 29 | - Long-term viability and backing 30 | - Add deprecation notices before removal (1-2 month transition period) 31 | - Document rationale for decisions 32 | -------------------------------------------------------------------------------- /docs/server/README.md: -------------------------------------------------------------------------------- 1 | # ToolHive Server API Documentation 2 | 3 | ToolHive uses OpenAPI 3.1.0 for API documentation. The documentation is generated using [swag](https://github.com/swaggo/swag) and served using [Scalar](https://github.com/scalar/scalar). 4 | 5 | ## Prerequisites 6 | 7 | Install the required tools: 8 | 9 | ```bash 10 | # Install swag for OpenAPI generation 11 | go install github.com/swaggo/swag/v2/cmd/swag@v2.0.0-rc4 12 | ``` 13 | 14 | ## Generating Documentation 15 | 16 | 1. Add OpenAPI annotations to your code following the [swag documentation](https://github.com/swaggo/swag#declarative-comments-format) 17 | 18 | 2. Generate the OpenAPI specification: 19 | 20 | ```bash 21 | # at the root of the repository run: 22 | swag init -g pkg/api/server.go --v3.1 -o docs/server 23 | ``` 24 | 25 | This will generate: 26 | 27 | - `docs/swagger.json`: OpenAPI 3.1.0 specification 28 | - `docs/swagger.yaml`: YAML version of the specification 29 | - `docs/docs.go`: Go code containing the specification 30 | 31 | ## Viewing Documentation 32 | 33 | 1. Start the server with OpenAPI docs enabled: 34 | 35 | ```bash 36 | thv serve --openapi 37 | ``` 38 | 39 | 2. Access the documentation: 40 | - OpenAPI JSON spec: `http://localhost:8080/api/openapi.json` 41 | - Scalar UI: `http://localhost:8080/api/doc` 42 | 43 | ## Best Practices 44 | 45 | 1. Always document: 46 | 47 | - Request/response schemas 48 | - Error responses 49 | - Authentication requirements 50 | - Query parameters 51 | - Path parameters 52 | 53 | 2. Use descriptive summaries and descriptions 54 | 55 | 3. Group related endpoints using tags 56 | 57 | 4. Keep the documentation up to date with code changes 58 | 59 | ## Troubleshooting 60 | 61 | If the documentation is not updating: 62 | 63 | 1. Check that your annotations are correct 64 | 2. Verify that you're using the correct version of swag 65 | 3. Make sure you're running `swag init` from the correct directory 66 | 4. Check that the generated files are being included in your build 67 | -------------------------------------------------------------------------------- /examples/authz-config-with-entities.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0", 3 | "type": "cedarv1", 4 | "cedar": { 5 | "policies": [ 6 | "permit(principal, action == Action::\"call_tool\", resource) when { resource.owner == principal.claim_sub };", 7 | "permit(principal, action == Action::\"get_prompt\", resource) when { resource.visibility == \"public\" };", 8 | "permit(principal, action == Action::\"get_prompt\", resource) when { resource.visibility == \"private\" && resource.owner == principal.claim_sub };", 9 | "permit(principal, action == Action::\"read_resource\", resource) when { resource.visibility == \"public\" };", 10 | "permit(principal, action == Action::\"read_resource\", resource) when { resource.visibility == \"private\" && resource.owner == principal.claim_sub };", 11 | "permit(principal, action, resource) when { principal.claim_roles.contains(\"admin\") };" 12 | ], 13 | "entities_json": "[{\"uid\":\"Tool::weather\",\"attrs\":{\"owner\":\"user123\",\"description\":\"Weather forecast tool\"}},{\"uid\":\"Tool::calculator\",\"attrs\":{\"owner\":\"user456\",\"description\":\"Calculator tool\"}},{\"uid\":\"Prompt::greeting\",\"attrs\":{\"owner\":\"user123\",\"visibility\":\"public\",\"description\":\"Greeting prompt\"}},{\"uid\":\"Prompt::farewell\",\"attrs\":{\"owner\":\"user123\",\"visibility\":\"private\",\"description\":\"Farewell prompt\"}},{\"uid\":\"Resource::data\",\"attrs\":{\"owner\":\"user123\",\"visibility\":\"public\",\"description\":\"Public data resource\"}},{\"uid\":\"Resource::secret\",\"attrs\":{\"owner\":\"user123\",\"visibility\":\"private\",\"description\":\"Private data resource\"}}]" 14 | } 15 | } -------------------------------------------------------------------------------- /examples/authz-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.0", 3 | "type": "cedarv1", 4 | "cedar": { 5 | "policies": [ 6 | "permit(principal, action == Action::\"call_tool\", resource == Tool::\"weather\");", 7 | "permit(principal, action == Action::\"get_prompt\", resource == Prompt::\"greeting\");", 8 | "permit(principal, action == Action::\"read_resource\", resource == Resource::\"data\");", 9 | "permit(principal, action == Action::\"call_tool\", resource in Tool::[\"calculator\", \"translator\"]) when { principal.claim_roles.contains(\"admin\") };" 10 | ], 11 | "entities_json": "[]" 12 | } 13 | } -------------------------------------------------------------------------------- /examples/operator/mcp-servers/mcpserver_fetch.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: toolhive.stacklok.dev/v1alpha1 2 | kind: MCPServer 3 | metadata: 4 | name: fetch 5 | namespace: toolhive-system 6 | spec: 7 | image: docker.io/mcp/fetch 8 | transport: stdio 9 | port: 8080 10 | permissionProfile: 11 | type: builtin 12 | name: network 13 | podTemplateSpec: 14 | spec: 15 | containers: 16 | - name: mcp 17 | securityContext: 18 | allowPrivilegeEscalation: false 19 | runAsNonRoot: false 20 | runAsUser: 0 21 | runAsGroup: 0 22 | capabilities: 23 | drop: 24 | - ALL 25 | resources: 26 | limits: 27 | cpu: "500m" 28 | memory: "512Mi" 29 | requests: 30 | cpu: "100m" 31 | memory: "128Mi" 32 | securityContext: 33 | runAsNonRoot: false 34 | runAsUser: 0 35 | runAsGroup: 0 36 | seccompProfile: 37 | type: RuntimeDefault 38 | resources: 39 | limits: 40 | cpu: "100m" 41 | memory: "128Mi" 42 | requests: 43 | cpu: "50m" 44 | memory: "64Mi" -------------------------------------------------------------------------------- /examples/operator/mcp-servers/mcpserver_github.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: toolhive.stacklok.dev/v1alpha1 2 | kind: MCPServer 3 | metadata: 4 | name: github 5 | namespace: toolhive-system 6 | spec: 7 | image: ghcr.io/github/github-mcp-server 8 | transport: stdio 9 | port: 8080 10 | permissionProfile: 11 | type: builtin 12 | name: network 13 | secrets: 14 | - name: github-token 15 | token: token 16 | targetEnvName: GITHUB_PERSONAL_ACCESS_TOKEN 17 | env: 18 | - name: GITHUB_API_URL 19 | value: https://api.github.com 20 | - name: LOG_LEVEL 21 | value: info 22 | resources: 23 | limits: 24 | cpu: "200m" 25 | memory: "256Mi" 26 | requests: 27 | cpu: "100m" 28 | memory: "128Mi" 29 | -------------------------------------------------------------------------------- /examples/operator/mcp-servers/mcpserver_mkp.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: toolhive.stacklok.dev/v1alpha1 2 | kind: MCPServer 3 | metadata: 4 | name: mkp 5 | namespace: toolhive-system 6 | spec: 7 | image: ghcr.io/stackloklabs/mkp/server 8 | transport: sse 9 | port: 8080 10 | permissionProfile: 11 | type: builtin 12 | name: network 13 | # Example of using the PodTemplateSpec to customize the pod 14 | podTemplateSpec: 15 | spec: 16 | containers: 17 | - name: mcp 18 | # this value has to be set to a serviceaccount that has the necessary permissions 19 | # will use the default toolhive service account for an example 20 | serviceAccountName: toolhive 21 | containers: 22 | - name: mcp 23 | resources: 24 | limits: 25 | cpu: "100m" 26 | memory: "128Mi" 27 | requests: 28 | cpu: "50m" 29 | memory: "64Mi" -------------------------------------------------------------------------------- /examples/operator/mcp-servers/mcpserver_with_configmap_oidc.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: google-oidc-config 5 | namespace: toolhive-system 6 | data: 7 | issuer: "https://accounts.google.com" 8 | audience: "my-google-client-id" 9 | clientId: "my-google-client-id" 10 | # jwksUrl is optional - will be auto-discovered from issuer if not provided 11 | --- 12 | apiVersion: toolhive.stacklok.dev/v1alpha1 13 | kind: MCPServer 14 | metadata: 15 | name: secure-fetch-google 16 | namespace: toolhive-system 17 | spec: 18 | image: docker.io/mcp/fetch 19 | transport: stdio 20 | port: 8080 21 | permissionProfile: 22 | type: builtin 23 | name: network 24 | oidcConfig: 25 | type: configmap 26 | configMap: 27 | name: google-oidc-config 28 | resources: 29 | limits: 30 | cpu: "100m" 31 | memory: "128Mi" 32 | requests: 33 | cpu: "50m" 34 | memory: "64Mi" 35 | -------------------------------------------------------------------------------- /examples/operator/mcp-servers/mcpserver_with_inline_oidc.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: toolhive.stacklok.dev/v1alpha1 2 | kind: MCPServer 3 | metadata: 4 | name: secure-fetch-inline 5 | namespace: toolhive-system 6 | spec: 7 | image: docker.io/mcp/fetch 8 | transport: stdio 9 | port: 8080 10 | permissionProfile: 11 | type: builtin 12 | name: network 13 | oidcConfig: 14 | type: inline 15 | inline: 16 | issuer: "https://my-oidc-provider.com" 17 | audience: "my-audience" 18 | jwksUrl: "https://my-oidc-provider.com/.well-known/jwks.json" 19 | clientId: "my-client-id" 20 | resources: 21 | limits: 22 | cpu: "100m" 23 | memory: "128Mi" 24 | requests: 25 | cpu: "50m" 26 | memory: "64Mi" 27 | -------------------------------------------------------------------------------- /examples/operator/mcp-servers/mcpserver_with_kubernetes_oidc.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: toolhive.stacklok.dev/v1alpha1 2 | kind: MCPServer 3 | metadata: 4 | name: secure-fetch-k8s 5 | namespace: toolhive-system 6 | spec: 7 | image: docker.io/mcp/fetch 8 | transport: stdio 9 | port: 8080 10 | permissionProfile: 11 | type: builtin 12 | name: network 13 | oidcConfig: 14 | type: kubernetes 15 | kubernetes: 16 | serviceAccount: "mcp-client" 17 | namespace: "mcp-clients" 18 | audience: "toolhive" 19 | resources: 20 | limits: 21 | cpu: "100m" 22 | memory: "128Mi" 23 | requests: 24 | cpu: "50m" 25 | memory: "64Mi" 26 | -------------------------------------------------------------------------------- /examples/operator/mcp-servers/mcpserver_with_pod_template.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: toolhive.stacklok.dev/v1alpha1 2 | kind: MCPServer 3 | metadata: 4 | name: sample-with-pod-template 5 | spec: 6 | image: ghcr.io/stackloklabs/mcp-fetch:latest 7 | transport: sse 8 | port: 8080 9 | # Example of using the PodTemplateSpec to customize the pod 10 | podTemplateSpec: 11 | spec: 12 | # Add tolerations to run on nodes with specific taints 13 | tolerations: 14 | - key: "dedicated" 15 | operator: "Equal" 16 | value: "mcp-servers" 17 | effect: "NoSchedule" 18 | # Add node selector to run on specific nodes 19 | nodeSelector: 20 | kubernetes.io/os: linux 21 | node-type: mcp-server 22 | # Add security context for the pod 23 | securityContext: 24 | runAsNonRoot: true 25 | seccompProfile: 26 | type: RuntimeDefault 27 | # Customize the MCP container 28 | containers: 29 | - name: mcp 30 | securityContext: 31 | allowPrivilegeEscalation: false 32 | capabilities: 33 | drop: 34 | - ALL 35 | runAsUser: 1000 36 | resources: 37 | limits: 38 | cpu: "500m" 39 | memory: "512Mi" 40 | requests: 41 | cpu: "100m" 42 | memory: "128Mi" -------------------------------------------------------------------------------- /examples/operator/mcp-servers/mcpserver_with_resource_overrides.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: toolhive.stacklok.dev/v1alpha1 2 | kind: MCPServer 3 | metadata: 4 | name: github-with-overrides 5 | namespace: toolhive-system 6 | spec: 7 | image: docker.io/mcp/github 8 | transport: stdio 9 | port: 8080 10 | permissionProfile: 11 | type: builtin 12 | name: network 13 | secrets: 14 | - name: github-token 15 | key: GITHUB_PERSONAL_ACCESS_TOKEN 16 | env: 17 | - name: GITHUB_API_URL 18 | value: https://api.github.com 19 | - name: LOG_LEVEL 20 | value: info 21 | resources: 22 | limits: 23 | cpu: "200m" 24 | memory: "256Mi" 25 | requests: 26 | cpu: "100m" 27 | memory: "128Mi" 28 | resourceOverrides: 29 | proxyDeployment: 30 | annotations: 31 | example.com/deployment-annotation: "custom-deployment-value" 32 | monitoring.example.com/scrape: "true" 33 | monitoring.example.com/port: "8080" 34 | labels: 35 | example.com/deployment-label: "custom-deployment-label" 36 | environment: "production" 37 | team: "platform" 38 | proxyService: 39 | annotations: 40 | example.com/service-annotation: "custom-service-value" 41 | service.beta.kubernetes.io/aws-load-balancer-type: "nlb" 42 | external-dns.alpha.kubernetes.io/hostname: "github-mcp.example.com" 43 | labels: 44 | example.com/service-label: "custom-service-label" 45 | environment: "production" 46 | team: "platform" -------------------------------------------------------------------------------- /hack/boilerplate.go.txt: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 Stacklok 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ -------------------------------------------------------------------------------- /pkg/api/docs.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/go-chi/chi/v5" 7 | ) 8 | 9 | // DocsRouter creates a new router for documentation endpoints. 10 | func DocsRouter() http.Handler { 11 | r := chi.NewRouter() 12 | r.Get("/openapi.json", ServeOpenAPI) 13 | r.Get("/doc", ServeScalar) 14 | return r 15 | } 16 | -------------------------------------------------------------------------------- /pkg/api/openapi.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/stacklok/toolhive/docs/server" 8 | ) 9 | 10 | // ServeOpenAPI writes the OpenAPI specification as JSON to the response. 11 | // @Summary Get OpenAPI specification 12 | // @Description Returns the OpenAPI specification for the API 13 | // @Tags system 14 | // @Produce json 15 | // @Success 200 {object} object "OpenAPI specification" 16 | // @Router /api/openapi.json [get] 17 | func ServeOpenAPI(w http.ResponseWriter, _ *http.Request) { 18 | w.Header().Set("Content-Type", "application/json") 19 | 20 | // Parse the OpenAPI spec into a proper JSON object 21 | var openAPISpec map[string]interface{} 22 | if err := json.Unmarshal([]byte(server.SwaggerInfo.ReadDoc()), &openAPISpec); err != nil { 23 | http.Error(w, "Failed to parse OpenAPI specification", http.StatusInternalServerError) 24 | return 25 | } 26 | 27 | // Encode the JSON object 28 | if err := json.NewEncoder(w).Encode(openAPISpec); err != nil { 29 | http.Error(w, err.Error(), http.StatusInternalServerError) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /pkg/api/scalar.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | const scalarHTML = ` 8 | 9 | 10 | ToolHive API Reference 11 | 12 | 13 | 14 | 15 | 16 | 59 | 60 | 61 | ` 62 | 63 | // ServeScalar serves the Scalar API reference page 64 | func ServeScalar(w http.ResponseWriter, _ *http.Request) { 65 | w.Header().Set("Content-Type", "text/html") 66 | if _, err := w.Write([]byte(scalarHTML)); err != nil { 67 | http.Error(w, err.Error(), http.StatusInternalServerError) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /pkg/api/v1/discovery.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/go-chi/chi/v5" 8 | 9 | "github.com/stacklok/toolhive/pkg/client" 10 | ) 11 | 12 | // DiscoveryRoutes defines the routes for the client discovery API. 13 | type DiscoveryRoutes struct{} 14 | 15 | // DiscoveryRouter creates a new router for the client discovery API. 16 | func DiscoveryRouter() http.Handler { 17 | routes := DiscoveryRoutes{} 18 | 19 | r := chi.NewRouter() 20 | r.Get("/clients", routes.discoverClients) 21 | return r 22 | } 23 | 24 | // discoverClients 25 | // 26 | // @Summary List all clients status 27 | // @Description List all clients compatible with ToolHive and their status 28 | // @Tags discovery 29 | // @Produce json 30 | // @Success 200 {object} clientStatusResponse 31 | // @Router /api/v1beta/discovery/clients [get] 32 | func (*DiscoveryRoutes) discoverClients(w http.ResponseWriter, _ *http.Request) { 33 | clients, err := client.GetClientStatus() 34 | if err != nil { 35 | http.Error(w, "Failed to get client status", http.StatusInternalServerError) 36 | } 37 | 38 | err = json.NewEncoder(w).Encode(clientStatusResponse{Clients: clients}) 39 | if err != nil { 40 | http.Error(w, "Failed to encode client status", http.StatusInternalServerError) 41 | return 42 | } 43 | } 44 | 45 | // clientStatusResponse represents the response for the client discovery 46 | type clientStatusResponse struct { 47 | Clients []client.MCPClientStatus `json:"clients"` 48 | } 49 | -------------------------------------------------------------------------------- /pkg/api/v1/healtcheck_test.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestGetHealthcheck(t *testing.T) { 12 | t.Parallel() 13 | resp := httptest.NewRecorder() 14 | getHealthcheck(resp, nil) 15 | require.Equal(t, http.StatusNoContent, resp.Code) 16 | require.Empty(t, resp.Body) 17 | } 18 | -------------------------------------------------------------------------------- /pkg/api/v1/healthcheck.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/go-chi/chi/v5" 7 | ) 8 | 9 | // HealthcheckRouter sets up healthcheck route. 10 | func HealthcheckRouter() http.Handler { 11 | r := chi.NewRouter() 12 | r.Get("/", getHealthcheck) 13 | return r 14 | } 15 | 16 | // getHealthcheck 17 | // @Summary Health check 18 | // @Description Check if the API is healthy 19 | // @Tags system 20 | // @Success 204 {string} string "No Content" 21 | // @Router /health [get] 22 | func getHealthcheck(w http.ResponseWriter, _ *http.Request) { 23 | w.WriteHeader(http.StatusNoContent) 24 | } 25 | -------------------------------------------------------------------------------- /pkg/api/v1/version.go: -------------------------------------------------------------------------------- 1 | // Package v1 contains the V1 API for ToolHive. 2 | package v1 3 | 4 | import ( 5 | "encoding/json" 6 | "net/http" 7 | 8 | "github.com/go-chi/chi/v5" 9 | 10 | "github.com/stacklok/toolhive/pkg/versions" 11 | ) 12 | 13 | // VersionRouter sets up the version route. 14 | func VersionRouter() http.Handler { 15 | r := chi.NewRouter() 16 | r.Get("/", getVersion) 17 | return r 18 | } 19 | 20 | type versionResponse struct { 21 | Version string `json:"version"` 22 | } 23 | 24 | // getVersion 25 | // @Summary Get server version 26 | // @Description Returns the current version of the server 27 | // @Tags version 28 | // @Produce json 29 | // @Success 200 {object} versionResponse 30 | // @Router /api/v1beta/version [get] 31 | func getVersion(w http.ResponseWriter, _ *http.Request) { 32 | versionInfo := versions.GetVersionInfo() 33 | err := json.NewEncoder(w).Encode(versionResponse{Version: versionInfo.Version}) 34 | if err != nil { 35 | http.Error(w, "Failed to marshal version info", http.StatusInternalServerError) 36 | return 37 | } 38 | w.Header().Set("Content-Type", "application/json") 39 | } 40 | -------------------------------------------------------------------------------- /pkg/api/v1/version_test.go: -------------------------------------------------------------------------------- 1 | package v1 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestGetVersion(t *testing.T) { 13 | t.Parallel() 14 | resp := httptest.NewRecorder() 15 | getVersion(resp, nil) 16 | require.Equal(t, http.StatusOK, resp.Code) 17 | var version versionResponse 18 | require.NoError(t, json.NewDecoder(resp.Body).Decode(&version)) 19 | require.Contains(t, version.Version, "build-") 20 | } 21 | -------------------------------------------------------------------------------- /pkg/auth/anonymous.go: -------------------------------------------------------------------------------- 1 | // Package auth provides authentication and authorization utilities. 2 | package auth 3 | 4 | import ( 5 | "context" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/golang-jwt/jwt/v5" 10 | ) 11 | 12 | // AnonymousMiddleware creates an HTTP middleware that sets up anonymous claims. 13 | // This is useful for testing and local environments where authorization policies 14 | // need to work without requiring actual authentication. 15 | // 16 | // The middleware sets up basic anonymous claims that can be used by authorization 17 | // policies, allowing them to function even when authentication is disabled. 18 | // This is heavily discouraged in production settings but is handy for testing 19 | // and local development environments. 20 | func AnonymousMiddleware(next http.Handler) http.Handler { 21 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 22 | // Create anonymous claims with basic information 23 | claims := jwt.MapClaims{ 24 | "sub": "anonymous", 25 | "iss": "toolhive-local", 26 | "aud": "toolhive", 27 | "exp": time.Now().Add(24 * time.Hour).Unix(), // Valid for 24 hours 28 | "iat": time.Now().Unix(), 29 | "nbf": time.Now().Unix(), 30 | "email": "anonymous@localhost", 31 | "name": "Anonymous User", 32 | } 33 | 34 | // Add the anonymous claims to the request context using the same key 35 | // as the JWT middleware for consistency 36 | ctx := context.WithValue(r.Context(), ClaimsContextKey{}, claims) 37 | next.ServeHTTP(w, r.WithContext(ctx)) 38 | }) 39 | } 40 | -------------------------------------------------------------------------------- /pkg/auth/anonymous_test.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestAnonymousMiddleware(t *testing.T) { 14 | // Create a test handler that checks for claims in the context 15 | testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 16 | claims, ok := GetClaimsFromContext(r.Context()) 17 | require.True(t, ok, "Expected claims to be present in context") 18 | 19 | // Verify the anonymous claims 20 | assert.Equal(t, "anonymous", claims["sub"]) 21 | assert.Equal(t, "toolhive-local", claims["iss"]) 22 | assert.Equal(t, "toolhive", claims["aud"]) 23 | assert.Equal(t, "anonymous@localhost", claims["email"]) 24 | assert.Equal(t, "Anonymous User", claims["name"]) 25 | 26 | // Verify timestamps are reasonable 27 | now := time.Now().Unix() 28 | exp, ok := claims["exp"].(int64) 29 | require.True(t, ok, "Expected exp to be present and be an int64") 30 | assert.Greater(t, exp, now, "Expected exp to be in the future") 31 | 32 | iat, ok := claims["iat"].(int64) 33 | require.True(t, ok, "Expected iat to be present and be an int64") 34 | assert.LessOrEqual(t, iat, now+1, "Expected iat to be current time or earlier (with 1 second tolerance)") 35 | 36 | w.WriteHeader(http.StatusOK) 37 | w.Write([]byte("OK")) 38 | }) 39 | 40 | // Wrap the test handler with the anonymous middleware 41 | middleware := AnonymousMiddleware(testHandler) 42 | 43 | // Create a test request 44 | req := httptest.NewRequest("GET", "/test", nil) 45 | w := httptest.NewRecorder() 46 | 47 | // Execute the request 48 | middleware.ServeHTTP(w, req) 49 | 50 | // Check the response 51 | assert.Equal(t, http.StatusOK, w.Code) 52 | assert.Equal(t, "OK", w.Body.String()) 53 | } 54 | -------------------------------------------------------------------------------- /pkg/auth/local.go: -------------------------------------------------------------------------------- 1 | // Package auth provides authentication and authorization utilities. 2 | package auth 3 | 4 | import ( 5 | "context" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/golang-jwt/jwt/v5" 10 | ) 11 | 12 | // LocalUserMiddleware creates an HTTP middleware that sets up local user claims. 13 | // This allows specifying a local username while still bypassing authentication. 14 | // 15 | // This middleware is useful for development and testing scenarios where you want 16 | // to simulate a specific user without going through the full authentication flow. 17 | // Like AnonymousMiddleware, this is heavily discouraged in production settings. 18 | func LocalUserMiddleware(username string) func(http.Handler) http.Handler { 19 | return func(next http.Handler) http.Handler { 20 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 21 | // Create local user claims with the specified username 22 | claims := jwt.MapClaims{ 23 | "sub": username, 24 | "iss": "toolhive-local", 25 | "aud": "toolhive", 26 | "exp": time.Now().Add(24 * time.Hour).Unix(), // Valid for 24 hours 27 | "iat": time.Now().Unix(), 28 | "nbf": time.Now().Unix(), 29 | "email": username + "@localhost", 30 | "name": "Local User: " + username, 31 | } 32 | 33 | // Add the local user claims to the request context using the same key 34 | // as the JWT middleware for consistency 35 | ctx := context.WithValue(r.Context(), ClaimsContextKey{}, claims) 36 | next.ServeHTTP(w, r.WithContext(ctx)) 37 | }) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /pkg/auth/local_test.go: -------------------------------------------------------------------------------- 1 | package auth 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestLocalUserMiddleware(t *testing.T) { 14 | username := "testuser" 15 | 16 | // Create a test handler that checks for claims in the context 17 | testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 18 | claims, ok := GetClaimsFromContext(r.Context()) 19 | require.True(t, ok, "Expected claims to be present in context") 20 | 21 | // Verify the local user claims 22 | assert.Equal(t, username, claims["sub"]) 23 | assert.Equal(t, "toolhive-local", claims["iss"]) 24 | assert.Equal(t, "toolhive", claims["aud"]) 25 | assert.Equal(t, username+"@localhost", claims["email"]) 26 | assert.Equal(t, "Local User: "+username, claims["name"]) 27 | 28 | // Verify timestamps are reasonable 29 | now := time.Now().Unix() 30 | exp, ok := claims["exp"].(int64) 31 | require.True(t, ok, "Expected exp to be present and be an int64") 32 | assert.Greater(t, exp, now, "Expected exp to be in the future") 33 | 34 | iat, ok := claims["iat"].(int64) 35 | require.True(t, ok, "Expected iat to be present and be an int64") 36 | assert.LessOrEqual(t, iat, now+1, "Expected iat to be current time or earlier (with 1 second tolerance)") 37 | 38 | w.WriteHeader(http.StatusOK) 39 | w.Write([]byte("OK")) 40 | }) 41 | 42 | // Wrap the test handler with the local user middleware 43 | middleware := LocalUserMiddleware(username)(testHandler) 44 | 45 | // Create a test request 46 | req := httptest.NewRequest("GET", "/test", nil) 47 | w := httptest.NewRecorder() 48 | 49 | // Execute the request 50 | middleware.ServeHTTP(w, req) 51 | 52 | // Check the response 53 | assert.Equal(t, http.StatusOK, w.Code) 54 | assert.Equal(t, "OK", w.Body.String()) 55 | } 56 | 57 | func TestLocalUserMiddlewareWithDifferentUsernames(t *testing.T) { 58 | testCases := []string{"alice", "bob", "admin", "user123"} 59 | 60 | for _, username := range testCases { 61 | t.Run("username_"+username, func(t *testing.T) { 62 | testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 63 | claims, ok := GetClaimsFromContext(r.Context()) 64 | require.True(t, ok, "Expected claims to be present in context") 65 | 66 | assert.Equal(t, username, claims["sub"]) 67 | assert.Equal(t, username+"@localhost", claims["email"]) 68 | 69 | w.WriteHeader(http.StatusOK) 70 | }) 71 | 72 | middleware := LocalUserMiddleware(username)(testHandler) 73 | req := httptest.NewRequest("GET", "/test", nil) 74 | w := httptest.NewRecorder() 75 | 76 | middleware.ServeHTTP(w, req) 77 | 78 | assert.Equal(t, http.StatusOK, w.Code) 79 | }) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /pkg/auth/utils.go: -------------------------------------------------------------------------------- 1 | // Package auth provides authentication and authorization utilities. 2 | package auth 3 | 4 | import ( 5 | "context" 6 | "net/http" 7 | "os/user" 8 | 9 | "github.com/golang-jwt/jwt/v5" 10 | 11 | "github.com/stacklok/toolhive/pkg/logger" 12 | ) 13 | 14 | // GetClaimsFromContext retrieves the claims from the request context. 15 | // This is a helper function that can be used by authorization policies 16 | // to access the claims regardless of which middleware was used (JWT, anonymous, or local). 17 | // 18 | // Returns the claims and a boolean indicating whether claims were found. 19 | func GetClaimsFromContext(ctx context.Context) (jwt.MapClaims, bool) { 20 | if ctx == nil { 21 | return nil, false 22 | } 23 | claims, ok := ctx.Value(ClaimsContextKey{}).(jwt.MapClaims) 24 | return claims, ok 25 | } 26 | 27 | // GetAuthenticationMiddleware returns the appropriate authentication middleware based on the configuration. 28 | // If OIDC config is provided, it returns JWT middleware. Otherwise, it returns local user middleware. 29 | func GetAuthenticationMiddleware(ctx context.Context, oidcConfig *JWTValidatorConfig) (func(http.Handler) http.Handler, error) { 30 | if oidcConfig != nil { 31 | logger.Info("OIDC validation enabled") 32 | 33 | // Create JWT validator 34 | jwtValidator, err := NewJWTValidator(ctx, *oidcConfig) 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | return jwtValidator.Middleware, nil 40 | } 41 | 42 | logger.Info("OIDC validation disabled, using local user authentication") 43 | 44 | // Get current OS user 45 | currentUser, err := user.Current() 46 | if err != nil { 47 | logger.Warnf("Failed to get current user, using 'local' as default: %v", err) 48 | return LocalUserMiddleware("local"), nil 49 | } 50 | 51 | logger.Infof("Using local user authentication for user: %s", currentUser.Username) 52 | return LocalUserMiddleware(currentUser.Username), nil 53 | } 54 | -------------------------------------------------------------------------------- /pkg/certs/validation.go: -------------------------------------------------------------------------------- 1 | // Package certs provides utilities for certificate validation and handling. 2 | package certs 3 | 4 | import ( 5 | "crypto/x509" 6 | "encoding/pem" 7 | "fmt" 8 | 9 | "github.com/stacklok/toolhive/pkg/logger" 10 | ) 11 | 12 | // ValidateCACertificate validates that the provided data contains a valid PEM-encoded certificate 13 | func ValidateCACertificate(certData []byte) error { 14 | // Check if the data contains PEM blocks 15 | block, _ := pem.Decode(certData) 16 | if block == nil { 17 | return fmt.Errorf("no PEM data found in certificate file") 18 | } 19 | 20 | // Check if it's a certificate block 21 | if block.Type != "CERTIFICATE" { 22 | return fmt.Errorf("PEM block is not a certificate (found: %s)", block.Type) 23 | } 24 | 25 | // Parse the certificate to ensure it's valid 26 | cert, err := x509.ParseCertificate(block.Bytes) 27 | if err != nil { 28 | return fmt.Errorf("failed to parse certificate: %w", err) 29 | } 30 | 31 | // Basic validation - check if it's a CA certificate 32 | if !cert.IsCA { 33 | // Log a warning but don't fail - some corporate proxies use non-CA certificates 34 | logger.Warnf("Certificate is not marked as a CA certificate, but proceeding anyway") 35 | } 36 | 37 | return nil 38 | } 39 | -------------------------------------------------------------------------------- /pkg/client/discovery.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "runtime" 8 | 9 | "github.com/stacklok/toolhive/pkg/config" 10 | ) 11 | 12 | // MCPClientStatus represents the status of a supported MCP client 13 | type MCPClientStatus struct { 14 | // ClientType is the type of MCP client 15 | ClientType MCPClient `json:"client_type"` 16 | 17 | // Installed indicates whether the client is installed on the system 18 | Installed bool `json:"installed"` 19 | 20 | // Registered indicates whether the client is registered in the ToolHive configuration 21 | Registered bool `json:"registered"` 22 | } 23 | 24 | // GetClientStatus returns the installation status of all supported MCP clients 25 | func GetClientStatus() ([]MCPClientStatus, error) { 26 | var statuses []MCPClientStatus 27 | 28 | // Get home directory 29 | home, err := os.UserHomeDir() 30 | if err != nil { 31 | return nil, fmt.Errorf("failed to get home directory: %w", err) 32 | } 33 | 34 | // Get app configuration to check for registered clients 35 | appConfig := config.GetConfig() 36 | registeredClients := make(map[string]bool) 37 | 38 | // Create a map of registered clients for quick lookup 39 | for _, client := range appConfig.Clients.RegisteredClients { 40 | registeredClients[client] = true 41 | } 42 | 43 | for _, cfg := range supportedClientIntegrations { 44 | status := MCPClientStatus{ 45 | ClientType: cfg.ClientType, 46 | Installed: false, // start with assuming client is not installed 47 | Registered: registeredClients[string(cfg.ClientType)], 48 | } 49 | 50 | // Determine path to check based on configuration 51 | var pathToCheck string 52 | if len(cfg.RelPath) == 0 { 53 | // If RelPath is empty, look at just the settings file 54 | pathToCheck = filepath.Join(home, cfg.SettingsFile) 55 | } else { 56 | // Otherwise build the directory path using RelPath 57 | pathToCheck = buildConfigDirectoryPath(cfg.RelPath, cfg.PlatformPrefix, []string{home}) 58 | } 59 | 60 | // Check if the path exists 61 | if _, err := os.Stat(pathToCheck); err == nil { 62 | status.Installed = true 63 | } 64 | 65 | statuses = append(statuses, status) 66 | } 67 | 68 | return statuses, nil 69 | } 70 | 71 | func buildConfigDirectoryPath(relPath []string, platformPrefix map[string][]string, path []string) string { 72 | if prefix, ok := platformPrefix[runtime.GOOS]; ok { 73 | path = append(path, prefix...) 74 | } 75 | path = append(path, relPath...) 76 | return filepath.Clean(filepath.Join(path...)) 77 | } 78 | -------------------------------------------------------------------------------- /pkg/client/discovery_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | 11 | "github.com/stacklok/toolhive/pkg/config" 12 | ) 13 | 14 | func TestGetClientStatus(t *testing.T) { 15 | // Setup a temporary home directory for testing 16 | origHome := os.Getenv("HOME") 17 | tempHome, err := os.MkdirTemp("", "toolhive-test-home") 18 | require.NoError(t, err) 19 | defer os.RemoveAll(tempHome) 20 | 21 | t.Setenv("HOME", tempHome) 22 | defer t.Setenv("HOME", origHome) 23 | 24 | // Create mock config with registered clients 25 | mockConfig := &config.Config{ 26 | Clients: config.Clients{ 27 | RegisteredClients: []string{string(ClaudeCode)}, 28 | }, 29 | } 30 | cleanup := MockConfig(t, mockConfig) 31 | defer cleanup() 32 | 33 | // Create a mock Cursor config file 34 | _, err = os.Create(filepath.Join(tempHome, ".cursor")) 35 | require.NoError(t, err) 36 | 37 | // Create a mock ClaudeCode config file 38 | _, err = os.Create(filepath.Join(tempHome, ".claude.json")) 39 | require.NoError(t, err) 40 | 41 | statuses, err := GetClientStatus() 42 | require.NoError(t, err) 43 | require.NotNil(t, statuses) 44 | 45 | // Create a map for easier testing 46 | statusMap := make(map[MCPClient]MCPClientStatus) 47 | for _, status := range statuses { 48 | statusMap[status.ClientType] = status 49 | } 50 | 51 | claudeStatus, exists := statusMap[ClaudeCode] 52 | assert.True(t, exists) 53 | assert.True(t, claudeStatus.Installed) 54 | assert.True(t, claudeStatus.Registered) 55 | 56 | cursorStatus, exists := statusMap[Cursor] 57 | assert.True(t, exists) 58 | assert.True(t, cursorStatus.Installed) 59 | assert.False(t, cursorStatus.Registered) 60 | 61 | vscodeStatus, exists := statusMap[VSCode] 62 | assert.True(t, exists) 63 | assert.False(t, vscodeStatus.Installed) 64 | assert.False(t, vscodeStatus.Registered) 65 | } 66 | -------------------------------------------------------------------------------- /pkg/config/singleton.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "sync" 6 | 7 | "github.com/stacklok/toolhive/pkg/logger" 8 | ) 9 | 10 | // Singleton value - should only be written to by the GetConfig function. 11 | var appConfig *Config 12 | 13 | var lock = &sync.Mutex{} 14 | 15 | // GetConfig is a Singleton that returns the application configuration. 16 | func GetConfig() *Config { 17 | if appConfig == nil { 18 | lock.Lock() 19 | defer lock.Unlock() 20 | if appConfig == nil { 21 | appConfig, err := LoadOrCreateConfig() 22 | if err != nil { 23 | logger.Errorf("error loading configuration: %v", err) 24 | os.Exit(1) 25 | } 26 | 27 | return appConfig 28 | } 29 | } 30 | return appConfig 31 | } 32 | -------------------------------------------------------------------------------- /pkg/container/factory.go: -------------------------------------------------------------------------------- 1 | // Package container provides utilities for managing containers, 2 | // including creating, starting, stopping, and monitoring containers. 3 | package container 4 | 5 | import ( 6 | "context" 7 | "os" 8 | 9 | "github.com/stacklok/toolhive/pkg/container/docker" 10 | "github.com/stacklok/toolhive/pkg/container/kubernetes" 11 | "github.com/stacklok/toolhive/pkg/container/runtime" 12 | ) 13 | 14 | // Factory creates container runtimes 15 | type Factory struct{} 16 | 17 | // NewFactory creates a new container factory 18 | func NewFactory() *Factory { 19 | return &Factory{} 20 | } 21 | 22 | // Create creates a container runtime 23 | func (*Factory) Create(ctx context.Context) (runtime.Runtime, error) { 24 | if !IsKubernetesRuntime() { 25 | client, err := docker.NewClient(ctx) 26 | if err != nil { 27 | return nil, err 28 | } 29 | return client, nil 30 | } 31 | 32 | client, err := kubernetes.NewClient(ctx) 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | return client, nil 38 | } 39 | 40 | // NewMonitor creates a new container monitor 41 | func NewMonitor(rt runtime.Runtime, containerID, containerName string) runtime.Monitor { 42 | return docker.NewMonitor(rt, containerID, containerName) 43 | } 44 | 45 | // IsKubernetesRuntime returns true if the runtime is Kubernetes 46 | // isn't the best way to do this, but for now it's good enough 47 | func IsKubernetesRuntime() bool { 48 | return os.Getenv("KUBERNETES_SERVICE_HOST") != "" 49 | } 50 | -------------------------------------------------------------------------------- /pkg/container/name.go: -------------------------------------------------------------------------------- 1 | package container 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | ) 8 | 9 | // GetOrGenerateContainerName generates a container name if not provided. 10 | // It returns both the container name and the base name. 11 | // If containerName is not empty, it will be used as both the container name and base name. 12 | // If containerName is empty, a name will be generated based on the image. 13 | func GetOrGenerateContainerName(containerName, image string) (string, string) { 14 | var baseName string 15 | 16 | if containerName == "" { 17 | // Generate a container name from the image 18 | baseName = generateContainerBaseName(image) 19 | containerName = appendTimestamp(baseName) 20 | } else { 21 | // If container name is provided, use it as the base name 22 | baseName = containerName 23 | } 24 | 25 | return containerName, baseName 26 | } 27 | 28 | // generateContainerBaseName generates a base name for a container from the image name 29 | func generateContainerBaseName(image string) string { 30 | // Extract the base name from the image, preserving registry namespaces 31 | // Examples: 32 | // - "nginx:latest" -> "nginx" 33 | // - "docker.io/library/nginx:latest" -> "docker.io-library-nginx" 34 | // - "quay.io/stacklok/mcp-server:v1" -> "quay.io-stacklok-mcp-server" 35 | 36 | // First, remove the tag part (everything after the colon) 37 | imageWithoutTag := strings.Split(image, ":")[0] 38 | 39 | // Replace slashes with dashes to preserve namespace structure 40 | namespaceName := strings.ReplaceAll(imageWithoutTag, "/", "-") 41 | 42 | // Sanitize the name (allow alphanumeric, dashes) 43 | var sanitizedName strings.Builder 44 | for _, c := range namespaceName { 45 | if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' { 46 | sanitizedName.WriteRune(c) 47 | } else { 48 | sanitizedName.WriteRune('-') 49 | } 50 | } 51 | 52 | return sanitizedName.String() 53 | } 54 | 55 | // appendTimestamp appends a timestamp to a base name to ensure uniqueness 56 | func appendTimestamp(baseName string) string { 57 | timestamp := time.Now().Unix() 58 | return fmt.Sprintf("%s-%d", baseName, timestamp) 59 | } 60 | -------------------------------------------------------------------------------- /pkg/container/templates/go.tmpl: -------------------------------------------------------------------------------- 1 | FROM golang:1.24-alpine 2 | 3 | {{if .CACertContent}} 4 | # Add custom CA certificate BEFORE any network operations 5 | # This ensures that package managers can verify TLS certificates in corporate networks 6 | COPY ca-cert.crt /tmp/custom-ca.crt 7 | RUN cat /tmp/custom-ca.crt >> /etc/ssl/certs/ca-certificates.crt && \ 8 | rm /tmp/custom-ca.crt 9 | {{end}} 10 | 11 | # Install CA certificates 12 | RUN apk add --no-cache ca-certificates 13 | 14 | # Set working directory 15 | WORKDIR /app 16 | 17 | # Create a non-root user to run the application and set proper permissions 18 | RUN addgroup -S appgroup && \ 19 | adduser -S appuser -G appgroup && \ 20 | mkdir -p /app && \ 21 | chown -R appuser:appgroup /app && \ 22 | mkdir -p /home/appuser/.cache && \ 23 | chown -R appuser:appgroup /home/appuser 24 | 25 | {{if .CACertContent}} 26 | # Properly install the custom CA certificate using standard tools 27 | RUN mkdir -p /usr/local/share/ca-certificates && \ 28 | cp /tmp/custom-ca.crt /usr/local/share/ca-certificates/custom-ca.crt 2>/dev/null || \ 29 | echo "CA cert already added to bundle" && \ 30 | chmod 644 /usr/local/share/ca-certificates/custom-ca.crt 2>/dev/null || true && \ 31 | update-ca-certificates 32 | {{end}} 33 | 34 | # Set environment variables for better performance in containers 35 | ENV CGO_ENABLED=0 \ 36 | GOOS=linux \ 37 | GOARCH=amd64 \ 38 | GO111MODULE=on 39 | 40 | {{if .IsLocalPath}} 41 | # Copy the local source code 42 | COPY . /app/ 43 | 44 | # Change ownership of copied files to appuser 45 | USER root 46 | RUN chown -R appuser:appgroup /app 47 | {{end}} 48 | 49 | # Switch to non-root user 50 | USER appuser 51 | 52 | # Run the MCP server using go 53 | # The entrypoint will be constructed dynamically based on the package and arguments 54 | ENTRYPOINT ["go", "run", "{{.MCPPackage}}"{{range .MCPArgs}}, "{{.}}"{{end}}] -------------------------------------------------------------------------------- /pkg/container/templates/npx.tmpl: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine 2 | 3 | {{if .CACertContent}} 4 | # Add custom CA certificate BEFORE any network operations 5 | # This ensures that package managers can verify TLS certificates in corporate networks 6 | COPY ca-cert.crt /tmp/custom-ca.crt 7 | RUN cat /tmp/custom-ca.crt >> /etc/ssl/certs/ca-certificates.crt && \ 8 | rm /tmp/custom-ca.crt 9 | {{end}} 10 | 11 | # Install git for package installation support 12 | RUN apk add --no-cache git ca-certificates 13 | 14 | # Set working directory 15 | WORKDIR /app 16 | 17 | # Create a non-root user to run the application and set proper permissions 18 | RUN addgroup -S appgroup && \ 19 | adduser -S appuser -G appgroup && \ 20 | mkdir -p /app && \ 21 | chown -R appuser:appgroup /app 22 | 23 | # Configure npm for faster installations in containerized environments 24 | ENV NODE_ENV=production \ 25 | NPM_CONFIG_LOGLEVEL=error \ 26 | NPM_CONFIG_FUND=false \ 27 | NPM_CONFIG_AUDIT=false \ 28 | NPM_CONFIG_UPDATE_NOTIFIER=false \ 29 | NPM_CONFIG_PROGRESS=false 30 | 31 | {{if .CACertContent}} 32 | # Properly install the custom CA certificate using standard tools 33 | RUN mkdir -p /usr/local/share/ca-certificates && \ 34 | cp /tmp/custom-ca.crt /usr/local/share/ca-certificates/custom-ca.crt 2>/dev/null || \ 35 | echo "CA cert already added to bundle" && \ 36 | chmod 644 /usr/local/share/ca-certificates/custom-ca.crt 2>/dev/null || true && \ 37 | update-ca-certificates 38 | {{end}} 39 | 40 | # Run the MCP server using npx 41 | # The entrypoint will be constructed dynamically based on the package and arguments 42 | # Using the form: npx -- [@] [args...] 43 | # The -- separates npx options from the package name and arguments 44 | 45 | # Switch to non-root user 46 | USER appuser 47 | 48 | ENTRYPOINT ["npx", "--yes", "--", "{{.MCPPackage}}"{{range .MCPArgs}}, "{{.}}"{{end}}] -------------------------------------------------------------------------------- /pkg/container/templates/templates.go: -------------------------------------------------------------------------------- 1 | // Package templates provides utilities for generating Dockerfile templates 2 | // based on different transport types (uvx, npx). 3 | package templates 4 | 5 | import ( 6 | "bytes" 7 | "embed" 8 | "fmt" 9 | "text/template" 10 | ) 11 | 12 | //go:embed *.tmpl 13 | var templateFS embed.FS 14 | 15 | // TemplateData represents the data to be passed to the Dockerfile template. 16 | type TemplateData struct { 17 | // MCPPackage is the name of the MCP package to run. 18 | MCPPackage string 19 | // MCPArgs are the arguments to pass to the MCP package. 20 | MCPArgs []string 21 | // CACertContent is the content of the custom CA certificate to include in the image. 22 | CACertContent string 23 | // IsLocalPath indicates if the MCPPackage is a local path that should be copied into the container. 24 | IsLocalPath bool 25 | } 26 | 27 | // TransportType represents the type of transport to use. 28 | type TransportType string 29 | 30 | const ( 31 | // TransportTypeUVX represents the uvx transport. 32 | TransportTypeUVX TransportType = "uvx" 33 | // TransportTypeNPX represents the npx transport. 34 | TransportTypeNPX TransportType = "npx" 35 | // TransportTypeGO represents the go transport. 36 | TransportTypeGO TransportType = "go" 37 | ) 38 | 39 | // GetDockerfileTemplate returns the Dockerfile template for the specified transport type. 40 | func GetDockerfileTemplate(transportType TransportType, data TemplateData) (string, error) { 41 | var templateName string 42 | 43 | // Determine the template name based on the transport type 44 | switch transportType { 45 | case TransportTypeUVX: 46 | templateName = "uvx.tmpl" 47 | case TransportTypeNPX: 48 | templateName = "npx.tmpl" 49 | case TransportTypeGO: 50 | templateName = "go.tmpl" 51 | default: 52 | return "", fmt.Errorf("unsupported transport type: %s", transportType) 53 | } 54 | 55 | // Read the template file 56 | tmplContent, err := templateFS.ReadFile(templateName) 57 | if err != nil { 58 | return "", fmt.Errorf("failed to read template file: %w", err) 59 | } 60 | 61 | // Parse the template 62 | tmpl, err := template.New(templateName).Parse(string(tmplContent)) 63 | if err != nil { 64 | return "", fmt.Errorf("failed to parse template: %w", err) 65 | } 66 | 67 | // Execute the template with the provided data 68 | var buf bytes.Buffer 69 | if err := tmpl.Execute(&buf, data); err != nil { 70 | return "", fmt.Errorf("failed to execute template: %w", err) 71 | } 72 | 73 | return buf.String(), nil 74 | } 75 | 76 | // ParseTransportType parses a string into a transport type. 77 | func ParseTransportType(s string) (TransportType, error) { 78 | switch s { 79 | case "uvx": 80 | return TransportTypeUVX, nil 81 | case "npx": 82 | return TransportTypeNPX, nil 83 | case "go": 84 | return TransportTypeGO, nil 85 | default: 86 | return "", fmt.Errorf("unsupported transport type: %s", s) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /pkg/container/templates/uvx.tmpl: -------------------------------------------------------------------------------- 1 | FROM python:3.12-slim 2 | 3 | {{if .CACertContent}} 4 | # Add custom CA certificate BEFORE any network operations 5 | # This ensures that package managers can verify TLS certificates in corporate networks 6 | COPY ca-cert.crt /tmp/custom-ca.crt 7 | RUN cat /tmp/custom-ca.crt >> /etc/ssl/certs/ca-certificates.crt && \ 8 | rm /tmp/custom-ca.crt 9 | {{end}} 10 | 11 | # Install uv package manager and CA certificates 12 | RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && \ 13 | pip install --no-cache-dir uv && \ 14 | apt-get clean && \ 15 | rm -rf /var/lib/apt/lists/* 16 | 17 | # Set working directory 18 | WORKDIR /app 19 | 20 | # Create a non-root user to run the application and set proper permissions 21 | RUN groupadd -r appgroup && \ 22 | useradd -r -g appgroup -m appuser && \ 23 | mkdir -p /app && \ 24 | chown -R appuser:appgroup /app && \ 25 | mkdir -p /home/appuser/.cache && \ 26 | chown -R appuser:appgroup /home/appuser 27 | 28 | {{if .CACertContent}} 29 | # Properly install the custom CA certificate using standard tools 30 | RUN mkdir -p /usr/local/share/ca-certificates && \ 31 | cp /tmp/custom-ca.crt /usr/local/share/ca-certificates/custom-ca.crt 2>/dev/null || \ 32 | echo "CA cert already added to bundle" && \ 33 | chmod 644 /usr/local/share/ca-certificates/custom-ca.crt 2>/dev/null || true && \ 34 | update-ca-certificates 35 | {{end}} 36 | 37 | # Set environment variables for better performance in containers 38 | ENV PYTHONDONTWRITEBYTECODE=1 \ 39 | PYTHONUNBUFFERED=1 \ 40 | PIP_NO_CACHE_DIR=1 \ 41 | PIP_DISABLE_PIP_VERSION_CHECK=1 \ 42 | UV_SYSTEM_PYTHON=1 43 | 44 | # Switch to non-root user 45 | USER appuser 46 | 47 | # Run the MCP server using uvx (alias for uv tool run) 48 | # The entrypoint will be constructed dynamically based on the package and arguments 49 | ENTRYPOINT ["uvx", "{{.MCPPackage}}"{{range .MCPArgs}}, "{{.}}"{{end}}] -------------------------------------------------------------------------------- /pkg/environment/environment.go: -------------------------------------------------------------------------------- 1 | // Package environment provides utilities for handling environment variables 2 | // and environment-related operations for containers. 3 | package environment 4 | 5 | import ( 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/stacklok/toolhive/pkg/secrets" 10 | ) 11 | 12 | // ParseSecretParameters parses the secret parameters from the command line, 13 | // fetches them from the secrets manager, and returns a map of secrets and 14 | // their environment variable names. 15 | func ParseSecretParameters(parameters []string, secretsManager secrets.Provider) (map[string]string, error) { 16 | secretVariables := make(map[string]string, len(parameters)) 17 | for _, param := range parameters { 18 | parameter, err := secrets.ParseSecretParameter(param) 19 | if err != nil { 20 | return nil, err 21 | } 22 | 23 | secret, err := secretsManager.GetSecret(parameter.Name) 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | secretVariables[parameter.Target] = secret 29 | } 30 | 31 | return secretVariables, nil 32 | } 33 | 34 | // ParseEnvironmentVariables parses environment variables from a slice of strings 35 | // in the format KEY=VALUE 36 | func ParseEnvironmentVariables(envVars []string) (map[string]string, error) { 37 | result := make(map[string]string) 38 | 39 | for _, env := range envVars { 40 | parts := strings.SplitN(env, "=", 2) 41 | if len(parts) != 2 { 42 | return nil, fmt.Errorf("invalid environment variable format: %s", env) 43 | } 44 | 45 | key := parts[0] 46 | value := parts[1] 47 | 48 | if key == "" { 49 | return nil, fmt.Errorf("empty environment variable key") 50 | } 51 | 52 | result[key] = value 53 | } 54 | 55 | return result, nil 56 | } 57 | 58 | // SetTransportEnvironmentVariables sets transport-specific environment variables 59 | func SetTransportEnvironmentVariables(envVars map[string]string, transportType string, port int) { 60 | // Set common environment variables 61 | envVars["MCP_TRANSPORT"] = transportType 62 | 63 | // Set port-related environment variables only if port is greater than 0 64 | if port > 0 { 65 | // Set transport-specific environment variables 66 | switch transportType { 67 | case "sse": 68 | envVars["MCP_PORT"] = fmt.Sprintf("%d", port) 69 | envVars["FASTMCP_PORT"] = fmt.Sprintf("%d", port) 70 | case "stdio": 71 | // No additional environment variables needed for stdio transport 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /pkg/labels/labels.go: -------------------------------------------------------------------------------- 1 | // Package labels provides utilities for managing container labels 2 | // used by the toolhive application. 3 | package labels 4 | 5 | import ( 6 | "fmt" 7 | "strings" 8 | ) 9 | 10 | const ( 11 | // LabelPrefix is the prefix for all ToolHive labels 12 | LabelPrefix = "toolhive" 13 | 14 | // LabelEnabled is the label that indicates a container is managed by ToolHive 15 | LabelEnabled = "toolhive" 16 | 17 | // LabelName is the label that contains the container name 18 | LabelName = "toolhive-name" 19 | 20 | // LabelBaseName is the label that contains the base container name (without timestamp) 21 | LabelBaseName = "toolhive-basename" 22 | 23 | // LabelTransport is the label that contains the transport mode 24 | LabelTransport = "toolhive-transport" 25 | 26 | // LabelPort is the label that contains the port 27 | LabelPort = "toolhive-port" 28 | 29 | // LabelToolType is the label that indicates the type of tool 30 | LabelToolType = "toolhive-tool-type" 31 | ) 32 | 33 | // AddStandardLabels adds standard labels to a container 34 | func AddStandardLabels(labels map[string]string, containerName, containerBaseName, transportType string, port int) { 35 | // Add standard labels 36 | labels[LabelEnabled] = "true" 37 | labels[LabelName] = containerName 38 | labels[LabelBaseName] = containerBaseName 39 | labels[LabelTransport] = transportType 40 | labels[LabelPort] = fmt.Sprintf("%d", port) 41 | 42 | // TODO: In the future, we'll support different tool types beyond just "mcp" 43 | labels[LabelToolType] = "mcp" 44 | } 45 | 46 | // FormatToolHiveFilter formats a filter for ToolHive containers 47 | func FormatToolHiveFilter() string { 48 | return fmt.Sprintf("%s=true", LabelEnabled) 49 | } 50 | 51 | // IsToolHiveContainer checks if a container is managed by ToolHive 52 | func IsToolHiveContainer(labels map[string]string) bool { 53 | value, ok := labels[LabelEnabled] 54 | return ok && strings.ToLower(value) == "true" 55 | } 56 | 57 | // GetContainerName gets the container name from labels 58 | func GetContainerName(labels map[string]string) string { 59 | return labels[LabelName] 60 | } 61 | 62 | // GetContainerBaseName gets the base container name from labels 63 | func GetContainerBaseName(labels map[string]string) string { 64 | return labels[LabelBaseName] 65 | } 66 | 67 | // GetTransportType gets the transport type from labels 68 | func GetTransportType(labels map[string]string) string { 69 | return labels[LabelTransport] 70 | } 71 | 72 | // GetPort gets the port from labels 73 | func GetPort(labels map[string]string) (int, error) { 74 | portStr, ok := labels[LabelPort] 75 | if !ok { 76 | return 0, fmt.Errorf("port label not found") 77 | } 78 | 79 | var port int 80 | if _, err := fmt.Sscanf(portStr, "%d", &port); err != nil { 81 | return 0, fmt.Errorf("invalid port: %s", portStr) 82 | } 83 | 84 | return port, nil 85 | } 86 | 87 | // GetToolType gets the tool type from labels 88 | func GetToolType(labels map[string]string) string { 89 | return labels[LabelToolType] 90 | } 91 | -------------------------------------------------------------------------------- /pkg/lifecycle/sysproc_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package lifecycle 5 | 6 | import ( 7 | "syscall" 8 | ) 9 | 10 | // getSysProcAttr returns the platform-specific SysProcAttr for detaching processes 11 | func getSysProcAttr() *syscall.SysProcAttr { 12 | return &syscall.SysProcAttr{ 13 | Setsid: true, // Create a new session (Unix only) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /pkg/lifecycle/sysproc_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package lifecycle 5 | 6 | import ( 7 | "syscall" 8 | ) 9 | 10 | // getSysProcAttr returns the platform-specific SysProcAttr for detaching processes 11 | func getSysProcAttr() *syscall.SysProcAttr { 12 | return &syscall.SysProcAttr{ 13 | // Windows doesn't have Setsid 14 | // Instead, use CreationFlags with CREATE_NEW_PROCESS_GROUP and DETACHED_PROCESS 15 | CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP | 0x00000008, // 0x00000008 is DETACHED_PROCESS 16 | } 17 | } -------------------------------------------------------------------------------- /pkg/logger/logr.go: -------------------------------------------------------------------------------- 1 | // Package logger provides a logging capability for toolhive for running locally as a CLI and in Kubernetes 2 | package logger 3 | 4 | import ( 5 | "github.com/go-logr/logr" 6 | ) 7 | 8 | // NewLogr returns a logr.Logger which uses the singleton logger. 9 | func NewLogr() logr.Logger { 10 | return logr.New(&toolhiveLogSink{logger: log}) 11 | } 12 | 13 | // toolhiveLogSink adapts our logger to the logr.LogSink interface 14 | type toolhiveLogSink struct { 15 | logger Logger 16 | name string 17 | } 18 | 19 | // Init implements logr.LogSink 20 | func (*toolhiveLogSink) Init(logr.RuntimeInfo) { 21 | // Nothing to do 22 | } 23 | 24 | // Enabled implements logr.LogSink 25 | func (*toolhiveLogSink) Enabled(int) bool { 26 | // Always enable logging 27 | return true 28 | } 29 | 30 | // Info implements logr.LogSink 31 | func (l *toolhiveLogSink) Info(_ int, msg string, keysAndValues ...interface{}) { 32 | l.logger.Info(msg, keysAndValues...) 33 | } 34 | 35 | // Error implements logr.LogSink 36 | func (l *toolhiveLogSink) Error(err error, msg string, keysAndValues ...interface{}) { 37 | args := append([]interface{}{"error", err}, keysAndValues...) 38 | l.logger.Error(msg, args...) 39 | } 40 | 41 | // WithValues implements logr.LogSink 42 | func (l *toolhiveLogSink) WithValues(keysAndValues ...interface{}) logr.LogSink { 43 | // Create a new logger with the additional key-value pairs 44 | if slogger, ok := l.logger.(*slogLogger); ok { 45 | newLogger := &slogLogger{ 46 | logger: slogger.logger.With(keysAndValues...), 47 | } 48 | return &toolhiveLogSink{ 49 | logger: newLogger, 50 | name: l.name, 51 | } 52 | } 53 | 54 | // If we can't add the values, just return a sink with the same logger 55 | return &toolhiveLogSink{ 56 | logger: l.logger, 57 | name: l.name, 58 | } 59 | } 60 | 61 | // WithName implements logr.LogSink 62 | func (l *toolhiveLogSink) WithName(name string) logr.LogSink { 63 | // If we already have a name, append the new name 64 | newName := name 65 | if l.name != "" { 66 | newName = l.name + "/" + name 67 | } 68 | 69 | // Create a new sink with the component logger 70 | return &toolhiveLogSink{ 71 | logger: GetLogger(newName), 72 | name: newName, 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /pkg/networking/http_client.go: -------------------------------------------------------------------------------- 1 | package networking 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net" 7 | "net/http" 8 | "net/url" 9 | "syscall" 10 | "time" 11 | ) 12 | 13 | var privateIPBlocks []*net.IPNet 14 | 15 | // HttpTimeout is the timeout for outgoing HTTP requests 16 | const HttpTimeout = 30 * time.Second 17 | 18 | func init() { 19 | for _, cidr := range []string{ 20 | "127.0.0.0/8", // IPv4 loopback 21 | "10.0.0.0/8", // RFC1918 22 | "172.16.0.0/12", // RFC1918 23 | "192.168.0.0/16", // RFC1918 24 | "169.254.0.0/16", // RFC3927 link-local 25 | "::1/128", // IPv6 loopback 26 | "fe80::/10", // IPv6 link-local 27 | "fc00::/7", // IPv6 unique local addr 28 | } { 29 | _, block, err := net.ParseCIDR(cidr) 30 | if err != nil { 31 | panic(fmt.Errorf("parse error on %q: %v", cidr, err)) 32 | } 33 | privateIPBlocks = append(privateIPBlocks, block) 34 | } 35 | } 36 | 37 | func isPrivateIP(ip net.IP) bool { 38 | if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { 39 | return true 40 | } 41 | for _, block := range privateIPBlocks { 42 | if block.Contains(ip) { 43 | return true 44 | } 45 | } 46 | return false 47 | } 48 | 49 | // Dialer control function for validating addresses prior to connection 50 | func protectedDialerControl(network, address string, _ syscall.RawConn) error { 51 | 52 | fmt.Printf("protectedDialerControl: %s, %s\n", network, address) 53 | 54 | host, _, err := net.SplitHostPort(address) 55 | if err != nil { 56 | return err 57 | } 58 | // Check for a private IP address or loopback 59 | ip := net.ParseIP(host) 60 | if isPrivateIP(ip) { 61 | return errors.New("private IP address not allowed") 62 | } 63 | return nil 64 | } 65 | 66 | // ValidatingTransport is for validating URLs prior to request 67 | type ValidatingTransport struct { 68 | Transport http.RoundTripper 69 | } 70 | 71 | // RoundTrip validates the request URL prior to forwarding 72 | func (t *ValidatingTransport) RoundTrip(req *http.Request) (*http.Response, error) { 73 | // Check for valid URL specification 74 | parsedUrl, err := url.Parse(req.URL.String()) 75 | if err != nil { 76 | fmt.Print(err) 77 | return nil, fmt.Errorf("the supplied URL %s is malformed", req.URL.String()) 78 | } 79 | 80 | // Check for HTTPS scheme 81 | if parsedUrl.Scheme != "https" { 82 | return nil, fmt.Errorf("the supplied URL %s is not HTTPS scheme", req.URL.String()) 83 | } 84 | 85 | return t.Transport.RoundTrip(req) 86 | } 87 | 88 | // GetProtectedHttpClient returns a new http client with a protected dialer and URL validation 89 | func GetProtectedHttpClient() *http.Client { 90 | 91 | protectedTransport := &http.Transport{ 92 | DialContext: (&net.Dialer{ 93 | Control: protectedDialerControl, 94 | }).DialContext, 95 | } 96 | 97 | client := &http.Client{ 98 | Transport: &ValidatingTransport{ 99 | Transport: protectedTransport, 100 | }, 101 | Timeout: HttpTimeout, 102 | } 103 | 104 | return client 105 | } 106 | -------------------------------------------------------------------------------- /pkg/process/detached.go: -------------------------------------------------------------------------------- 1 | package process 2 | 3 | import "os" 4 | 5 | // ToolHiveDetachedEnv is the environment variable used to indicate that the process is running in detached mode. 6 | const ToolHiveDetachedEnv = "TOOLHIVE_DETACHED" 7 | 8 | // ToolHiveDetachedValue is the expected value of ToolHiveDetachedEnv when set. 9 | const ToolHiveDetachedValue = "1" 10 | 11 | // IsDetached checks if the process is running in detached mode. 12 | func IsDetached() bool { 13 | return os.Getenv(ToolHiveDetachedEnv) == ToolHiveDetachedValue 14 | } 15 | -------------------------------------------------------------------------------- /pkg/process/find_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package process 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | "syscall" 10 | ) 11 | 12 | // FindProcess finds a process by its ID and checks if it's running. 13 | // This function works on Unix systems (Linux and macOS). 14 | func FindProcess(pid int) (bool, error) { 15 | // On Unix systems, os.FindProcess always succeeds regardless of whether 16 | // the process exists or not. We need to send a signal to check if it's running. 17 | proc, err := os.FindProcess(pid) 18 | if err != nil { 19 | return false, fmt.Errorf("failed to find process: %w", err) 20 | } 21 | 22 | // Send signal 0 to check if the process exists 23 | // Signal 0 doesn't actually send a signal, but it checks if the process exists 24 | err = proc.Signal(syscall.Signal(0)) 25 | 26 | // If there's no error, the process exists 27 | if err == nil { 28 | return true, nil 29 | } 30 | 31 | // If the error is "no such process", the process doesn't exist 32 | if err.Error() == "no such process" || err.Error() == "os: process already finished" { 33 | return false, nil 34 | } 35 | 36 | // For other errors (e.g., permission denied), return the error 37 | return false, fmt.Errorf("error checking process: %w", err) 38 | } 39 | -------------------------------------------------------------------------------- /pkg/process/find_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package process 5 | 6 | import ( 7 | "fmt" 8 | "syscall" 9 | "unsafe" 10 | ) 11 | 12 | // Windows API constants 13 | const ( 14 | processQueryInformation = 0x0400 15 | stillActive = 259 16 | ) 17 | 18 | // Windows API functions 19 | var ( 20 | kernel32 = syscall.NewLazyDLL("kernel32.dll") 21 | openProcess = kernel32.NewProc("OpenProcess") 22 | getExitCodeProcess = kernel32.NewProc("GetExitCodeProcess") 23 | closeHandle = kernel32.NewProc("CloseHandle") 24 | ) 25 | 26 | // FindProcess finds a process by its ID and checks if it's running. 27 | // This function works on Windows. 28 | func FindProcess(pid int) (bool, error) { 29 | // On Windows, we need to use Windows API to check if a process is running 30 | 31 | // Open the process with PROCESS_QUERY_INFORMATION access right 32 | handle, _, err := openProcess.Call( 33 | uintptr(processQueryInformation), 34 | uintptr(0), 35 | uintptr(pid), 36 | ) 37 | 38 | if handle == 0 { 39 | // Process doesn't exist or cannot be opened 40 | return false, nil 41 | } 42 | 43 | // Don't forget to close the handle when we're done 44 | defer closeHandle.Call(handle) 45 | 46 | // Check if the process is still running by getting its exit code 47 | var exitCode uint32 48 | ret, _, err := getExitCodeProcess.Call( 49 | handle, 50 | uintptr(unsafe.Pointer(&exitCode)), 51 | ) 52 | 53 | if ret == 0 { 54 | // Failed to get exit code 55 | return false, fmt.Errorf("failed to get process exit code: %w", err) 56 | } 57 | 58 | // If the exit code is STILL_ACTIVE, the process is running 59 | return exitCode == stillActive, nil 60 | } 61 | -------------------------------------------------------------------------------- /pkg/process/kill_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package process 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | "syscall" 10 | ) 11 | 12 | // KillProcess kills a process by its ID 13 | func KillProcess(pid int) error { 14 | // Check if the process exists 15 | process, err := os.FindProcess(pid) 16 | if err != nil { 17 | return fmt.Errorf("failed to find process: %w", err) 18 | } 19 | 20 | // Send a SIGTERM signal to the process 21 | if err := process.Signal(syscall.SIGTERM); err != nil { 22 | return fmt.Errorf("failed to send SIGTERM to process: %w", err) 23 | } 24 | 25 | return nil 26 | } 27 | -------------------------------------------------------------------------------- /pkg/process/kill_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package process 5 | 6 | import ( 7 | "fmt" 8 | "os" 9 | ) 10 | 11 | // KillProcess kills a process by its ID on Windows 12 | func KillProcess(pid int) error { 13 | // Check if the process exists 14 | process, err := os.FindProcess(pid) 15 | if err != nil { 16 | return fmt.Errorf("failed to find process: %w", err) 17 | } 18 | 19 | // On Windows, os.Process.Kill() calls TerminateProcess with exit code 1 20 | if err := process.Kill(); err != nil { 21 | return fmt.Errorf("failed to terminate process: %w", err) 22 | } 23 | 24 | return nil 25 | } 26 | -------------------------------------------------------------------------------- /pkg/process/pid.go: -------------------------------------------------------------------------------- 1 | // Package process provides utilities for managing process-related operations, 2 | // such as PID file handling and process management. 3 | package process 4 | 5 | import ( 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | "strconv" 10 | "strings" 11 | ) 12 | 13 | // GetPIDFilePath returns the path to the PID file for a container 14 | func GetPIDFilePath(containerBaseName string) string { 15 | // Use the system temporary directory 16 | tmpDir := os.TempDir() 17 | return filepath.Join(tmpDir, fmt.Sprintf("toolhive-%s.pid", containerBaseName)) 18 | } 19 | 20 | // WritePIDFile writes a process ID to a file 21 | func WritePIDFile(containerBaseName string, pid int) error { 22 | // Get the PID file path 23 | pidFilePath := GetPIDFilePath(containerBaseName) 24 | 25 | // Write the PID to the file 26 | return os.WriteFile(pidFilePath, []byte(fmt.Sprintf("%d", pid)), 0600) 27 | } 28 | 29 | // WriteCurrentPIDFile writes the current process ID to a file 30 | func WriteCurrentPIDFile(containerBaseName string) error { 31 | return WritePIDFile(containerBaseName, os.Getpid()) 32 | } 33 | 34 | // ReadPIDFile reads the process ID from a file 35 | func ReadPIDFile(containerBaseName string) (int, error) { 36 | // Get the PID file path 37 | pidFilePath := GetPIDFilePath(containerBaseName) 38 | 39 | // Read the PID from the file 40 | // #nosec G304 - This is safe as the path is constructed from a known prefix and container name 41 | pidBytes, err := os.ReadFile(pidFilePath) 42 | if err != nil { 43 | return 0, fmt.Errorf("failed to read PID file: %w", err) 44 | } 45 | 46 | // Parse the PID 47 | pidStr := strings.TrimSpace(string(pidBytes)) 48 | pid, err := strconv.Atoi(pidStr) 49 | if err != nil { 50 | return 0, fmt.Errorf("failed to parse PID: %w", err) 51 | } 52 | 53 | return pid, nil 54 | } 55 | 56 | // RemovePIDFile removes the PID file 57 | func RemovePIDFile(containerBaseName string) error { 58 | // Get the PID file path 59 | pidFilePath := GetPIDFilePath(containerBaseName) 60 | 61 | // Remove the file 62 | return os.Remove(pidFilePath) 63 | } 64 | -------------------------------------------------------------------------------- /pkg/registry/registry_test.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestGetRegistry(t *testing.T) { 8 | reg, err := GetRegistry() 9 | if err != nil { 10 | t.Fatalf("Failed to get registry: %v", err) 11 | } 12 | 13 | if reg == nil { 14 | t.Fatal("Registry is nil") 15 | return 16 | } 17 | 18 | if reg.Version == "" { 19 | t.Error("Registry version is empty") 20 | } 21 | 22 | if reg.LastUpdated == "" { 23 | t.Error("Registry last updated is empty") 24 | } 25 | 26 | if len(reg.Servers) == 0 { 27 | t.Error("Registry has no servers") 28 | } 29 | } 30 | 31 | func TestGetServer(t *testing.T) { 32 | // Test getting an existing server 33 | server, err := GetServer("brave-search") 34 | if err != nil { 35 | t.Fatalf("Failed to get server: %v", err) 36 | } 37 | 38 | if server == nil { 39 | t.Fatal("Server is nil") 40 | return 41 | } 42 | 43 | if server.Image == "" { 44 | t.Error("Server image is empty") 45 | } 46 | 47 | if server.Description == "" { 48 | t.Error("Server description is empty") 49 | } 50 | 51 | // Test getting a non-existent server 52 | _, err = GetServer("non-existent-server") 53 | if err == nil { 54 | t.Error("Expected error when getting non-existent server") 55 | } 56 | } 57 | 58 | func TestSearchServers(t *testing.T) { 59 | // Test searching for servers 60 | servers, err := SearchServers("search") 61 | if err != nil { 62 | t.Fatalf("Failed to search servers: %v", err) 63 | } 64 | 65 | if len(servers) == 0 { 66 | t.Error("No servers found for search query") 67 | } 68 | 69 | // Test searching for non-existent servers 70 | servers, err = SearchServers("non-existent-server") 71 | if err != nil { 72 | t.Fatalf("Failed to search servers: %v", err) 73 | } 74 | 75 | if len(servers) > 0 { 76 | t.Errorf("Expected no servers for non-existent query, got %d", len(servers)) 77 | } 78 | } 79 | 80 | func TestListServers(t *testing.T) { 81 | servers, err := ListServers() 82 | if err != nil { 83 | t.Fatalf("Failed to list servers: %v", err) 84 | } 85 | 86 | if len(servers) == 0 { 87 | t.Error("No servers found") 88 | } 89 | 90 | // Verify that we get the same number of servers as in the registry 91 | reg, err := GetRegistry() 92 | if err != nil { 93 | t.Fatalf("Failed to get registry: %v", err) 94 | } 95 | 96 | if len(servers) != len(reg.Servers) { 97 | t.Errorf("Expected %d servers, got %d", len(reg.Servers), len(servers)) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /pkg/secrets/1password.go: -------------------------------------------------------------------------------- 1 | package secrets 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "strings" 8 | "time" 9 | 10 | "github.com/1password/onepassword-sdk-go" 11 | ) 12 | 13 | //go:generate mockgen -destination=mocks/mock_onepassword.go -package=mocks -source=1password.go OPSecretsService 14 | 15 | // Err1PasswordReadOnly indicates that the 1Password secrets manager is read-only. 16 | // Is it returned by operations which attempt to change values in 1Password. 17 | var Err1PasswordReadOnly = fmt.Errorf("1Password secrets manager is read-only, write operations are not supported") 18 | 19 | // OPSecretsService defines the interface for the 1Password Secrets service 20 | type OPSecretsService interface { 21 | Resolve(ctx context.Context, secretReference string) (string, error) 22 | } 23 | 24 | // OnePasswordManager manages secrets in 1Password. 25 | type OnePasswordManager struct { 26 | secretsService OPSecretsService 27 | } 28 | 29 | var timeout = 5 * time.Second 30 | 31 | // GetSecret retrieves a secret from 1Password. 32 | func (opm *OnePasswordManager) GetSecret(path string) (string, error) { 33 | if !strings.Contains(path, "op://") { 34 | return "", fmt.Errorf("invalid secret path: %s", path) 35 | } 36 | 37 | ctx, cancel := context.WithTimeout(context.Background(), timeout) 38 | defer cancel() 39 | 40 | secret, err := opm.secretsService.Resolve(ctx, path) 41 | if err != nil { 42 | return "", fmt.Errorf("error resolving secret: %v", err) 43 | } 44 | 45 | return secret, nil 46 | } 47 | 48 | // SetSecret is not supported for 1Password unless there is 49 | // demand for it. 50 | func (*OnePasswordManager) SetSecret(_, _ string) error { 51 | return Err1PasswordReadOnly 52 | } 53 | 54 | // DeleteSecret is not supported for 1Password unless there is 55 | // demand for it. 56 | func (*OnePasswordManager) DeleteSecret(_ string) error { 57 | return Err1PasswordReadOnly 58 | } 59 | 60 | // ListSecrets is not supported for 1Password unless there is 61 | // demand for it. 62 | func (*OnePasswordManager) ListSecrets() ([]string, error) { 63 | return nil, nil 64 | } 65 | 66 | // Cleanup is not needed for 1Password. 67 | func (*OnePasswordManager) Cleanup() error { 68 | return nil 69 | } 70 | 71 | // NewOnePasswordManager creates an instance of OnePasswordManager. 72 | func NewOnePasswordManager() (Provider, error) { 73 | token := os.Getenv("OP_SERVICE_ACCOUNT_TOKEN") 74 | if token == "" { 75 | return nil, fmt.Errorf("OP_SERVICE_ACCOUNT_TOKEN is not set") 76 | } 77 | 78 | ctx, cancel := context.WithTimeout(context.Background(), timeout) 79 | defer cancel() 80 | 81 | client, err := onepassword.NewClient( 82 | ctx, 83 | onepassword.WithServiceAccountToken(token), 84 | onepassword.WithIntegrationInfo(onepassword.DefaultIntegrationName, onepassword.DefaultIntegrationVersion), 85 | ) 86 | if err != nil { 87 | return nil, fmt.Errorf("error creating 1Password client: %v", err) 88 | } 89 | 90 | return &OnePasswordManager{ 91 | secretsService: client.Secrets(), 92 | }, nil 93 | } 94 | 95 | // NewOnePasswordManagerWithService creates an instance of OnePasswordManager with a provided secrets service. 96 | // This function is primarily intended for testing purposes. 97 | func NewOnePasswordManagerWithService(secretsService OPSecretsService) *OnePasswordManager { 98 | return &OnePasswordManager{ 99 | secretsService: secretsService, 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /pkg/secrets/aes/aes.go: -------------------------------------------------------------------------------- 1 | // Package aes contains functions for encrypting and decrypting data using AES-GCM 2 | package aes 3 | 4 | import ( 5 | "crypto/aes" 6 | "crypto/cipher" 7 | "crypto/rand" 8 | "errors" 9 | "io" 10 | ) 11 | 12 | const maxPlaintextSize = 32 * 1024 * 1024 13 | 14 | // ErrExceedsMaxSize is returned when the plaintext is too large to encrypt. 15 | var ErrExceedsMaxSize = errors.New("plaintext is too large, limited to 32MiB") 16 | 17 | // Encrypt encrypts data using 256-bit AES-GCM. This both hides the content of 18 | // the data and provides a check that it hasn't been altered. Output takes the 19 | // form nonce|ciphertext|tag where '|' indicates concatenation. 20 | func Encrypt(plaintext []byte, key []byte) ([]byte, error) { 21 | if len(plaintext) > maxPlaintextSize { 22 | return nil, ErrExceedsMaxSize 23 | } 24 | 25 | block, err := aes.NewCipher(key[:]) 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | gcm, err := cipher.NewGCM(block) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | nonce := make([]byte, gcm.NonceSize()) 36 | _, err = io.ReadFull(rand.Reader, nonce) 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | return gcm.Seal(nonce, nonce, plaintext, nil), nil 42 | } 43 | 44 | // Decrypt decrypts data using 256-bit AES-GCM. This both hides the content of 45 | // the data and provides a check that it hasn't been altered. Expects input 46 | // form nonce|ciphertext|tag where '|' indicates concatenation. 47 | func Decrypt(ciphertext []byte, key []byte) ([]byte, error) { 48 | block, err := aes.NewCipher(key[:]) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | gcm, err := cipher.NewGCM(block) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | if len(ciphertext) < gcm.NonceSize() { 59 | return nil, errors.New("malformed ciphertext") 60 | } 61 | 62 | return gcm.Open(nil, 63 | ciphertext[:gcm.NonceSize()], 64 | ciphertext[gcm.NonceSize():], 65 | nil, 66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /pkg/secrets/aes/aes_test.go: -------------------------------------------------------------------------------- 1 | package aes_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | 8 | "github.com/stacklok/toolhive/pkg/secrets/aes" 9 | ) 10 | 11 | func TestGCMEncrypt(t *testing.T) { 12 | t.Parallel() 13 | 14 | scenarios := []struct { 15 | Name string 16 | Key []byte 17 | Plaintext []byte 18 | ExpectedError string 19 | }{ 20 | { 21 | Name: "GCM Encrypt rejects short key", 22 | Key: []byte{0x41, 0x42, 0x43, 0x44}, 23 | Plaintext: []byte(plaintext), 24 | ExpectedError: "invalid key size", 25 | }, 26 | { 27 | Name: "GCM Encrypt rejects oversized plaintext", 28 | Key: key, 29 | Plaintext: make([]byte, 33*1024*1024), // 33MiB 30 | ExpectedError: aes.ErrExceedsMaxSize.Error(), 31 | }, 32 | { 33 | Name: "GCM encrypts plaintext", 34 | Key: key, 35 | Plaintext: []byte(plaintext), 36 | }, 37 | } 38 | 39 | for _, scenario := range scenarios { 40 | t.Run(scenario.Name, func(t *testing.T) { 41 | t.Parallel() 42 | 43 | result, err := aes.Encrypt(scenario.Plaintext, scenario.Key) 44 | if scenario.ExpectedError == "" { 45 | require.NoError(t, err) 46 | // validate by decrypting 47 | decrypted, err := aes.Decrypt(result, key) 48 | require.NoError(t, err) 49 | require.Equal(t, scenario.Plaintext, decrypted) 50 | } else { 51 | require.ErrorContains(t, err, scenario.ExpectedError) 52 | } 53 | }) 54 | } 55 | } 56 | 57 | // This doesn't test decryption - that is tested in the happy path of the encrypt test 58 | func TestGCMDecrypt(t *testing.T) { 59 | t.Parallel() 60 | 61 | scenarios := []struct { 62 | Name string 63 | Key []byte 64 | Ciphertext []byte 65 | ExpectedError string 66 | }{ 67 | { 68 | Name: "GCM Decrypt rejects short key", 69 | Key: []byte{0xa}, 70 | Ciphertext: []byte(plaintext), 71 | ExpectedError: "invalid key size", 72 | }, 73 | { 74 | Name: "GCM Decrypt rejects malformed ciphertext", 75 | Key: key, 76 | Ciphertext: make([]byte, 32), // 33MiB 77 | ExpectedError: "message authentication failed", 78 | }, 79 | { 80 | Name: "GCM Decrypt rejects undersized ciphertext", 81 | Key: key, 82 | Ciphertext: []byte{0xFF}, 83 | ExpectedError: "malformed ciphertext", 84 | }, 85 | } 86 | 87 | for _, scenario := range scenarios { 88 | t.Run(scenario.Name, func(t *testing.T) { 89 | t.Parallel() 90 | 91 | _, err := aes.Decrypt(scenario.Ciphertext, scenario.Key) 92 | require.ErrorContains(t, err, scenario.ExpectedError) 93 | }) 94 | } 95 | } 96 | 97 | var key = []byte{0x7a, 0x91, 0xc8, 0x36, 0x47, 0xdf, 0xe2, 0x0b, 0x3d, 0x8c, 0x57, 0xf8, 0x15, 0xae, 0x69, 0x02, 0xc4, 98 | 0x5f, 0xba, 0x83, 0x1e, 0x70, 0x96, 0xd1, 0x4c, 0x25, 0xa7, 0xf3, 0x6d, 0x08, 0xe9, 0xb4} 99 | 100 | const plaintext = "Hello world" 101 | -------------------------------------------------------------------------------- /pkg/secrets/mocks/mock_onepassword.go: -------------------------------------------------------------------------------- 1 | // Code generated by MockGen. DO NOT EDIT. 2 | // Source: 1password.go 3 | // 4 | // Generated by this command: 5 | // 6 | // mockgen -destination=mocks/mock_onepassword.go -package=mocks -source=1password.go OPSecretsService 7 | // 8 | 9 | // Package mocks is a generated GoMock package. 10 | package mocks 11 | 12 | import ( 13 | context "context" 14 | reflect "reflect" 15 | 16 | gomock "go.uber.org/mock/gomock" 17 | ) 18 | 19 | // MockOPSecretsService is a mock of OPSecretsService interface. 20 | type MockOPSecretsService struct { 21 | ctrl *gomock.Controller 22 | recorder *MockOPSecretsServiceMockRecorder 23 | isgomock struct{} 24 | } 25 | 26 | // MockOPSecretsServiceMockRecorder is the mock recorder for MockOPSecretsService. 27 | type MockOPSecretsServiceMockRecorder struct { 28 | mock *MockOPSecretsService 29 | } 30 | 31 | // NewMockOPSecretsService creates a new mock instance. 32 | func NewMockOPSecretsService(ctrl *gomock.Controller) *MockOPSecretsService { 33 | mock := &MockOPSecretsService{ctrl: ctrl} 34 | mock.recorder = &MockOPSecretsServiceMockRecorder{mock} 35 | return mock 36 | } 37 | 38 | // EXPECT returns an object that allows the caller to indicate expected use. 39 | func (m *MockOPSecretsService) EXPECT() *MockOPSecretsServiceMockRecorder { 40 | return m.recorder 41 | } 42 | 43 | // Resolve mocks base method. 44 | func (m *MockOPSecretsService) Resolve(ctx context.Context, secretReference string) (string, error) { 45 | m.ctrl.T.Helper() 46 | ret := m.ctrl.Call(m, "Resolve", ctx, secretReference) 47 | ret0, _ := ret[0].(string) 48 | ret1, _ := ret[1].(error) 49 | return ret0, ret1 50 | } 51 | 52 | // Resolve indicates an expected call of Resolve. 53 | func (mr *MockOPSecretsServiceMockRecorder) Resolve(ctx, secretReference any) *gomock.Call { 54 | mr.mock.ctrl.T.Helper() 55 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Resolve", reflect.TypeOf((*MockOPSecretsService)(nil).Resolve), ctx, secretReference) 56 | } 57 | -------------------------------------------------------------------------------- /pkg/secrets/types.go: -------------------------------------------------------------------------------- 1 | // Package secrets contains the secrets management logic for ToolHive. 2 | package secrets 3 | 4 | import ( 5 | "fmt" 6 | "regexp" 7 | ) 8 | 9 | // regex to extract name and target from secret parameter, e.g. "name,target=target" 10 | var secretParamRegex = regexp.MustCompile(`^([^,]+),target=(.+)$`) 11 | 12 | // Provider describes a type which can manage secrets. 13 | type Provider interface { 14 | GetSecret(name string) (string, error) 15 | SetSecret(name, value string) error 16 | DeleteSecret(name string) error 17 | ListSecrets() ([]string, error) 18 | Cleanup() error 19 | } 20 | 21 | // SecretParameter represents a parsed `--secret` parameter. 22 | type SecretParameter struct { 23 | Name string `json:"name"` 24 | Target string `json:"target"` 25 | } 26 | 27 | // ParseSecretParameter creates an instance of SecretParameter from a string. 28 | // Expected format: `,target=`. 29 | func ParseSecretParameter(parameter string) (SecretParameter, error) { 30 | if parameter == "" { 31 | return SecretParameter{}, fmt.Errorf("secret parameter cannot be empty") 32 | } 33 | 34 | // extract name and target using secretParamRegex 35 | matches := secretParamRegex.FindStringSubmatch(parameter) 36 | if len(matches) != 3 { // The first element is the full match, followed by capture groups 37 | return SecretParameter{}, fmt.Errorf("invalid secret parameter format: %s", parameter) 38 | } 39 | 40 | name := matches[1] 41 | target := matches[2] 42 | 43 | return SecretParameter{ 44 | Name: name, 45 | Target: target, 46 | }, nil 47 | } 48 | 49 | // SecretParametersToCLI does the reverse of `ParseSecretParameter` 50 | // TODO: It may be possible to get rid of this with refactoring. 51 | func SecretParametersToCLI(params []SecretParameter) []string { 52 | result := make([]string, len(params)) 53 | for i, p := range params { 54 | result[i] = fmt.Sprintf("%s,target=%s", p.Name, p.Target) 55 | } 56 | return result 57 | } 58 | -------------------------------------------------------------------------------- /pkg/state/factory.go: -------------------------------------------------------------------------------- 1 | package state 2 | 3 | const ( 4 | // RunConfigsDir is the directory name for storing run configurations 5 | RunConfigsDir = "runconfigs" 6 | 7 | // GroupConfigsDir is the directory name for storing group configurations 8 | GroupConfigsDir = "groups" 9 | ) 10 | 11 | // NewRunConfigStore creates a store for run configuration state 12 | func NewRunConfigStore(appName string) (Store, error) { 13 | return NewLocalStore(appName, RunConfigsDir) 14 | } 15 | 16 | // NewGroupConfigStore creates a store for group configurations 17 | func NewGroupConfigStore(appName string) (Store, error) { 18 | return NewLocalStore(appName, GroupConfigsDir) 19 | } 20 | -------------------------------------------------------------------------------- /pkg/state/interface.go: -------------------------------------------------------------------------------- 1 | // Package state provides functionality for storing and retrieving runner state 2 | // across different environments (local filesystem, Kubernetes, etc.) 3 | package state 4 | 5 | import ( 6 | "context" 7 | "io" 8 | ) 9 | 10 | // Store defines the interface for runner state storage operations 11 | type Store interface { 12 | // Save stores the data for the given name from the provided reader 13 | Save(ctx context.Context, name string, r io.Reader) error 14 | 15 | // Load retrieves the data for the given name and writes it to the provided writer 16 | // Returns an error if the state doesn't exist 17 | Load(ctx context.Context, name string, w io.Writer) error 18 | 19 | // GetReader returns a reader for the state data 20 | // This is useful for streaming large state data 21 | GetReader(ctx context.Context, name string) (io.ReadCloser, error) 22 | 23 | // GetWriter returns a writer for the state data 24 | // This is useful for streaming large state data 25 | GetWriter(ctx context.Context, name string) (io.WriteCloser, error) 26 | 27 | // Delete removes the data for the given name 28 | Delete(ctx context.Context, name string) error 29 | 30 | // List returns all available state names 31 | List(ctx context.Context) ([]string, error) 32 | 33 | // Exists checks if data exists for the given name 34 | Exists(ctx context.Context, name string) (bool, error) 35 | } 36 | -------------------------------------------------------------------------------- /pkg/transport/errors.go: -------------------------------------------------------------------------------- 1 | // Package transport provides utilities for handling different transport modes 2 | // for communication between the client and MCP server. 3 | package transport 4 | 5 | import ( 6 | "errors" 7 | "fmt" 8 | ) 9 | 10 | // Common transport errors 11 | var ( 12 | ErrUnsupportedTransport = errors.New("unsupported transport type") 13 | ErrTransportNotStarted = errors.New("transport not started") 14 | ErrTransportClosed = errors.New("transport closed") 15 | ErrInvalidMessage = errors.New("invalid message") 16 | ErrRuntimeNotSet = errors.New("container runtime not set") 17 | ErrContainerIDNotSet = errors.New("container ID not set") 18 | ErrContainerNameNotSet = errors.New("container name not set") 19 | ) 20 | 21 | // TransportError represents an error related to transport operations 22 | // 23 | //nolint:revive // Intentionally named TransportError despite package name 24 | type TransportError struct { 25 | // Err is the underlying error 26 | Err error 27 | // ContainerID is the ID of the container 28 | ContainerID string 29 | // Message is an optional error message 30 | Message string 31 | } 32 | 33 | // Error returns the error message 34 | func (e *TransportError) Error() string { 35 | if e.Message != "" { 36 | if e.ContainerID != "" { 37 | return fmt.Sprintf("%s: %s (container: %s)", e.Err, e.Message, e.ContainerID) 38 | } 39 | return fmt.Sprintf("%s: %s", e.Err, e.Message) 40 | } 41 | 42 | if e.ContainerID != "" { 43 | return fmt.Sprintf("%s (container: %s)", e.Err, e.ContainerID) 44 | } 45 | 46 | return e.Err.Error() 47 | } 48 | 49 | // Unwrap returns the underlying error 50 | func (e *TransportError) Unwrap() error { 51 | return e.Err 52 | } 53 | 54 | // NewTransportError creates a new transport error 55 | func NewTransportError(err error, containerID, message string) *TransportError { 56 | return &TransportError{ 57 | Err: err, 58 | ContainerID: containerID, 59 | Message: message, 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /pkg/transport/errors/errors.go: -------------------------------------------------------------------------------- 1 | // Package errors provides error types and constants for the transport package. 2 | package errors 3 | 4 | import ( 5 | "errors" 6 | "fmt" 7 | ) 8 | 9 | // Common transport errors 10 | var ( 11 | ErrUnsupportedTransport = errors.New("unsupported transport type") 12 | ErrTransportNotStarted = errors.New("transport not started") 13 | ErrTransportClosed = errors.New("transport closed") 14 | ErrInvalidMessage = errors.New("invalid message") 15 | ErrRuntimeNotSet = errors.New("container runtime not set") 16 | ErrContainerIDNotSet = errors.New("container ID not set") 17 | ErrContainerNameNotSet = errors.New("container name not set") 18 | ) 19 | 20 | // TransportError represents an error related to transport operations 21 | type TransportError struct { 22 | // Err is the underlying error 23 | Err error 24 | // ContainerID is the ID of the container 25 | ContainerID string 26 | // Message is an optional error message 27 | Message string 28 | } 29 | 30 | // Error returns the error message 31 | func (e *TransportError) Error() string { 32 | if e.Message != "" { 33 | if e.ContainerID != "" { 34 | return fmt.Sprintf("%s: %s (container: %s)", e.Err, e.Message, e.ContainerID) 35 | } 36 | return fmt.Sprintf("%s: %s", e.Err, e.Message) 37 | } 38 | 39 | if e.ContainerID != "" { 40 | return fmt.Sprintf("%s (container: %s)", e.Err, e.ContainerID) 41 | } 42 | 43 | return e.Err.Error() 44 | } 45 | 46 | // Unwrap returns the underlying error 47 | func (e *TransportError) Unwrap() error { 48 | return e.Err 49 | } 50 | 51 | // NewTransportError creates a new transport error 52 | func NewTransportError(err error, containerID, message string) *TransportError { 53 | return &TransportError{ 54 | Err: err, 55 | ContainerID: containerID, 56 | Message: message, 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /pkg/transport/factory.go: -------------------------------------------------------------------------------- 1 | // Package transport provides utilities for handling different transport modes 2 | // for communication between the client and MCP server. 3 | package transport 4 | 5 | import ( 6 | "github.com/stacklok/toolhive/pkg/transport/errors" 7 | "github.com/stacklok/toolhive/pkg/transport/types" 8 | ) 9 | 10 | // Factory creates transports 11 | type Factory struct{} 12 | 13 | // NewFactory creates a new transport factory 14 | func NewFactory() *Factory { 15 | return &Factory{} 16 | } 17 | 18 | // Create creates a transport based on the provided configuration 19 | func (*Factory) Create(config types.Config) (types.Transport, error) { 20 | switch config.Type { 21 | case types.TransportTypeStdio: 22 | return NewStdioTransport(config.Host, config.Port, config.Runtime, config.Debug, config.Middlewares...), nil 23 | case types.TransportTypeSSE: 24 | return NewSSETransport( 25 | config.Host, 26 | config.Port, 27 | config.TargetPort, 28 | config.Runtime, 29 | config.Debug, 30 | config.TargetHost, 31 | config.Middlewares..., 32 | ), nil 33 | case types.TransportTypeInspector: 34 | // HTTP transport is not implemented yet 35 | return nil, errors.ErrUnsupportedTransport 36 | default: 37 | return nil, errors.ErrUnsupportedTransport 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /pkg/transport/proxy/manager.go: -------------------------------------------------------------------------------- 1 | // Package proxy contains code for managing proxy processes. 2 | package proxy 3 | 4 | import ( 5 | "github.com/stacklok/toolhive/pkg/logger" 6 | "github.com/stacklok/toolhive/pkg/process" 7 | ) 8 | 9 | // We may want to move these operations behind an interface. For now, they 10 | // have been moved to this package to keep proxy-related logic grouped together. 11 | 12 | // StopProcess stops the proxy process associated with the container 13 | func StopProcess(containerBaseName string) { 14 | if containerBaseName == "" { 15 | logger.Warnf("Warning: Could not find base container name in labels") 16 | return 17 | } 18 | 19 | // Try to read the PID file and kill the process 20 | pid, err := process.ReadPIDFile(containerBaseName) 21 | if err != nil { 22 | logger.Errorf("No PID file found for %s, proxy may not be running in detached mode", containerBaseName) 23 | return 24 | } 25 | 26 | // PID file found, try to kill the process 27 | logger.Infof("Stopping proxy process (PID: %d)...", pid) 28 | if err := process.KillProcess(pid); err != nil { 29 | logger.Warnf("Warning: Failed to kill proxy process: %v", err) 30 | } else { 31 | logger.Info("Proxy process stopped") 32 | } 33 | 34 | // Remove the PID file 35 | if err := process.RemovePIDFile(containerBaseName); err != nil { 36 | logger.Warnf("Warning: Failed to remove PID file: %v", err) 37 | } 38 | } 39 | 40 | // IsRunning checks if the proxy process is running 41 | func IsRunning(containerBaseName string) bool { 42 | if containerBaseName == "" { 43 | return false 44 | } 45 | 46 | // Try to read the PID file 47 | pid, err := process.ReadPIDFile(containerBaseName) 48 | if err != nil { 49 | return false 50 | } 51 | 52 | // Check if the process exists and is running 53 | isRunning, err := process.FindProcess(pid) 54 | if err != nil { 55 | logger.Warnf("Warning: Error checking process: %v", err) 56 | return false 57 | } 58 | 59 | return isRunning 60 | } 61 | -------------------------------------------------------------------------------- /pkg/transport/ssecommon/sse_common.go: -------------------------------------------------------------------------------- 1 | // Package ssecommon provides common types and utilities for Server-Sent Events (SSE) 2 | // used in communication between the client and MCP server. 3 | package ssecommon 4 | 5 | import ( 6 | "fmt" 7 | "strings" 8 | "time" 9 | ) 10 | 11 | const ( 12 | // HTTPSSEEndpoint is the endpoint for SSE connections 13 | HTTPSSEEndpoint = "/sse" 14 | // HTTPMessagesEndpoint is the endpoint for JSON-RPC messages 15 | HTTPMessagesEndpoint = "/messages" 16 | ) 17 | 18 | // SSEMessage represents a Server-Sent Event message 19 | type SSEMessage struct { 20 | // EventType is the type of event (e.g., "message", "endpoint") 21 | EventType string 22 | // Data is the event data 23 | Data string 24 | // TargetClientID is the ID of the target client (if any) 25 | TargetClientID string 26 | // CreatedAt is the time the message was created 27 | CreatedAt time.Time 28 | } 29 | 30 | // NewSSEMessage creates a new SSE message 31 | func NewSSEMessage(eventType, data string) *SSEMessage { 32 | return &SSEMessage{ 33 | EventType: eventType, 34 | Data: data, 35 | CreatedAt: time.Now(), 36 | } 37 | } 38 | 39 | // WithTargetClientID sets the target client ID for the message 40 | func (m *SSEMessage) WithTargetClientID(clientID string) *SSEMessage { 41 | m.TargetClientID = clientID 42 | return m 43 | } 44 | 45 | // ToSSEString converts the message to an SSE-formatted string 46 | func (m *SSEMessage) ToSSEString() string { 47 | var sb strings.Builder 48 | 49 | // Add event type 50 | sb.WriteString(fmt.Sprintf("event: %s\n", m.EventType)) 51 | 52 | // Add data (split by newlines to ensure proper formatting) 53 | for _, line := range strings.Split(m.Data, "\n") { 54 | sb.WriteString(fmt.Sprintf("data: %s\n", line)) 55 | } 56 | 57 | // End the message with a blank line 58 | sb.WriteString("\n") 59 | 60 | return sb.String() 61 | } 62 | 63 | // PendingSSEMessage represents an SSE message that is pending delivery 64 | type PendingSSEMessage struct { 65 | // Message is the SSE message 66 | Message *SSEMessage 67 | // CreatedAt is the time the message was created 68 | CreatedAt time.Time 69 | } 70 | 71 | // NewPendingSSEMessage creates a new pending SSE message 72 | func NewPendingSSEMessage(message *SSEMessage) *PendingSSEMessage { 73 | return &PendingSSEMessage{ 74 | Message: message, 75 | CreatedAt: time.Now(), 76 | } 77 | } 78 | 79 | // SSEClient represents a connected SSE client 80 | type SSEClient struct { 81 | // MessageCh is the channel for sending messages to the client 82 | MessageCh chan string 83 | // CreatedAt is the time the client connected 84 | CreatedAt time.Time 85 | } 86 | -------------------------------------------------------------------------------- /pkg/updates/client.go: -------------------------------------------------------------------------------- 1 | package updates 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "os" 9 | ) 10 | 11 | // VersionClient is an interface for calling the update service API. 12 | type VersionClient interface { 13 | GetLatestVersion(instanceID string, currentVersion string) (string, error) 14 | } 15 | 16 | // NewVersionClient creates a new instance of VersionClient. 17 | func NewVersionClient() VersionClient { 18 | return NewVersionClientWithSuffix("") 19 | } 20 | 21 | // NewVersionClientWithSuffix creates a new instance of VersionClient with an optional user agent suffix. 22 | func NewVersionClientWithSuffix(suffix string) VersionClient { 23 | return &defaultVersionClient{ 24 | versionEndpoint: defaultVersionAPI, 25 | userAgentSuffix: suffix, 26 | } 27 | } 28 | 29 | type defaultVersionClient struct { 30 | versionEndpoint string 31 | userAgentSuffix string 32 | } 33 | 34 | const ( 35 | instanceIDHeader = "X-Instance-ID" 36 | userAgentHeader = "User-Agent" 37 | defaultVersionAPI = "https://updates.codegate.ai/api/v1/version" 38 | ) 39 | 40 | // GetLatestVersion sends a GET request to the update API endpoint and returns the version from the response. 41 | // It returns an error if the request fails or if the response status code is not 200. 42 | func (d *defaultVersionClient) GetLatestVersion(instanceID string, currentVersion string) (string, error) { 43 | // Create a new request 44 | req, err := http.NewRequest(http.MethodGet, d.versionEndpoint, nil) 45 | if err != nil { 46 | return "", fmt.Errorf("failed to create request: %w", err) 47 | } 48 | 49 | // Set headers 50 | userAgent := fmt.Sprintf("toolhive/%s", currentVersion) 51 | if d.userAgentSuffix != "" { 52 | userAgent += " " + d.userAgentSuffix 53 | } 54 | // Add `dev` to the user agent for Stacklok devs. 55 | if os.Getenv("TOOLHIVE_DEV") != "" { 56 | userAgent += " dev" 57 | } 58 | req.Header.Set(instanceIDHeader, instanceID) 59 | req.Header.Set(userAgentHeader, userAgent) 60 | 61 | // Send the request 62 | client := &http.Client{} 63 | resp, err := client.Do(req) 64 | if err != nil { 65 | return "", fmt.Errorf("failed to send request to update API: %w", err) 66 | } 67 | defer resp.Body.Close() 68 | 69 | // Check if status code is 200 70 | if resp.StatusCode != http.StatusOK { 71 | return "", fmt.Errorf("update API returned non-200 status code: %d", resp.StatusCode) 72 | } 73 | 74 | // Read and parse the response body 75 | body, err := io.ReadAll(resp.Body) 76 | if err != nil { 77 | return "", fmt.Errorf("failed to read response body: %w", err) 78 | } 79 | 80 | // Parse JSON response 81 | var response struct { 82 | Version string `json:"version"` 83 | } 84 | if err := json.Unmarshal(body, &response); err != nil { 85 | return "", fmt.Errorf("failed to parse JSON response: %w", err) 86 | } 87 | 88 | return response.Version, nil 89 | } 90 | -------------------------------------------------------------------------------- /pkg/updates/client_test.go: -------------------------------------------------------------------------------- 1 | package updates 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestNewVersionClientWithSuffix(t *testing.T) { 10 | tests := []struct { 11 | name string 12 | suffix string 13 | expected string 14 | }{ 15 | { 16 | name: "no suffix", 17 | suffix: "", 18 | expected: "", 19 | }, 20 | { 21 | name: "operator suffix", 22 | suffix: "operator", 23 | expected: "operator", 24 | }, 25 | { 26 | name: "custom suffix", 27 | suffix: "custom", 28 | expected: "custom", 29 | }, 30 | } 31 | 32 | for _, tt := range tests { 33 | t.Run(tt.name, func(t *testing.T) { 34 | client := NewVersionClientWithSuffix(tt.suffix) 35 | defaultClient, ok := client.(*defaultVersionClient) 36 | assert.True(t, ok, "Expected defaultVersionClient type") 37 | assert.Equal(t, tt.expected, defaultClient.userAgentSuffix) 38 | }) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /pkg/versions/version.go: -------------------------------------------------------------------------------- 1 | // Package versions provides version information for the ToolHive application. 2 | package versions 3 | 4 | import ( 5 | "fmt" 6 | "runtime" 7 | "runtime/debug" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | const ( 13 | unknownStr = "unknown" 14 | ) 15 | 16 | // Version information set by build using -ldflags 17 | var ( 18 | // Version is the current version of ToolHive 19 | Version = "dev" 20 | // Commit is the git commit hash of the build 21 | //nolint:goconst // This is a placeholder for the commit hash 22 | Commit = unknownStr 23 | // BuildDate is the date when the binary was built 24 | // nolint:goconst // This is a placeholder for the build date 25 | BuildDate = unknownStr 26 | ) 27 | 28 | // VersionInfo represents the version information 29 | type VersionInfo struct { 30 | Version string `json:"version"` 31 | Commit string `json:"commit"` 32 | BuildDate string `json:"build_date"` 33 | GoVersion string `json:"go_version"` 34 | Platform string `json:"platform"` 35 | } 36 | 37 | // GetVersionInfo returns the version information 38 | func GetVersionInfo() VersionInfo { 39 | // If version is still "dev", try to get it from build info 40 | ver := Version 41 | commit := Commit 42 | buildDate := BuildDate 43 | 44 | if strings.HasPrefix(ver, "dev") { 45 | if info, ok := debug.ReadBuildInfo(); ok { 46 | // Try to get version from build info 47 | for _, setting := range info.Settings { 48 | switch setting.Key { 49 | case "vcs.revision": 50 | if commit == unknownStr { 51 | commit = setting.Value 52 | } 53 | case "vcs.time": 54 | if buildDate == unknownStr { 55 | buildDate = setting.Value 56 | } 57 | } 58 | } 59 | } 60 | } 61 | 62 | // Format the build date if it's a timestamp 63 | if buildDate != unknownStr { 64 | if t, err := time.Parse(time.RFC3339, buildDate); err == nil { 65 | buildDate = t.Format("2006-01-02 15:04:05 MST") 66 | } 67 | } 68 | 69 | // If the version is just "dev" - manufacture a version string using the commit. 70 | // NOTE: Ignore any IDE warnings about this condition always being true - it is 71 | // overridden by the build flags. 72 | if ver == "dev" { 73 | // Truncate commit to 8 characters for brevity. 74 | ver = fmt.Sprintf("build-%s", fmt.Sprintf("%.*s", 8, commit)) 75 | } 76 | 77 | return VersionInfo{ 78 | Version: ver, 79 | Commit: commit, 80 | BuildDate: buildDate, 81 | GoVersion: runtime.Version(), 82 | Platform: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH), 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended", 5 | "helpers:pinGitHubActionDigests" 6 | ], 7 | "labels": ["dependencies"] 8 | } 9 | -------------------------------------------------------------------------------- /test/e2e/e2e_suite_test.go: -------------------------------------------------------------------------------- 1 | package e2e_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestE2e(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "E2e Suite") 13 | } 14 | -------------------------------------------------------------------------------- /test/e2e/run_tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # E2E Test Runner for ToolHive 4 | # This script sets up the environment and runs the e2e tests 5 | 6 | set -e 7 | 8 | # Colors for output 9 | RED='\033[0;31m' 10 | GREEN='\033[0;32m' 11 | YELLOW='\033[1;33m' 12 | NC='\033[0m' # No Color 13 | 14 | echo -e "${GREEN}ToolHive E2E Test Runner${NC}" 15 | echo "================================" 16 | 17 | # Set TOOLHIVE_DEV environment variable to true 18 | export TOOLHIVE_DEV=true 19 | 20 | # Check if thv binary exists 21 | THV_BINARY="${THV_BINARY:-thv}" 22 | if ! command -v "$THV_BINARY" &> /dev/null; then 23 | echo -e "${RED}Error: thv binary not found in PATH${NC}" 24 | echo "Please build the binary first with: task build" 25 | echo "Or set THV_BINARY environment variable to the binary path" 26 | exit 1 27 | fi 28 | 29 | echo -e "${GREEN}✓${NC} Found thv binary: $(which $THV_BINARY)" 30 | 31 | # Check if container runtime is available 32 | if ! command -v docker &> /dev/null && ! command -v podman &> /dev/null; then 33 | echo -e "${RED}Error: Neither docker nor podman found${NC}" 34 | echo "Please install docker or podman to run MCP servers" 35 | exit 1 36 | fi 37 | 38 | if command -v docker &> /dev/null; then 39 | echo -e "${GREEN}✓${NC} Found container runtime: docker" 40 | else 41 | echo -e "${GREEN}✓${NC} Found container runtime: podman" 42 | fi 43 | 44 | # Set test timeout 45 | TEST_TIMEOUT="${TEST_TIMEOUT:-10m}" 46 | echo -e "${GREEN}✓${NC} Test timeout: $TEST_TIMEOUT" 47 | 48 | # Export environment variables for tests 49 | export THV_BINARY 50 | export TEST_TIMEOUT 51 | 52 | echo "" 53 | echo -e "${YELLOW}Running E2E Tests...${NC}" 54 | echo "" 55 | 56 | # Run the tests 57 | cd "$(dirname "$0")" 58 | if ginkgo run --timeout="$TEST_TIMEOUT" --v --progress --trace .; then 59 | echo "" 60 | echo -e "${GREEN}✓ All E2E tests passed!${NC}" 61 | exit 0 62 | else 63 | echo "" 64 | echo -e "${RED}✗ Some E2E tests failed${NC}" 65 | exit 1 66 | fi --------------------------------------------------------------------------------