Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 51 additions & 21 deletions workloads/mcp-gateway/README.md
Original file line number Diff line number Diff line change
@@ -1,30 +1,60 @@
# MCP Gateway — `gcp` ToolHive Backend
# ToolHive MCP endpoints

This directory deploys the local `gcp` backend for the MCP gateway. MCPGroup
members carry clean integration keys (`gcp`, `kubernetes`, `argocd`, `apify`,
`aws`, `aws-docs`, `cloudflare-read`, `context7`, `grafana`, `parallel-search`,
`playwright`, `slidespeak`, `terraform-docs`, `twilio-docs`); the `vmcp-gateway`
aggregate exposes their tools name-prefixed with those keys, in-cluster at
`http://vmcp-gateway.mcp.svc:4483/mcp` and externally at `mcp.makeitwork.cloud`
behind Cloudflare Access. `github`, `hero-ssh`, and `codebase-memory` remain
intentionally non-aggregated per-backend Services.
This directory deploys the ToolHive backends used by OpenCode and external MCP
clients. OpenCode connects directly to the ClusterIP proxy Services. External
clients use one Cloudflare Access-protected endpoint per selected backend.

## External endpoints

Each direct endpoint is `https://mcp-<integration>.makeitwork.cloud/mcp` for:

`apify`, `argocd`, `aws`, `aws-docs`, `cloudflare`, `context7`, `gcp`,
`grafana`, `kubernetes`, `parallel-search`, `playwright`, `slidespeak`,
`terraform-docs`, and `twilio-docs`.

The `TunnelBinding` owns workload DNS and routes each hostname to its
ToolHive-generated ClusterIP proxy Service. `tfroot-cloudflare` owns the
matching Cloudflare Access applications. The proxy Services remain internal;
Cloudflare Access is the only external authentication boundary.

## Authentication and security boundary

Owner decision, 2026-09-12: for the solo-developer external-MCP use case, every
direct endpoint may use the existing shared MCP Gateway Cloudflare Access
service token. Clients provide its `CF-Access-Client-*` headers at the edge;
no Cloudflare Access credential is stored in these manifests, and backend API
credentials remain in their existing cluster-owned Secrets.

`github`, `hero-ssh`, and `codebase-memory` remain ClusterIP-only and have no
external TunnelBinding subject. The Cloudflare and provider credentials attached
to other ToolHive proxies remain separate backend authorization boundaries.

## Workload

- **Image:** `ghcr.io/makeitworkcloud/gcloud-mcp@sha256:1a2e38cf1f1855f445b17a1fe6669045b5a3fa794ca54ccf9a22c0aee68dd1d2`.
- **Identity:** dedicated Kubernetes ServiceAccount `mcp/gcloud-mcp` with a 3600-second projected token for the Google Workload Identity Federation (WIF) provider.
- **Configuration:** non-secret, content-addressed ConfigMaps carry the external-account configuration and restricted `gcloud` command allowlist.
- **GCP image:** `ghcr.io/makeitworkcloud/gcloud-mcp@sha256:1a2e38cf1f1855f445b17a1fe6669045b5a3fa794ca54ccf9a22c0aee68dd1d2`.
- **GCP identity:** dedicated Kubernetes ServiceAccount `mcp/gcloud-mcp` with a 3600-second projected token for the Google Workload Identity Federation (WIF) provider.
- **GCP configuration:** non-secret, content-addressed ConfigMaps carry the external-account configuration and restricted `gcloud` command allowlist.
- **Kustomize:** `gcloud-mcp-namereference.yaml` is required because ToolHive stores `podTemplateSpec` as a `RawExtension`, outside Kustomize's default name-reference rules.

## Ownership and Security Boundary
`tfroot-gcp` owns and applies the Google Cloud WIF provider. The provider
accepts only `system:serviceaccount:mcp:gcloud-mcp` and impersonates
`gcloud-mcp@makeitworkcloud.iam.gserviceaccount.com`.

## Delivery and verification

The Cloudflare Access applications must be applied successfully from
`tfroot-cloudflare` **before** this GitOps change is merged: adding a
`TunnelBinding` subject first would expose a route without its required edge
Access application. This is a cross-repository ordering requirement.

