diff --git a/platform-gitops/services/labs/openclaw/values.yaml b/platform-gitops/services/labs/openclaw/values.yaml index 527eabf2c..d02783251 100644 --- a/platform-gitops/services/labs/openclaw/values.yaml +++ b/platform-gitops/services/labs/openclaw/values.yaml @@ -101,6 +101,11 @@ envFrom: # the fallback is a node bridge script that reads process.env directly. - secretRef: name: labs-openclaw-coinglass + # TRADING_DATA_TOKEN — bearer for the labs-trading-data MCP service + # (Bybit/Bitget WS taker + CryptoPanic/Etherscan REST). Read from + # process.env by /scripts/trading-data-mcp-proxy.js below. + - secretRef: + name: labs-trading-data-client initContainers: # 1. Restore state from MinIO (no-op for new tenants, preserves OAuth credentials on restart) - name: restore-state @@ -738,6 +743,17 @@ extraExternalSecrets: - secretKey: COINGLASS_API_KEY remoteKey: secret/data/teams/labs/openclaw/coinglass property: api_key + # Bearer for the in-cluster labs-trading-data MCP service. Same Vault path + # is consumed server-side by services/labs/trading-data/values.yaml — the + # two ExternalSecrets read the same `bearer_token` field so client and + # server agree on the token without manual rotation across two repos. + labs-trading-data-client: + refreshInterval: 1h + targetSecret: labs-trading-data-client + data: + - secretKey: TRADING_DATA_TOKEN + remoteKey: secret/data/teams/labs/trading-data/client-token + property: bearer_token configMaps: openclaw-scripts: mcp-agent-proxy.js: |- @@ -1055,6 +1071,122 @@ configMaps: errorReply(err && err.message ? err.message : err); } } + # stdio -> HTTP+Bearer bridge for the in-cluster labs-trading-data MCP + # service. The claude-cli backend's `bundle-mcp` step accepts only stdio + # MCP servers (it silently skips streamable-HTTP / SSE), so this + # ~40-line proxy converts stdio JSON-RPC into Bearer-auth POSTs against + # the FastAPI streamable-HTTP endpoint. The trading-data service is + # stateless from the client side (no session-id, no protocol-version + # exchange beyond initialize) so this bridge is much simpler than the + # coinglass one — no auth refresh, no SSE re-framing, no env-var header + # interpolation. URL + token come from process.env (envFrom -> + # labs-trading-data-client + openclaw.json env passthrough below). + trading-data-mcp-proxy.js: |- + #!/usr/bin/env node + const url = process.env.TRADING_DATA_URL; + const token = process.env.TRADING_DATA_TOKEN; + let queue = Promise.resolve(); + process.stdin.setEncoding('utf8'); + let buf = ''; + process.stdin.on('data', chunk => { + buf += chunk; + let nl; + while ((nl = buf.indexOf('\n')) !== -1) { + const line = buf.slice(0, nl).trim(); + buf = buf.slice(nl + 1); + if (line) queue = queue.then(() => handle(line)).catch(() => {}); + } + }); + function writeLine(obj) { process.stdout.write(JSON.stringify(obj) + '\n'); } + function isNotification(req) { + // JSON-RPC 2.0: a notification is a request object with no `id` + // member at all (e.g. `notifications/initialized`). It expects no + // response. We must NOT emit unsolicited error frames for these + // -- a stdio MCP client tracking ids would treat them as orphan + // replies (codex P2 round 2 on PR #137). + return !req || !Object.prototype.hasOwnProperty.call(req, 'id'); + } + function rpcError(req, message) { + if (isNotification(req)) return; + writeLine({ jsonrpc: '2.0', id: req.id, error: { code: -32603, message: String(message) } }); + } + // Parse a streamable-HTTP / SSE body into the JSON payloads of each + // event block. SSE allows one event to span multiple `data:` lines + // that the spec requires concatenating with `\n` before parsing; + // splitting per-line and JSON.parsing each fragment incorrectly + // discards every multi-line event (codex P2 round 2 on PR #137). + // Event blocks are separated by blank lines (one or more CRLF/LF). + function parseSseEvents(text) { + const out = []; + for (const block of text.split(/\r?\n\r?\n/)) { + if (!block.trim()) continue; + const dataLines = []; + for (const raw of block.split(/\r?\n/)) { + // Per spec: a leading single SP after the colon is part of + // formatting, not data. Strip exactly one leading space. + if (!raw.startsWith('data:')) continue; + const value = raw.slice(5); + dataLines.push(value.startsWith(' ') ? value.slice(1) : value); + } + if (!dataLines.length) continue; + const payload = dataLines.join('\n'); + if (!payload.trim()) continue; + try { JSON.parse(payload); } catch (_) { continue; } + out.push(payload); + } + return out; + } + async function handle(line) { + let req; + try { req = JSON.parse(line); } catch (_) { return; } + if (!url || !token) { + rpcError(req, 'trading-data proxy not configured (TRADING_DATA_URL/TOKEN missing)'); + return; + } + try { + const res = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + 'Authorization': `Bearer ${token}`, + }, + body: line, + }); + const text = await res.text(); + const contentType = (res.headers.get('content-type') || '').toLowerCase(); + if (contentType.includes('text/event-stream')) { + const payloads = parseSseEvents(text); + for (const p of payloads) process.stdout.write(p + '\n'); + if (payloads.length === 0) { + rpcError(req, `trading-data returned no parseable SSE payload (status ${res.status})`); + } + return; + } + // Non-SSE: validate that the body is a non-empty JSON document + // before passing through. Empty bodies, gateway HTML error + // pages, or upstream timeouts must surface as structured + // JSON-RPC errors so the agent retries cleanly instead of + // hitting a stdio framing exception (codex P2 round 1). + if (!text.trim()) { + // 202 Accepted with no body is the canonical reply to a + // notification. Skip silently in that case. + if (!isNotification(req)) { + rpcError(req, `trading-data returned empty body (status ${res.status})`); + } + return; + } + let parsed; + try { parsed = JSON.parse(text); } catch (_) { + const preview = text.length > 80 ? text.slice(0, 80) + '...' : text; + rpcError(req, `trading-data returned non-JSON body (status ${res.status}, content-type=${contentType || 'unknown'}, body=${preview})`); + return; + } + process.stdout.write(JSON.stringify(parsed) + '\n'); + } catch (err) { + rpcError(req, err && err.message ? err.message : err); + } + } github-pr-mcp.js: |- #!/usr/bin/env node const { Buffer } = require('buffer'); @@ -1402,6 +1534,13 @@ configMaps: "env": { "COINGLASS_API_KEY": "__COINGLASS_API_KEY__" } + }, + "trading-data": { + "command": "node", + "args": ["/scripts/trading-data-mcp-proxy.js"], + "env": { + "TRADING_DATA_URL": "http://labs-trading-data.labs.svc.cluster.local:8080/mcp" + } } } } diff --git a/platform-gitops/services/labs/trading-data/catalog-info.yaml b/platform-gitops/services/labs/trading-data/catalog-info.yaml new file mode 100644 index 000000000..527475309 --- /dev/null +++ b/platform-gitops/services/labs/trading-data/catalog-info.yaml @@ -0,0 +1,30 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: trading-data + namespace: labs + description: "Bybit + Bitget WS taker aggregation + CryptoPanic + Etherscan MCP service for labs-openclaw" + annotations: + backstage.io/kubernetes-label-selector: app.kubernetes.io/instance=labs-trading-data + backstage.io/kubernetes-id: trading-data + backstage.io/kubernetes-namespace: labs + argocd/app-name: labs-trading-data + github.com/source-repo: mctlhq/mctl-trading-data + # Auto-deploy disabled — image bumps land via manual mctl_deploy_service + # so the operator can stage Vault changes for new tools alongside. + mctl.me/auto-deploy: "false" + links: + - url: https://ops.mctl.ai/applications/argocd/labs-trading-data + title: ArgoCD + icon: dashboard + - url: https://github.com/mctlhq/mctl-trading-data + title: Source Repository + icon: github + labels: + team: labs + env: apps +spec: + type: service + lifecycle: production + owner: group:default/labs + system: system:default/team-labs diff --git a/platform-gitops/services/labs/trading-data/values.yaml b/platform-gitops/services/labs/trading-data/values.yaml new file mode 100644 index 000000000..37e4f6aa7 --- /dev/null +++ b/platform-gitops/services/labs/trading-data/values.yaml @@ -0,0 +1,141 @@ +# Service: trading-data +# Team: labs +# Chart: base-service +# +# Stateful WebSocket service that aggregates Bybit V5 + Bitget V2 public-trade +# streams into rolling 24h taker buckets and exposes them — alongside +# CryptoPanic news + Etherscan gas/whale REST helpers — as MCP tools over +# streamable-HTTP with Bearer auth. Consumed by labs-openclaw via the +# `trading-data-mcp-proxy.js` stdio bridge declared in the openclaw chart. +# +# replicaCount=1 is a HARD INVARIANT — buckets live in process memory. +# Scaling > 1 requires WS leader election + Redis-backed buckets first. + +image: + repository: ghcr.io/mctlhq/mctl-trading-data + tag: "0.2.0" +imagePullSecrets: + - name: ghcr-credentials +service: + port: 8080 +backstage: + kubernetesId: trading-data +replicaCount: 1 +autoscaling: + enabled: false +resources: + requests: + cpu: 200m + memory: 384Mi + limits: + cpu: 1000m + memory: 768Mi +env: + APP_ENV: production + LOG_LEVEL: info + WATCH_SYMBOLS: "ETHUSDT" +envFrom: + # CryptoCompare News + Etherscan free-tier API keys; sourced from Vault at + # secret/data/teams/labs/trading-data/{cryptocompare,etherscan}. CryptoPanic + # was the original choice but its free tier was discontinued April 2026 — + # CryptoCompare News v2 (Apikey header auth, ~100K calls/month free) is + # the drop-in replacement that mctl-trading-data 0.2.0 ships against. + - secretRef: + name: labs-trading-data-upstream + # TRADING_DATA_TOKEN — server-side bearer; same secret value also lands in + # labs-openclaw via the matching client-side ExternalSecret in that chart. + - secretRef: + name: labs-trading-data-server +probes: + liveness: + path: /healthz + port: http + initialDelaySeconds: 60 + periodSeconds: 30 + readiness: + path: /readyz + port: http + initialDelaySeconds: 30 + periodSeconds: 10 +ingress: + enabled: false +extraObjects: + # Pod-to-pod only — no ingress controller, no inter-tenant traffic. + # Egress is restricted to public internet (443) + cluster DNS (kube-system). + - apiVersion: networking.k8s.io/v1 + kind: NetworkPolicy + metadata: + name: labs-trading-data + spec: + podSelector: + matchLabels: + app.kubernetes.io/instance: labs-trading-data + policyTypes: + - Ingress + - Egress + ingress: + - from: + - podSelector: + matchLabels: + app.kubernetes.io/instance: labs-openclaw + ports: + - protocol: TCP + port: 8080 + egress: + # Cluster DNS + - to: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 + # Public internet (exchanges + APIs); RFC1918 ranges denied. + - to: + - ipBlock: + cidr: 0.0.0.0/0 + except: + - 10.0.0.0/8 + - 172.16.0.0/12 + - 192.168.0.0/16 + ports: + - protocol: TCP + port: 443 +extraExternalSecrets: + ghcr-credentials: + refreshInterval: 24h + targetSecret: ghcr-credentials + targetTemplateType: kubernetes.io/dockerconfigjson + targetTemplateMetadataAnnotations: + argocd.argoproj.io/compare-options: IgnoreExtraneous + data: + - secretKey: .dockerconfigjson + remoteKey: platform/backstage/ghcr-credentials + property: dockerconfigjson + # Free-tier upstream API keys. Operator provisions these in Vault at + # secret/data/teams/labs/trading-data/{cryptocompare,etherscan} (field + # `api_key`). NEWS_API_KEY is consumed by mctl-trading-data's news.py + # (CryptoCompare News v2 since 0.2.0) — vendor-neutral env name so a + # future swap stays a one-file change. + labs-trading-data-upstream: + refreshInterval: 1h + targetSecret: labs-trading-data-upstream + data: + - secretKey: NEWS_API_KEY + remoteKey: secret/data/teams/labs/trading-data/cryptocompare + property: api_key + - secretKey: ETHERSCAN_API_KEY + remoteKey: secret/data/teams/labs/trading-data/etherscan + property: api_key + # Server-side bearer for incoming MCP requests. Same Vault path is consumed + # client-side by labs-openclaw — see ExternalSecret labs-trading-data-client + # in services/labs/openclaw/values.yaml. + labs-trading-data-server: + refreshInterval: 1h + targetSecret: labs-trading-data-server + data: + - secretKey: TRADING_DATA_TOKEN + remoteKey: secret/data/teams/labs/trading-data/client-token + property: bearer_token