Files
damascus-orchestrator/scripts/_verify_mcp_helper.py
hermes-kanban 79e3e59ab5 feat(verify): P6a manual verification recipe + verify.sh
scripts/verify.sh — bash E2E smoke that proves 'v1 works' without a browser.
8 sections (preflight, stack-up, mcp-stdio, ingest-via-mcp, ui-shows-it,
drive-cycle, cleanup, summary); exits non-zero on first failure. Drives
phase transitions via direct SQL to bypass the orchestrator worker's claim
loop. Cleans up its own rows so re-runs are idempotent.

scripts/_verify_mcp_helper.py — Python MCP stdio helper used by verify.sh.
Drives python -m damascus.mcp_server via the official mcp SDK client and
frames the JSON-RPC handshake + tools/list + ingest_story so bash does
not have to manage Content-Length headers or heredoc framing.

docs/VERIFICATION.md — <1 page runnable-by-hand recipe plus architecture
notes (token source, MCP upstream DNS, why direct SQL, failure modes).

Verified end-to-end: bash scripts/verify.sh exits 0 against the live stack
(7/7 sections green; log at .hermes/evidence/p6a/verify.log, gitignored).
tests/contract + tests/unit still 56/56 green.
2026-06-26 07:03:45 +00:00

179 lines
5.8 KiB
Python
Executable File

"""Damascus MCP stdio helper for scripts/verify.sh.
Drives ``python -m damascus.mcp_server`` over stdio via the official
``mcp`` SDK client. The MCP server is a thin wrapper around
``damascus-api`` (loopback HTTP); this helper just frames the JSON-RPC
for the bash wrapper script so the bash doesn't have to manage
heredocs, Content-Length headers, or mcp SDK imports.
Subcommands
-----------
``initialize``
Send the MCP ``initialize`` handshake; print server name + version
as a single JSON line on stdout.
``list-tools``
Send ``tools/list`` after the handshake; print the sorted tool
name list + count as a single JSON line.
``ingest-story PROJECT STORY_ID TITLE PRIORITY``
Call ``tools/call ingest_story`` and print
``{"server_name": ..., "payload": <API response>}``.
Auth
----
The helper reads ``DAMASCUS_API_TOKEN`` from the shell env, falling back
to ``/root/.hermes/.env`` (the same source ``damascus-api`` itself
reads). The MCP process is launched via ``docker compose exec
damascus-api python -m damascus.mcp_server`` and inherits ``DAMASCUS_API_BASE=http://damascus-api:9110`` so the container DNS
resolves the upstream.
Exit codes
----------
``0`` on success, ``1`` on a runtime error, ``2`` on bad arguments.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import sys
from pathlib import Path
from mcp import ClientSession
from mcp.client.stdio import StdioServerParameters, stdio_client
# Silence the SDK's "Tool <name> not listed, no validation will be
# performed" warning emitted on every call_tool. The MCP server declares
# `ingest_story` in its catalog but the SDK's structured-output validator
# still complains because the server does not return a `structuredContent`
# block (it returns the API payload as TextContent). Validation is
# not actionable here — the bash wrapper asserts the JSON shape itself.
logging.getLogger("mcp.client.session").setLevel(logging.ERROR)
ENV_FILE = Path("/root/.hermes/.env")
COMPOSE_FILE = "/root/damascus-orchestrator/docker-compose.yml"
TOKEN_KEY = "DAMASCUS_API_TOKEN"
def _load_token() -> str:
token = os.environ.get(TOKEN_KEY, "").strip()
if token:
return token
if not ENV_FILE.exists():
return ""
for raw in ENV_FILE.read_text().splitlines():
line = raw.strip()
if line.startswith("export "):
line = line[len("export "):].lstrip()
if not line.startswith(TOKEN_KEY + "="):
continue
val = line.split("=", 1)[1].strip()
if (val.startswith("'") and val.endswith("'")) or (
val.startswith('"') and val.endswith('"')
):
val = val[1:-1]
return val
return ""
def _stdio_params() -> StdioServerParameters:
token = _load_token()
if not token:
print(f"[verify-mcp] {TOKEN_KEY} not found in env or {ENV_FILE}", file=sys.stderr)
sys.exit(2)
# The MCP process runs inside damascus-api (via `docker compose exec`),
# so it needs the container-DNS upstream URL — not localhost:9110.
api_base = os.environ.get("DAMASCUS_API_BASE_FOR_MCP", "http://damascus-api:9110")
return StdioServerParameters(
command="docker",
args=[
"compose",
"-f",
COMPOSE_FILE,
"exec",
"-T",
"damascus-api",
"python",
"-m",
"damascus.mcp_server",
],
env={
**os.environ,
"DAMASCUS_API_BASE": api_base,
TOKEN_KEY: token,
},
)
async def _run(sub: str, rest: list[str]) -> int:
params = _stdio_params()
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
init = await session.initialize()
server_name = init.serverInfo.name
if sub == "initialize":
print(json.dumps({
"server_name": server_name,
"server_version": init.serverInfo.version,
}))
return 0
if sub == "list-tools":
tools = await session.list_tools()
names = sorted(t.name for t in tools.tools)
print(json.dumps({
"server_name": server_name,
"tool_names": names,
"tool_count": len(names),
}))
return 0
if sub == "ingest-story":
if len(rest) < 4:
print(
"[verify-mcp] ingest-story requires "
"PROJECT STORY_ID TITLE PRIORITY",
file=sys.stderr,
)
return 2
project, story_id, title, priority = rest[:4]
res = await session.call_tool(
"ingest_story",
arguments={
"project": project,
"story_id": story_id,
"title": title,
"priority": int(priority),
},
)
if not res.content:
print("[verify-mcp] empty content from ingest_story", file=sys.stderr)
return 1
payload = json.loads(res.content[0].text)
print(json.dumps({"server_name": server_name, "payload": payload}))
return 0
print(f"[verify-mcp] unknown subcommand: {sub!r}", file=sys.stderr)
return 2
def main() -> int:
if len(sys.argv) < 2:
print(__doc__, file=sys.stderr)
return 2
sub = sys.argv[1]
rest = sys.argv[2:]
try:
return asyncio.run(_run(sub, rest))
except Exception as exc:
print(f"[verify-mcp] {type(exc).__name__}: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())