- `tfroot-gcp` owns and applies the Google Cloud WIF provider; this repository consumes it.
- The provider accepts only `system:serviceaccount:mcp:gcloud-mcp` and impersonates `gcloud-mcp@makeitworkcloud.iam.gserviceaccount.com`.
- The command allowlist and GCP IAM roles are independent read boundaries. No credentials or token values are committed.
- `groupRef: gateway` exposes the backend through the existing `mcp.makeitwork.cloud` Cloudflare Access path under the `gcp` integration key; this workload creates no dedicated TunnelBinding or DNS record. The per-integration TunnelBinding subjects were retired when OpenCode moved to the single internal aggregate endpoint; only the aggregate root route remains.
- Owner waiver, 2026-09-09: the aggregate is anonymous to in-cluster callers. The single developer consumer holds the external Cloudflare Access pre-shared key.
- Owner approval, 2026-09-11: the browser-capable `playwright` member is exposed through the external aggregate; the shared MCP Gateway Cloudflare Access service token remains the edge authentication boundary.
After the Access apply, merge the GitOps route change and separately verify:

## Delivery and Verification
1. the `mcp-gateway` Argo CD Application and every generated proxy Service are healthy;
2. each expected CNAME, ownership TXT record, and tunnel route exists;
3. each direct endpoint rejects a request without Cloudflare Access headers;
4. each direct endpoint accepts an authenticated MCP `tools/list` request; and
5. no mutating MCP tool is used as a rollout probe.

