diff --git a/operators/kustomization.yaml b/operators/kustomization.yaml index 19165a9..8139e77 100644 --- a/operators/kustomization.yaml +++ b/operators/kustomization.yaml @@ -10,4 +10,5 @@ resources: - forgejo - grafana - kube-prometheus-stack + - toolhive - tor-controller diff --git a/operators/toolhive/kustomization.yaml b/operators/toolhive/kustomization.yaml new file mode 100644 index 0000000..a07ca93 --- /dev/null +++ b/operators/toolhive/kustomization.yaml @@ -0,0 +1,6 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - toolhive-crds-app.yaml + - toolhive-operator-app.yaml diff --git a/operators/toolhive/toolhive-crds-app.yaml b/operators/toolhive/toolhive-crds-app.yaml new file mode 100644 index 0000000..da39e94 --- /dev/null +++ b/operators/toolhive/toolhive-crds-app.yaml @@ -0,0 +1,33 @@ +--- +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: toolhive-operator-crds + namespace: argocd + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: + ignoreDifferences: + - group: apiextensions.k8s.io + kind: CustomResourceDefinition + jqPathExpressions: + - .metadata.annotations + - .spec.conversion + - .spec.preserveUnknownFields + - .status + project: default + destination: + server: https://kubernetes.default.svc + source: + chart: toolhive-operator-crds + repoURL: ghcr.io/stacklok/toolhive + targetRevision: 0.44.0 + helm: + releaseName: toolhive-operator-crds + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - ServerSideApply=true + - RespectIgnoreDifferences=true diff --git a/operators/toolhive/toolhive-operator-app.yaml b/operators/toolhive/toolhive-operator-app.yaml new file mode 100644 index 0000000..1f62825 --- /dev/null +++ b/operators/toolhive/toolhive-operator-app.yaml @@ -0,0 +1,34 @@ +--- +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: toolhive-operator + namespace: argocd + annotations: + # Operator crashes on startup if its CRDs are missing. + argocd.argoproj.io/sync-wave: "1" +spec: + project: default + destination: + server: https://kubernetes.default.svc + namespace: toolhive-system + source: + chart: toolhive-operator + repoURL: ghcr.io/stacklok/toolhive + targetRevision: 0.44.0 + helm: + releaseName: toolhive-operator + values: | + operator: + # Chart defaults carry requests/limits; empty keeps single-node policy. + resources: {} + env: + - name: TOOLHIVE_SKIP_UPDATE_CHECK + value: "true" + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - ServerSideApply=true + - CreateNamespace=true diff --git a/workloads/apps/kustomization.yaml b/workloads/apps/kustomization.yaml index 3db3d49..c4f173a 100644 --- a/workloads/apps/kustomization.yaml +++ b/workloads/apps/kustomization.yaml @@ -9,5 +9,6 @@ resources: - arc-app.yaml - grafana-app.yaml - kubectl-tunnel-app.yaml + - mcp-gateway-app.yaml - opencode-app.yaml - ../argocd diff --git a/workloads/apps/mcp-gateway-app.yaml b/workloads/apps/mcp-gateway-app.yaml new file mode 100644 index 0000000..e1d28b2 --- /dev/null +++ b/workloads/apps/mcp-gateway-app.yaml @@ -0,0 +1,29 @@ +--- +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: mcp-gateway + namespace: argocd + annotations: + argocd.argoproj.io/sync-wave: "1" +spec: + project: default + source: + repoURL: https://github.com/makeitworkcloud/kustomize-cluster.git + path: workloads/mcp-gateway + targetRevision: main + destination: + server: https://kubernetes.default.svc + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true + - SkipDryRunOnMissingResource=true + retry: + limit: 5 + backoff: + duration: 30s + maxDuration: 5m + factor: 2 diff --git a/workloads/apps/wait-for-crds.yaml b/workloads/apps/wait-for-crds.yaml index 13003d5..8117182 100644 --- a/workloads/apps/wait-for-crds.yaml +++ b/workloads/apps/wait-for-crds.yaml @@ -42,6 +42,11 @@ spec: grafanadashboards.grafana.integreatly.org servicemonitors.monitoring.coreos.com probes.monitoring.coreos.com + mcpservers.toolhive.stacklok.dev + mcpremoteproxies.toolhive.stacklok.dev + mcpgroups.toolhive.stacklok.dev + virtualmcpservers.toolhive.stacklok.dev + mcpexternalauthconfigs.toolhive.stacklok.dev ) for crd in "${CRDS[@]}"; do diff --git a/workloads/mcp-gateway/github-secret.yaml b/workloads/mcp-gateway/github-secret.yaml new file mode 100644 index 0000000..d040356 --- /dev/null +++ b/workloads/mcp-gateway/github-secret.yaml @@ -0,0 +1,26 @@ +apiVersion: v1 +kind: Secret +metadata: + name: mcp-github + namespace: mcp + annotations: + argocd.argoproj.io/sync-wave: "0" +type: Opaque +stringData: + # Value must be "Bearer "; set the real token with `sops` before relying on the github proxy. + github_token: ENC[AES256_GCM,data:ZFkkbMtBs+T1WOqkR34eLVFvfcXXA2kb9bgYo0+5,iv:jfQFwykT9AtNXvVZZAHG2EI7yGh8K1xhKBQoiZxPZAI=,tag:wzeWjm30uliTyp9VDFZiRQ==,type:str] +sops: + age: + - enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBhZ29ZazZIbGNGQk5ISmNo + ci9JTXJEOVZsbExtRXRacFRlWXVnNnB2eVN3CkRudXFnS2J0VlFzTUJ2U1NOeXll + NmU3WVM5aXhxWllaRi93NWNsb3VRWlEKLS0tIEkrNm94TzRVRXhoa0NwZUtJUmJS + anJiUWdCNkdOSjcwRUJjcUUyci9WVEkKlRM+6XutijQ1DhMowYFB5NjNzjyKzLHd + IK/4zKTGd3N/iMld4f3eNefoDAvzhXcjNtTBjy1G5tc3kx6qBpRthQ== + -----END AGE ENCRYPTED FILE----- + recipient: age152ek83tm4fj5u70r3fecytn4kg7c5xca24erjchxexx4pfqg6das7q763l + encrypted_regex: ^(token|api-token|apiToken|clientID|clientSecret|client_id|client_secret|password|secret|github_token|CLOUDFLARE_API_TOKEN|KIMI_API_KEY|credentials\.json|.*_SERVICE_KEY|GF_AUTH_(GITHUB|GENERIC_OAUTH)_CLIENT_SECRET|GF_SECURITY_ADMIN_PASSWORD|dex\.[a-z]+\.clientID|dex\.[a-z]+\.clientSecret|OIDC_CLIENT_(ID|SECRET))$ + lastmodified: "2026-08-26T02:46:44Z" + mac: ENC[AES256_GCM,data:DJvovnhPN4xjHSjGkJj64hyyMZGhbS7eawmRYn3LaTV0Q8Lxhg5AsNUnREKkOFIhv5pvgWL0dN0Yr8MnIjg0qxpP+ckqbFZgErDQoLV8A5V7nrU+W9CHxdZ9HLCJ53ZjPzp/pALAzBENODWKTqkOhcBHADnUUqQT2fZVcd1gHpc=,iv:BK64OlZmt5M2Ls166Yn2s+IXic/R632F5x8eThBgBXg=,tag:vtfRfXZHiCbLNyYS8TPWiQ==,type:str] + version: 3.13.3 diff --git a/workloads/mcp-gateway/ksops-mcp-gateway-secrets.yaml b/workloads/mcp-gateway/ksops-mcp-gateway-secrets.yaml new file mode 100644 index 0000000..81dc886 --- /dev/null +++ b/workloads/mcp-gateway/ksops-mcp-gateway-secrets.yaml @@ -0,0 +1,11 @@ +--- +apiVersion: viaduct.ai/v1 +kind: ksops +metadata: + name: ksops-mcp-gateway-secrets + annotations: + config.kubernetes.io/function: | + exec: + path: ksops +files: + - github-secret.yaml diff --git a/workloads/mcp-gateway/kubernetes-mcpserver.yaml b/workloads/mcp-gateway/kubernetes-mcpserver.yaml new file mode 100644 index 0000000..f0d7919 --- /dev/null +++ b/workloads/mcp-gateway/kubernetes-mcpserver.yaml @@ -0,0 +1,91 @@ +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: mcp-kubernetes + namespace: mcp + annotations: + argocd.argoproj.io/sync-wave: "0" +automountServiceAccountToken: false +--- +# Read-only across the cluster, but Secrets stay excluded: KSOPS-decrypted +# secrets live in-cluster and the gateway must not become a read path to them. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: mcp-kubernetes-readonly + annotations: + argocd.argoproj.io/sync-wave: "0" +rules: + - apiGroups: [""] + resources: + - configmaps + - endpoints + - events + - limitranges + - namespaces + - nodes + - persistentvolumeclaims + - pods + - pods/log + - replicationcontrollers + - resourcequotas + - serviceaccounts + - services + verbs: ["get", "list", "watch"] + - apiGroups: + - actions.github.com + - apiextensions.k8s.io + - apps + - argoproj.io + - autoscaling + - batch + - cert-manager.io + - grafana.integreatly.org + - metrics.k8s.io + - monitoring.coreos.com + - networking.cfargotunnel.com + - networking.k8s.io + - policy + - rbac.authorization.k8s.io + - storage.k8s.io + - toolhive.stacklok.dev + - tor.k8s.torproject.org + resources: ["*"] + verbs: ["get", "list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: mcp-kubernetes-readonly + annotations: + argocd.argoproj.io/sync-wave: "0" +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: mcp-kubernetes-readonly +subjects: + - kind: ServiceAccount + name: mcp-kubernetes + namespace: mcp +--- +# --read-only is belt-and-braces: even an RBAC mistake cannot make this +# server perform writes through the MCP tool surface. +apiVersion: toolhive.stacklok.dev/v1beta1 +kind: MCPServer +metadata: + name: kubernetes + namespace: mcp + annotations: + argocd.argoproj.io/sync-wave: "1" +spec: + groupRef: + name: gateway + image: ghcr.io/containers/kubernetes-mcp-server:v0.0.66@sha256:6d650f4bd6ac303ad82713c997e73a2d001602f9bf17392c9b9a0e30e29c6423 + transport: streamable-http + mcpPort: 8080 + serviceAccount: mcp-kubernetes + args: + - --read-only + - --port + - "8080" diff --git a/workloads/mcp-gateway/kustomization.yaml b/workloads/mcp-gateway/kustomization.yaml new file mode 100644 index 0000000..5b82c29 --- /dev/null +++ b/workloads/mcp-gateway/kustomization.yaml @@ -0,0 +1,11 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - namespace.yaml + - mcpgroup.yaml + - kubernetes-mcpserver.yaml + - remote-proxies.yaml + - vmcp.yaml +generators: + - ksops-mcp-gateway-secrets.yaml diff --git a/workloads/mcp-gateway/mcpgroup.yaml b/workloads/mcp-gateway/mcpgroup.yaml new file mode 100644 index 0000000..f071d81 --- /dev/null +++ b/workloads/mcp-gateway/mcpgroup.yaml @@ -0,0 +1,9 @@ +--- +apiVersion: toolhive.stacklok.dev/v1beta1 +kind: MCPGroup +metadata: + name: gateway + namespace: mcp + annotations: + argocd.argoproj.io/sync-wave: "0" +spec: {} diff --git a/workloads/mcp-gateway/namespace.yaml b/workloads/mcp-gateway/namespace.yaml new file mode 100644 index 0000000..83c2429 --- /dev/null +++ b/workloads/mcp-gateway/namespace.yaml @@ -0,0 +1,7 @@ +--- +apiVersion: v1 +kind: Namespace +metadata: + name: mcp + annotations: + argocd.argoproj.io/sync-wave: "0" diff --git a/workloads/mcp-gateway/remote-proxies.yaml b/workloads/mcp-gateway/remote-proxies.yaml new file mode 100644 index 0000000..16b9401 --- /dev/null +++ b/workloads/mcp-gateway/remote-proxies.yaml @@ -0,0 +1,44 @@ +--- +apiVersion: toolhive.stacklok.dev/v1beta1 +kind: MCPRemoteProxy +metadata: + name: context7 + namespace: mcp + annotations: + argocd.argoproj.io/sync-wave: "1" +spec: + groupRef: + name: gateway + remoteUrl: https://mcp.context7.com/mcp + transport: streamable-http +--- +apiVersion: toolhive.stacklok.dev/v1beta1 +kind: MCPRemoteProxy +metadata: + name: github + namespace: mcp + annotations: + argocd.argoproj.io/sync-wave: "1" +spec: + groupRef: + name: gateway + remoteUrl: https://api.githubcopilot.com/mcp/ + transport: streamable-http + externalAuthConfigRef: + name: github-token +--- +# The header value is injected verbatim, so the Secret holds "Bearer ". +apiVersion: toolhive.stacklok.dev/v1beta1 +kind: MCPExternalAuthConfig +metadata: + name: github-token + namespace: mcp + annotations: + argocd.argoproj.io/sync-wave: "1" +spec: + type: headerInjection + headerInjection: + headerName: Authorization + valueSecretRef: + name: mcp-github + key: github_token diff --git a/workloads/mcp-gateway/vmcp.yaml b/workloads/mcp-gateway/vmcp.yaml new file mode 100644 index 0000000..9ac4c36 --- /dev/null +++ b/workloads/mcp-gateway/vmcp.yaml @@ -0,0 +1,16 @@ +--- +# ClusterIP-only trust boundary: edge auth is Cloudflare Access on +# mcp.makeitwork.cloud (tfroot-cloudflare); flannel does not enforce +# NetworkPolicy on this cluster, so anonymous here is a deliberate choice. +apiVersion: toolhive.stacklok.dev/v1beta1 +kind: VirtualMCPServer +metadata: + name: gateway + namespace: mcp + annotations: + argocd.argoproj.io/sync-wave: "2" +spec: + groupRef: + name: gateway + incomingAuth: + type: anonymous diff --git a/workloads/opencode/configmap.yaml b/workloads/opencode/configmap.yaml index 7b42fc7..20970e7 100644 --- a/workloads/opencode/configmap.yaml +++ b/workloads/opencode/configmap.yaml @@ -18,5 +18,13 @@ data: "apiKey": "{env:KIMI_API_KEY}" } } + }, + "mcp": { + "mcp-gateway": { + "type": "remote", + "url": "http://vmcp-gateway.mcp.svc:4483/mcp", + "enabled": true, + "oauth": false + } } }