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
139 changes: 139 additions & 0 deletions platform-gitops/services/labs/openclaw/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: |-
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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"
}
Comment on lines +1541 to +1543

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Pass TRADING_DATA_TOKEN to trading-data MCP process

trading-data is configured with only TRADING_DATA_URL in mcp.servers.trading-data.env, but the proxy script requires both TRADING_DATA_URL and TRADING_DATA_TOKEN and immediately returns an error when the token is missing. In this same file, the setup comment explains that the bundle-mcp path uses mcp.servers.<name>.env verbatim for the spawned process, so pod-level envFrom secrets are not sufficient here; as committed, every call to this MCP server will fail with the proxy-not-configured error instead of reaching labs-trading-data.

Useful? React with 👍 / 👎.

}
}
}
Expand Down
30 changes: 30 additions & 0 deletions platform-gitops/services/labs/trading-data/catalog-info.yaml
Original file line number Diff line number Diff line change
@@ -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
141 changes: 141 additions & 0 deletions platform-gitops/services/labs/trading-data/values.yaml
Original file line number Diff line number Diff line change
@@ -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