The WIF provider must apply before this workload can authenticate. After a GitOps merge, verify the rendered ConfigMap references, GCP WIF startup, Gateway/Argo reconciliation, and an allowed read-only MCP request. Do not use a write probe without separate approval.
Roll back in reverse order: first remove the direct TunnelBinding subjects and
verify their operator-owned DNS cleanup, then revert the Cloudflare Access
application change through its environment-gated apply.
9 changes: 4 additions & 5 deletions workloads/mcp-gateway/argocd-mcpserver.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ metadata:
annotations:
argocd.argoproj.io/sync-wave: "1"
spec:
groupRef:
name: gateway
image: ghcr.io/argoproj-labs/mcp-for-argocd:v0.9.0@sha256:dffc6c719d8635be2d5f2c8ad1c0178540325da4b40da0b373307e12b4c48f86
transport: streamable-http
mcpPort: 3000
Expand All @@ -17,9 +15,10 @@ spec:
- "3000"
# The proxyrunner runs in a separate pod, so the server must leave
# loopback. Unauthenticated + skipped Host check (no --allowed-host-header)
# is acceptable: the backend svc is ClusterIP-only behind the gateway, the
# same trust boundary as the vmcp anonymous decision in vmcp.yaml, and the
# real credential (the ArgoCD mcp account token) stays server-side.
# is acceptable: the backend svc is ClusterIP-only behind the ToolHive
# proxy, external callers must pass the direct endpoint's Cloudflare
# Access application, and the real credential (the ArgoCD mcp account
# token) stays server-side.
- --bind-address
- 0.0.0.0
- --allow-unauthenticated
Expand Down
2 changes: 0 additions & 2 deletions workloads/mcp-gateway/aws-docs-mcpserver.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ metadata:
annotations:
argocd.argoproj.io/sync-wave: "1"
spec:
groupRef:
name: gateway
# Upstream publishes sha-* tags only; the digest pins the current latest.
image: public.ecr.aws/awslabs-mcp/awslabs/aws-documentation-mcp-server:latest@sha256:1e6bcdbe2a9085f304f9f6f8c39765883df2d900cf03970a81959f62db755ce1
transport: stdio
Expand Down
2 changes: 0 additions & 2 deletions workloads/mcp-gateway/aws-mcpserver.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ metadata:
annotations:
argocd.argoproj.io/sync-wave: "1"
spec:
groupRef:
name: gateway
# Pinned to the upstream AWS MCP Proxy v1.6.4 release.
image: public.ecr.aws/mcp-proxy-for-aws/mcp-proxy-for-aws:1.6.4
transport: stdio
Expand Down
11 changes: 5 additions & 6 deletions workloads/mcp-gateway/cloudflare-remote-proxy.yaml
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
---
# Cloudflare's hosted API MCP server remains the upstream. This in-cluster
# ToolHive proxy keeps the dedicated API token in the mcp namespace instead of
# in OpenCode. groupRef adds it to the externally exposed VirtualMCPServer
# aggregate: external callers still authenticate through the shared MCP
# Gateway Cloudflare Access service token, never with the Cloudflare API
# token, which the proxy injects only on outbound upstream requests.
# in OpenCode. It is externally reachable only through the dedicated direct
# route (mcp-cloudflare.makeitwork.cloud, tunnel-binding.yaml): external
# callers authenticate through that endpoint's Cloudflare Access application,
# never with the Cloudflare API token, which the proxy injects only on
# outbound upstream requests.
#
# Required credential contract: a separate, account-wide token scoped only to
# Cloudflare API read permission groups for the account containing xnoto.dev,
Expand Down Expand Up @@ -38,8 +39,6 @@ metadata:
annotations:
argocd.argoproj.io/sync-wave: "1"
spec:
groupRef:
name: gateway
# https://github.com/cloudflare/mcp documents this Streamable HTTP endpoint.
remoteUrl: https://mcp.cloudflare.com/mcp
transport: streamable-http
Expand Down
9 changes: 4 additions & 5 deletions workloads/mcp-gateway/gcp-mcpserver.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
---
# Cluster-internal backend aggregated by the existing Access-protected gateway;
# no dedicated TunnelBinding or public hostname is created. Owner waived the
# anonymous in-cluster aggregate path on 2026-09-09: the single developer
# Externally reachable only through the dedicated direct route
# (mcp-gcp.makeitwork.cloud, tunnel-binding.yaml) behind that endpoint's
# Cloudflare Access application; no anonymous in-cluster path exists. Owner
# waived the in-cluster aggregate path on 2026-09-09: the single developer
# consumer holds the Cloudflare Access pre-shared key for external access.
apiVersion: toolhive.stacklok.dev/v1beta1
kind: MCPServer
Expand All @@ -11,8 +12,6 @@ metadata:
annotations:
argocd.argoproj.io/sync-wave: "1"
spec:
groupRef:
name: gateway
image: ghcr.io/makeitworkcloud/gcloud-mcp@sha256:1a2e38cf1f1855f445b17a1fe6669045b5a3fa794ca54ccf9a22c0aee68dd1d2
transport: stdio
proxyMode: streamable-http
Expand Down
5 changes: 2 additions & 3 deletions workloads/mcp-gateway/grafana-remote-proxy.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# The Grafana MCP backend runs with its operator-owned token in the grafana
# namespace. This proxy preserves the existing external gateway exposure.
# namespace. This proxy exposes it externally through the dedicated direct
# route (mcp-grafana.makeitwork.cloud, tunnel-binding.yaml).
apiVersion: toolhive.stacklok.dev/v1beta1
kind: MCPRemoteProxy
metadata:
Expand All @@ -8,8 +9,6 @@ metadata:
annotations:
argocd.argoproj.io/sync-wave: "1"
spec:
groupRef:
name: gateway
# This is a deliberately selected in-cluster ToolHive Service. ToolHive
# blocks private endpoints by default, so permit this single upstream.
allowPrivateEndpoint: true
Expand Down
6 changes: 2 additions & 4 deletions workloads/mcp-gateway/kubernetes-mcpserver.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ metadata:
automountServiceAccountToken: true
---
# Operator access across the cluster. Secrets stay excluded: KSOPS-decrypted
# secrets live in-cluster and the gateway must not become a read or write path
# to them. The Kubernetes MCP server's generic resource operations use
# secrets live in-cluster and this MCP backend must not become a read or write
# path to them. The Kubernetes MCP server's generic resource operations use
# server-side apply, which requires patch in addition to create/update/delete.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
Expand Down Expand Up @@ -90,8 +90,6 @@ metadata:
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
Expand Down
2 changes: 0 additions & 2 deletions workloads/mcp-gateway/kustomization.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- mcpgroup.yaml
- kubernetes-mcpserver.yaml
- argocd-mcpserver.yaml
- grafana-remote-proxy.yaml
Expand All @@ -25,7 +24,6 @@ resources:
- remote-proxies.yaml
- slidespeak-remote-proxy.yaml
- twilio-remote-proxy.yaml
- vmcp.yaml
- tunnel-binding.yaml
configMapGenerator:
# A content-addressed ConfigMap name changes the MCPServer pod template,
Expand Down
9 changes: 0 additions & 9 deletions workloads/mcp-gateway/mcpgroup.yaml

