09fa65a094
Block internal-flavored paths / Block forbidden paths (pull_request) Waiting to run
MCP Stdio Transport Regression / MCP stdio with regular-file stdout (pull_request) Waiting to run
CI / Detect changes (pull_request) Waiting to run
CI / Platform (Go) (pull_request) Waiting to run
CI / Canvas (Next.js) (pull_request) Waiting to run
CI / Shellcheck (E2E scripts) (pull_request) Waiting to run
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Waiting to run
Handlers Postgres Integration / detect-changes (pull_request) Waiting to run
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Waiting to run
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Waiting to run
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Waiting to run
lint-required-no-paths / lint-required-no-paths (pull_request) Waiting to run
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Waiting to run
qa-review / approved (pull_request) Waiting to run
sop-checklist / all-items-acked (pull_request) Waiting to run
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 37s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
E2E API Smoke Test / detect-changes (pull_request) Successful in 3m3s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 37s
publish-runtime-autobump / pr-validate (pull_request) Successful in 1m19s
security-review / approved (pull_request) Failing after 58s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 2m1s
gate-check-v3 / gate-check (pull_request) Waiting to run
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 34s
CI / Python Lint & Test (pull_request) Successful in 9m39s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 3m16s
sop-tier-check / tier-check (pull_request) Successful in 36s
CI / all-required (pull_request) Failing after 40m20s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Has been cancelled
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Has been cancelled
CI / Canvas Deploy Reminder (pull_request) Has been cancelled
a2a_mcp_server.py main()'s stdio read loop used `await loop.run_in_executor(None, stdin.read, 65536)`. On a PIPE, read(n) blocks until n bytes accumulate OR EOF. A live MCP client (openclaw bundle-mcp, Claude Code, Cursor) sends one ~150-byte newline-delimited request and keeps stdin OPEN waiting for the reply, so neither condition is met: the server never parses `initialize` and the client times out (~30s; openclaw: "MCP error -32000: Connection closed"). This silently broke peer visibility for every pipe-spawned MCP host while passing all existing stdio tests, which only fed stdin from a regular file or a heredoc-pipe that CLOSES (EOF returns immediately). readline() returns as soon as one newline-delimited line is available — exactly the JSON-RPC framing — and is backward-compatible with the EOF/file cases. Root cause of the 2026-05-15 openclaw peer-visibility outage (workspace 95744c11): the molecule MCP server could not complete the handshake over openclaw's stdio pipe, so the agent fell back to native sessions_list. The openclaw template adapter fix (template-openclaw#16) works around this via HTTP transport; this patch fixes the stdio root cause so stdio works for all CLI MCP hosts. Regression coverage: - tests/test_a2a_mcp_server.py::TestStdioKeepOpenPipe — spawns the real a2a_mcp_server.py, writes one request over a pipe, and DELIBERATELY keeps stdin open. FAILS (15s timeout, empty response) on read(65536); PASSES on readline(). Verified both directions. - ci-mcp-stdio-transport.yml: new "pipe held OPEN, no EOF" step that reproduces the literal openclaw failure (the prior steps only exercised EOF-closing stdin, which is why the outage shipped green). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
226 lines
8.7 KiB
YAML
226 lines
8.7 KiB
YAML
name: MCP Stdio Transport Regression
|
|
|
|
# Regression test for molecule-ai-workspace-runtime#61:
|
|
# asyncio.connect_read_pipe / connect_write_pipe fail with
|
|
# ValueError: "Pipe transport is only for pipes, sockets and character devices"
|
|
# when stdout is a regular file (openclaw capture, CI tee, debugging).
|
|
#
|
|
# This workflow reproduces the exact failure mode and verifies the
|
|
# fallback to direct buffer I/O works. It runs on every PR that
|
|
# touches the MCP server or this workflow, plus nightly cron.
|
|
#
|
|
# Why a separate workflow (not folded into ci.yml python-lint):
|
|
# - The test needs to spawn the MCP server with stdout redirected
|
|
# to a regular file (not a TTY/pipe), which conflicts with
|
|
# pytest's own capture mechanism.
|
|
# - It exercises the actual process spawn path (python a2a_mcp_server.py)
|
|
# not just unit-test mocks — closer to the real openclaw integration.
|
|
# - A dedicated workflow surfaces stdio-specific regressions without
|
|
# coupling to the broader Python test suite's coverage gate.
|
|
|
|
on:
|
|
pull_request:
|
|
branches: [main, staging]
|
|
paths:
|
|
- 'workspace/a2a_mcp_server.py'
|
|
- 'workspace/mcp_cli.py'
|
|
- 'workspace/tests/test_a2a_mcp_server.py'
|
|
- '.gitea/workflows/ci-mcp-stdio-transport.yml'
|
|
push:
|
|
branches: [main, staging]
|
|
paths:
|
|
- 'workspace/a2a_mcp_server.py'
|
|
- 'workspace/mcp_cli.py'
|
|
- 'workspace/tests/test_a2a_mcp_server.py'
|
|
- '.gitea/workflows/ci-mcp-stdio-transport.yml'
|
|
schedule:
|
|
# Nightly at 04:00 UTC — catches drift from dependency updates
|
|
# (e.g. asyncio behavior changes in new Python patch releases).
|
|
- cron: '0 4 * * *'
|
|
|
|
concurrency:
|
|
group: mcp-stdio-${{ github.ref }}
|
|
cancel-in-progress: true
|
|
|
|
env:
|
|
GITHUB_SERVER_URL: https://git.moleculesai.app
|
|
|
|
jobs:
|
|
# bp-exempt: regression canary for runtime#61; not a merge gate — informational only until promoted to required.
|
|
# mc#774: continue-on-error mask — new workflow, flip to false once it's green on ≥3 consecutive main runs.
|
|
mcp-stdio-regular-file:
|
|
name: MCP stdio with regular-file stdout
|
|
runs-on: ubuntu-latest
|
|
continue-on-error: true # mc#774
|
|
timeout-minutes: 5
|
|
env:
|
|
WORKSPACE_ID: "00000000-0000-0000-0000-000000000001"
|
|
defaults:
|
|
run:
|
|
working-directory: workspace
|
|
steps:
|
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
|
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
|
with:
|
|
python-version: '3.11'
|
|
cache: pip
|
|
cache-dependency-path: workspace/requirements.txt
|
|
- run: pip install -r requirements.txt pytest pytest-asyncio pytest-cov
|
|
|
|
- name: Reproduce runtime#61 — stdout as regular file
|
|
run: |
|
|
set -euo pipefail
|
|
echo "=== Reproducing molecule-ai-workspace-runtime#61 ==="
|
|
echo ""
|
|
echo "Before the fix, this command would fail with:"
|
|
echo ' ValueError: Pipe transport is only for pipes, sockets and character devices'
|
|
echo ""
|
|
|
|
# Spawn the MCP server with stdout redirected to a regular file.
|
|
# This is exactly what openclaw does when capturing MCP output.
|
|
OUTPUT=$(mktemp)
|
|
trap 'rm -f "$OUTPUT"' EXIT
|
|
|
|
# Send initialize request, then tools/list, then exit
|
|
{
|
|
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'
|
|
echo '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
|
|
} | python a2a_mcp_server.py > "$OUTPUT" 2>&1 || {
|
|
RC=$?
|
|
echo "FAIL: MCP server exited with code $RC"
|
|
echo "--- stdout+stderr ---"
|
|
cat "$OUTPUT"
|
|
exit 1
|
|
}
|
|
|
|
echo "PASS: MCP server handled regular-file stdout without crashing"
|
|
echo ""
|
|
echo "--- Output (first 20 lines) ---"
|
|
head -20 "$OUTPUT"
|
|
echo ""
|
|
|
|
# Verify we got valid JSON-RPC responses
|
|
if grep -q '"result"' "$OUTPUT"; then
|
|
echo "PASS: JSON-RPC responses found in output"
|
|
else
|
|
echo "FAIL: No JSON-RPC responses in output"
|
|
cat "$OUTPUT"
|
|
exit 1
|
|
fi
|
|
|
|
- name: Reproduce runtime#61 — stdin from regular file
|
|
run: |
|
|
set -euo pipefail
|
|
echo "=== stdin as regular file (CI tee / capture pattern) ==="
|
|
|
|
INPUT=$(mktemp)
|
|
OUTPUT=$(mktemp)
|
|
trap 'rm -f "$INPUT" "$OUTPUT"' EXIT
|
|
|
|
cat > "$INPUT" <<'EOF'
|
|
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}
|
|
{"jsonrpc":"2.0","id":2,"method":"tools/list"}
|
|
EOF
|
|
|
|
python a2a_mcp_server.py < "$INPUT" > "$OUTPUT" 2>&1 || {
|
|
RC=$?
|
|
echo "FAIL: MCP server exited with code $RC"
|
|
cat "$OUTPUT"
|
|
exit 1
|
|
}
|
|
|
|
echo "PASS: MCP server handled regular-file stdin without crashing"
|
|
|
|
if grep -q '"result"' "$OUTPUT"; then
|
|
echo "PASS: JSON-RPC responses found in output"
|
|
else
|
|
echo "FAIL: No JSON-RPC responses in output"
|
|
cat "$OUTPUT"
|
|
exit 1
|
|
fi
|
|
|
|
- name: Verify warning is emitted for non-pipe stdio
|
|
run: |
|
|
set -euo pipefail
|
|
echo "=== Verify diagnostic warning ==="
|
|
|
|
OUTPUT=$(mktemp)
|
|
trap 'rm -f "$OUTPUT"' EXIT
|
|
|
|
{
|
|
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'
|
|
} | python a2a_mcp_server.py > "$OUTPUT" 2>&1
|
|
|
|
# The warning should mention "not a pipe" for operator visibility
|
|
if grep -qi "not a pipe" "$OUTPUT"; then
|
|
echo "PASS: Diagnostic warning emitted for non-pipe stdio"
|
|
else
|
|
echo "NOTE: No warning in output (may be suppressed by log level)"
|
|
fi
|
|
|
|
- name: Reproduce openclaw failure — pipe held OPEN, no EOF
|
|
run: |
|
|
set -euo pipefail
|
|
echo "=== keep-stdin-open pipe (the real openclaw / Claude Code case) ==="
|
|
echo ""
|
|
echo "Before the readline() fix this HANGS: main() did"
|
|
echo " stdin.read(65536) -> on a pipe, blocks until 64KB OR EOF."
|
|
echo "An MCP client sends one ~150B initialize and keeps stdin"
|
|
echo "open waiting for the response, so the server never parsed"
|
|
echo "the request and the client timed out (openclaw: 'MCP error"
|
|
echo "-32000: Connection closed'). The earlier regular-file /"
|
|
echo "heredoc-pipe steps PASSED through this bug because a file"
|
|
echo "(or a closing heredoc) yields EOF immediately."
|
|
echo ""
|
|
|
|
# Drive the server through a real pipe that stays OPEN: write
|
|
# one initialize, do NOT close stdin, and require a response
|
|
# within a hard timeout. read(65536) -> no output -> timeout
|
|
# kills it -> FAIL. readline() -> immediate response -> PASS.
|
|
python - <<'PYEOF'
|
|
import json, subprocess, sys, time, select
|
|
|
|
proc = subprocess.Popen(
|
|
[sys.executable, "a2a_mcp_server.py"],
|
|
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
env={**__import__("os").environ},
|
|
)
|
|
req = json.dumps({
|
|
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
|
"params": {"protocolVersion": "2024-11-05",
|
|
"capabilities": {},
|
|
"clientInfo": {"name": "keepopen", "version": "1"}},
|
|
}) + "\n"
|
|
proc.stdin.write(req.encode())
|
|
proc.stdin.flush()
|
|
# Deliberately DO NOT close proc.stdin — mirror a live MCP client.
|
|
|
|
deadline = time.time() + 15
|
|
line = b""
|
|
while time.time() < deadline:
|
|
r, _, _ = select.select([proc.stdout], [], [], 1)
|
|
if r:
|
|
line = proc.stdout.readline()
|
|
if line:
|
|
break
|
|
proc.kill()
|
|
|
|
if not line:
|
|
print("FAIL: no response within 15s on an open pipe — "
|
|
"stdin.read(65536) regression is back")
|
|
sys.exit(1)
|
|
resp = json.loads(line.decode())
|
|
assert resp.get("id") == 1 and "result" in resp, \
|
|
f"unexpected response: {line[:200]!r}"
|
|
assert resp["result"]["serverInfo"]["name"] == "molecule", \
|
|
f"wrong serverInfo: {line[:200]!r}"
|
|
print("PASS: server answered initialize on a still-open pipe")
|
|
PYEOF
|
|
|
|
- name: Run unit tests for stdio transport
|
|
run: |
|
|
set -euo pipefail
|
|
echo "=== Running stdio transport unit tests ==="
|
|
python -m pytest tests/test_a2a_mcp_server.py::TestStdioPipeAssertion tests/test_a2a_mcp_server.py::TestStdioKeepOpenPipe -v --no-cov
|