From 4e68176492cf37161fdc84e94169dae6f40aeb84 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 24 Aug 2026 22:10:15 +0200 Subject: [PATCH 1/2] fix(cli): correct task status keys in statehub status cmd_status read tasks['in_progress'] and tasks['blocked']; the task vocabulary is wait|todo|progress|done|cancel and the totals block never carried those two keys, so the command always raised KeyError. Also accept either the workplans or legacy workstreams totals key, and print the resolved API base so the operator can see which hub instance answered. Refs CUST-WP-0067-T01 Co-Authored-By: Claude Opus 5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 2583210@bnt-lap001 Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006 --- custodian_cli.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/custodian_cli.py b/custodian_cli.py index 56b7fe6..361a1ec 100644 --- a/custodian_cli.py +++ b/custodian_cli.py @@ -560,10 +560,16 @@ def cmd_status(_args: argparse.Namespace) -> None: print(f"API: {health.get('status', '?')} DB: {health.get('db', '?')}") summary = _api_get("/state/summary") t = summary["totals"] - print(f"Topics: {t['topics']['active']} active") - print(f"Workstreams: {t['workstreams']['active']} active, {t['workstreams']['blocked']} blocked") - print(f"Tasks: {t['tasks']['in_progress']} in-progress, {t['tasks']['todo']} todo, {t['tasks']['blocked']} blocked") - print(f"Decisions: {t['decisions']['open']} open, {t['decisions']['escalated']} escalated") + topics = t.get("topics", {}) + workplans = t.get("workplans") or t.get("workstreams", {}) + tasks = t.get("tasks", {}) + decisions = t.get("decisions", {}) + print(f"API base: {API_BASE}") + print(f"Topics: {topics.get('active', 0)} active") + print(f"Workplans: {workplans.get('active', 0)} active, {workplans.get('blocked', 0)} blocked") + # Task statuses are wait|todo|progress|done|cancel (see workplan-convention.md). + print(f"Tasks: {tasks.get('progress', 0)} in-progress, {tasks.get('todo', 0)} todo, {tasks.get('wait', 0)} waiting") + print(f"Decisions: {decisions.get('open', 0)} open, {decisions.get('escalated', 0)} escalated") blocking = summary.get("blocking_decisions", []) if blocking: print(f"\nBlocking decisions ({len(blocking)}):") From 6d0454436831812eacd81bb9149c1afcbb506c29 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 24 Aug 2026 22:54:24 +0200 Subject: [PATCH 2/2] feat(mcp): deploy the MCP layer on central instead of tunnelling to a workstation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No MCP server ran on central, so remote agents reached dev-hub only through a reverse tunnel back to the workstation — routing a request for a service on their own machine out to another host and back. Adds a gated mcp Deployment and ClusterIP Service running the same image with `-m mcp_server.server`, API_BASE defaulted to the in-cluster API Service, and tcpSocket probes. No Ingress: the MCP layer proxies an unauthenticated API and must not be reachable from outside the cluster. Two fixes were needed before the manifests could work: - server.py hardcoded host="127.0.0.1". A Service routes to the pod IP, so a loopback bind is unreachable. Now MCP_HOST, still defaulting to loopback so local runs do not silently expose an unauthenticated proxy. - The container runs `-m mcp_server.server`, not the file path, so /app lands on sys.path rather than /app/mcp_server. mcp.enabled stays false in the deploy values: the running image predates MCP_HOST, so enabling it before the tag is bumped would ship a pod that never becomes reachable. Refs CUST-WP-0067-T08 Co-Authored-By: Claude Opus 5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 2583210@bnt-lap001 Assistant-Session: f2bff2d5-e9b2-4338-92ca-10282a927006 --- .../charts/state-hub/templates/_helpers.tpl | 30 +++++++- .../state-hub/templates/mcp-deployment.yaml | 75 +++++++++++++++++++ .../state-hub/templates/mcp-service.yaml | 17 +++++ .../apps/charts/state-hub/values.yaml | 32 ++++++++ mcp_server/server.py | 7 +- 5 files changed, 159 insertions(+), 2 deletions(-) create mode 100644 deploy/railiance/apps/charts/state-hub/templates/mcp-deployment.yaml create mode 100644 deploy/railiance/apps/charts/state-hub/templates/mcp-service.yaml diff --git a/deploy/railiance/apps/charts/state-hub/templates/_helpers.tpl b/deploy/railiance/apps/charts/state-hub/templates/_helpers.tpl index f02f9f4..d6af21c 100644 --- a/deploy/railiance/apps/charts/state-hub/templates/_helpers.tpl +++ b/deploy/railiance/apps/charts/state-hub/templates/_helpers.tpl @@ -23,4 +23,32 @@ app: {{ include "statehub.fullname" . }} {{- fail "image.tag is required - pin it in deploy/railiance/apps/helm/state-hub-values.yaml or pass --set image.tag=" -}} {{- end -}} {{- printf "%s:%s" .Values.image.repository .Values.image.tag -}} -{{- end -}} \ No newline at end of file +{{- end -}} + +{{- define "statehub.mcpFullname" -}} +{{- printf "%s-mcp" (include "statehub.fullname" .) | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "statehub.mcpLabels" -}} +app: {{ include "statehub.mcpFullname" . }} +app.kubernetes.io/name: {{ include "statehub.mcpFullname" . }} +app.kubernetes.io/component: mcp +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/part-of: railiance-apps +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }} +railiance.io/layer: s5-app +{{- end -}} + +{{- define "statehub.mcpSelectorLabels" -}} +app: {{ include "statehub.mcpFullname" . }} +{{- end -}} + +{{- define "statehub.mcpApiBase" -}} +{{- if .Values.mcp.apiBase -}} +{{- .Values.mcp.apiBase -}} +{{- else -}} +{{- printf "http://%s:%v" (include "statehub.fullname" .) .Values.service.port -}} +{{- end -}} +{{- end -}} diff --git a/deploy/railiance/apps/charts/state-hub/templates/mcp-deployment.yaml b/deploy/railiance/apps/charts/state-hub/templates/mcp-deployment.yaml new file mode 100644 index 0000000..bd17034 --- /dev/null +++ b/deploy/railiance/apps/charts/state-hub/templates/mcp-deployment.yaml @@ -0,0 +1,75 @@ +{{- if .Values.mcp.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "statehub.mcpFullname" . }} + labels: {{- include "statehub.mcpLabels" . | nindent 4 }} +spec: + replicas: {{ .Values.mcp.replicaCount }} + selector: + matchLabels: {{- include "statehub.mcpSelectorLabels" . | nindent 6 }} + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + template: + metadata: + labels: {{- include "statehub.mcpLabels" . | nindent 8 }} + spec: + securityContext: {{- toYaml .Values.podSecurityContext | nindent 8 }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: {{- toYaml . | nindent 8 }} + {{- end }} + containers: + - name: state-hub-mcp + image: {{ include "statehub.image" . | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: {{- toYaml .Values.securityContext | nindent 12 }} + # The image CMD serves the API; this pod runs the MCP layer instead. + # Use -m so the WORKDIR (/app) lands on sys.path — invoking the file + # directly would put /app/mcp_server there and break its own imports. + command: ["/app/.venv/bin/python", "-m", "mcp_server.server"] + ports: + - name: sse + containerPort: {{ .Values.mcp.service.targetPort }} + protocol: TCP + env: + - name: MCP_TRANSPORT + value: {{ .Values.mcp.transport | quote }} + - name: MCP_PORT + value: {{ .Values.mcp.service.targetPort | quote }} + # A Service routes to the pod IP, so the default loopback bind + # would be unreachable. + - name: MCP_HOST + value: "0.0.0.0" + # Stateless HTTP client over the API — no database access. + - name: API_BASE + value: {{ include "statehub.mcpApiBase" . | quote }} + {{- if .Values.mcp.probes.enabled }} + readinessProbe: + tcpSocket: + port: {{ .Values.mcp.service.targetPort }} + initialDelaySeconds: {{ .Values.mcp.probes.readiness.initialDelaySeconds }} + periodSeconds: {{ .Values.mcp.probes.readiness.periodSeconds }} + timeoutSeconds: {{ .Values.mcp.probes.readiness.timeoutSeconds }} + failureThreshold: {{ .Values.mcp.probes.readiness.failureThreshold }} + livenessProbe: + tcpSocket: + port: {{ .Values.mcp.service.targetPort }} + initialDelaySeconds: {{ .Values.mcp.probes.liveness.initialDelaySeconds }} + periodSeconds: {{ .Values.mcp.probes.liveness.periodSeconds }} + timeoutSeconds: {{ .Values.mcp.probes.liveness.timeoutSeconds }} + failureThreshold: {{ .Values.mcp.probes.liveness.failureThreshold }} + {{- end }} + resources: {{- toYaml .Values.mcp.resources | nindent 12 }} + {{- with .Values.nodeSelector }} + nodeSelector: {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} diff --git a/deploy/railiance/apps/charts/state-hub/templates/mcp-service.yaml b/deploy/railiance/apps/charts/state-hub/templates/mcp-service.yaml new file mode 100644 index 0000000..7b6ce33 --- /dev/null +++ b/deploy/railiance/apps/charts/state-hub/templates/mcp-service.yaml @@ -0,0 +1,17 @@ +{{- if .Values.mcp.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "statehub.mcpFullname" . }} + labels: {{- include "statehub.mcpLabels" . | nindent 4 }} +spec: + # ClusterIP only, and deliberately no Ingress: the MCP layer proxies an + # unauthenticated API and must not be reachable from outside the cluster. + type: ClusterIP + ports: + - port: {{ .Values.mcp.service.port }} + targetPort: {{ .Values.mcp.service.targetPort }} + protocol: TCP + name: sse + selector: {{- include "statehub.mcpSelectorLabels" . | nindent 4 }} +{{- end }} diff --git a/deploy/railiance/apps/charts/state-hub/values.yaml b/deploy/railiance/apps/charts/state-hub/values.yaml index eda4f17..a409637 100644 --- a/deploy/railiance/apps/charts/state-hub/values.yaml +++ b/deploy/railiance/apps/charts/state-hub/values.yaml @@ -47,6 +47,38 @@ ingress: traefik.ingress.kubernetes.io/router.tls: "true" cert-manager.io/cluster-issuer: letsencrypt-prod +# MCP layer (CUST-WP-0067-T08). Runs from the same image with a different +# command, as a stateless HTTP client over the API service. ClusterIP only — +# it proxies an unauthenticated API, so it must never gain an Ingress. +mcp: + enabled: false + replicaCount: 1 + transport: sse + # Defaults to the in-cluster API Service; override only to point elsewhere. + apiBase: "" + service: + port: 8001 + targetPort: 8001 + probes: + enabled: true + liveness: + initialDelaySeconds: 30 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + readiness: + initialDelaySeconds: 10 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + resources: + requests: + cpu: 50m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + probes: enabled: true path: /state/health diff --git a/mcp_server/server.py b/mcp_server/server.py index 5cedae9..51469c2 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -3212,4 +3212,9 @@ if __name__ == "__main__": mcp.run(transport="stdio") else: port = int(os.environ.get("MCP_PORT", "8001")) - mcp.run(transport=transport, host="127.0.0.1", port=port) + # Default to loopback: this server is an unauthenticated proxy over the + # API, so it must not land on every interface by accident. In-cluster + # deployment sets MCP_HOST=0.0.0.0 because a Service routes to the pod + # IP, and a loopback bind is unreachable there (CUST-WP-0067-T08). + host = os.environ.get("MCP_HOST", "127.0.0.1") + mcp.run(transport=transport, host=host, port=port)