This file was deleted.

14 changes: 6 additions & 8 deletions workloads/mcp-gateway/playwright-mcpserver.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
# print-to-PDF for OpenCode agents (career-agent research thread, see
# agent-knowledge docs/agents/career/work/2026-09-02-pdf-generation-mcp-landscape.md).
#
# Aggregated gateway member: groupRef exposes this backend through the
# VirtualMCPServer aggregate under the `playwright` integration key. The
# credentialed GitHub proxy (makeitwork-github-remote-proxy.yaml) remains
# intentionally non-aggregated. Owner approved the external aggregate exposure
# of this browser-capable member on 2026-09-11; the shared MCP Gateway
# Cloudflare Access service token remains the edge authentication boundary.
# Externally reachable only through the dedicated direct route
# (mcp-playwright.makeitwork.cloud, tunnel-binding.yaml) behind that endpoint's
# Cloudflare Access application. The credentialed GitHub proxy
# (makeitwork-github-remote-proxy.yaml) remains ClusterIP-only. Owner approved
# the external exposure of this browser-capable backend on 2026-09-11;
# Cloudflare Access remains the edge authentication boundary.
#
# Image digest resolved against the registry manifest API (OCI image index,
# amd64/linux present): sha256 of the v0.0.80 index.
Expand All @@ -21,8 +21,6 @@ metadata:
annotations:
argocd.argoproj.io/sync-wave: "1"
spec:
groupRef:
name: gateway
image: mcr.microsoft.com/playwright/mcp:v0.0.80@sha256:dda1f7f9b812e22946635c8af7df9288b96d3b9e3f0f1b8576d6823e2031c1de
transport: streamable-http
mcpPort: 8080
Expand Down
12 changes: 3 additions & 9 deletions workloads/mcp-gateway/remote-proxies.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Aggregate members use clean integration keys; the vmcp aggregate exposes
# member tools name-prefixed with those keys. Credential-bearing members keep
# their cluster-owned Secrets, referenced by unchanged field names.
# Hosted remote MCP backends, each externally reachable through its dedicated
# direct route in tunnel-binding.yaml. Credential-bearing backends keep their
# cluster-owned Secrets, referenced by unchanged field names.
---
apiVersion: toolhive.stacklok.dev/v1beta1
kind: MCPRemoteProxy
Expand All @@ -10,8 +10,6 @@ metadata:
annotations:
argocd.argoproj.io/sync-wave: "1"
spec:
groupRef:
name: gateway
remoteUrl: https://mcp.context7.com/mcp
transport: streamable-http
---
Expand All @@ -23,8 +21,6 @@ metadata:
annotations:
argocd.argoproj.io/sync-wave: "1"
spec:
groupRef:
name: gateway
remoteUrl: https://search.parallel.ai/mcp
transport: streamable-http
headerForward:
Expand All @@ -43,8 +39,6 @@ metadata:
annotations:
argocd.argoproj.io/sync-wave: "1"
spec:
groupRef:
name: gateway
remoteUrl: "https://mcp.apify.com/?tools=actors,apify/facebook-marketplace-scraper,apify/google-maps-scraper&telemetry-enabled=false"
transport: streamable-http
headerForward:
Expand Down
2 changes: 0 additions & 2 deletions workloads/mcp-gateway/slidespeak-remote-proxy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ metadata:
annotations:
argocd.argoproj.io/sync-wave: "1"
spec:
groupRef:
name: gateway
remoteUrl: https://mcp.slidespeak.co/mcp
transport: streamable-http
headerForward:
Expand Down
2 changes: 0 additions & 2 deletions workloads/mcp-gateway/terraform-docs-mcpserver.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ metadata:
annotations:
argocd.argoproj.io/sync-wave: "1"
spec:
groupRef:
name: gateway
image: docker.io/hashicorp/terraform-mcp-server:1.2.0@sha256:bd095e2b442a2cb61255fe4db52f9e824f35d307a2044784c95d37a93f18d324
transport: streamable-http
mcpPort: 8080
Expand Down
41 changes: 31 additions & 10 deletions workloads/mcp-gateway/tunnel-binding.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,37 @@ metadata:
argocd.argoproj.io/sync-wave: "2"
makeitwork.cloud/dns-reconcile: "2026-09-02"
subjects:
# Single aggregate route: every integration is addressed through the
# vmcp-gateway aggregate (in-cluster http://vmcp-gateway.mcp.svc:4483/mcp,
# external mcp.makeitwork.cloud) with member-name-prefixed tools. The former
# per-integration subjects were retired when OpenCode moved to the internal
# aggregate endpoint; reintroduce per-backend routes only with a new owner
# decision, and retire their DNS through this binding when doing so.
- name: vmcp-gateway
spec:
fqdn: mcp.makeitwork.cloud
target: http://vmcp-gateway.mcp.svc:4483
# Each ToolHive proxy is externally reachable only through its dedicated
# Cloudflare Access application. The direct routes preserve backend-specific
# tool discovery for clients while proxy Services remain ClusterIP-only.
- name: mcp-apify-remote-proxy
spec: {fqdn: mcp-apify.makeitwork.cloud, target: http://mcp-apify-remote-proxy.mcp.svc:8080}
- name: mcp-argocd-proxy
spec: {fqdn: mcp-argocd.makeitwork.cloud, target: http://mcp-argocd-proxy.mcp.svc:8080}
- name: mcp-aws-proxy
spec: {fqdn: mcp-aws.makeitwork.cloud, target: http://mcp-aws-proxy.mcp.svc:8080}
- name: mcp-aws-docs-proxy
spec: {fqdn: mcp-aws-docs.makeitwork.cloud, target: http://mcp-aws-docs-proxy.mcp.svc:8080}
- name: mcp-cloudflare-read-remote-proxy
spec: {fqdn: mcp-cloudflare.makeitwork.cloud, target: http://mcp-cloudflare-read-remote-proxy.mcp.svc:8080}
- name: mcp-context7-remote-proxy
spec: {fqdn: mcp-context7.makeitwork.cloud, target: http://mcp-context7-remote-proxy.mcp.svc:8080}
- name: mcp-gcp-proxy
spec: {fqdn: mcp-gcp.makeitwork.cloud, target: http://mcp-gcp-proxy.mcp.svc:8080}
- name: mcp-grafana-remote-proxy
spec: {fqdn: mcp-grafana.makeitwork.cloud, target: http://mcp-grafana-remote-proxy.mcp.svc:8080}
- name: mcp-kubernetes-proxy
spec: {fqdn: mcp-kubernetes.makeitwork.cloud, target: http://mcp-kubernetes-proxy.mcp.svc:8080}
- name: mcp-parallel-search-remote-proxy
spec: {fqdn: mcp-parallel-search.makeitwork.cloud, target: http://mcp-parallel-search-remote-proxy.mcp.svc:8080}
- name: mcp-playwright-proxy
spec: {fqdn: mcp-playwright.makeitwork.cloud, target: http://mcp-playwright-proxy.mcp.svc:8080}
- name: mcp-slidespeak-remote-proxy
spec: {fqdn: mcp-slidespeak.makeitwork.cloud, target: http://mcp-slidespeak-remote-proxy.mcp.svc:8080}
- name: mcp-terraform-docs-proxy
spec: {fqdn: mcp-terraform-docs.makeitwork.cloud, target: http://mcp-terraform-docs-proxy.mcp.svc:8080}
- name: mcp-twilio-docs-remote-proxy
spec: {fqdn: mcp-twilio-docs.makeitwork.cloud, target: http://mcp-twilio-docs-remote-proxy.mcp.svc:8080}
tunnelRef:
kind: ClusterTunnel
name: cluster-apps
4 changes: 1 addition & 3 deletions workloads/mcp-gateway/twilio-remote-proxy.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
# Public-docs-only Twilio member: proxies Twilio's hosted documentation MCP
# Public-docs-only Twilio backend: proxies Twilio's hosted documentation MCP
# (https://www.twilio.com/docs/ai/mcp) with no MCPExternalAuthConfig, no
# forwarded Authorization header, and no secret references, so it cannot
# reach Twilio account state even if upstream adds account-capable endpoints.
Expand All @@ -14,7 +14,5 @@ metadata:
annotations:
argocd.argoproj.io/sync-wave: "1"
spec:
groupRef:
name: gateway
remoteUrl: https://mcp.twilio.com/docs
transport: streamable-http
16 changes: 0 additions & 16 deletions workloads/mcp-gateway/vmcp.yaml

This file was deleted.

Loading