2.2.0 — echo-mcp: the containerized MCP server
Build and Push Docker Image / build (push) Successful in 23s
Build and Push Docker Image / build (push) Successful in 23s
ECHO as 14 typed MCP tools (streamable HTTP, stateless JSON, bearer auth, open /health) wrapping the 2.1.1 *_op cores in-process: - mcp-server/app.py: FastMCP app; duplicate gate + offline queueing surface as DATA (merge_into/force are parameters, never blind retries); recall packs excerpts into budget_chars by score; get_note is traversal-guarded with section/max_chars; patch_note enriches invalid-target errors with the note's actual headings; log_session wraps the session-end bundle (heartbeat-last); ECHO_MCP_TOOLS=core exposes only the six daily drivers; all MCP writes serialize in-process; startup fails fast on missing env. - Dockerfile (legacy format — no BuildKit on the CI runner), python:3.12-slim, healthcheck probes 127.0.0.1; .dockerignore keeps the context lean. - .gitea/workflows/docker-build.yml: standard image build; PORT redeploy trigger targets the echo-mcp container explicitly (repo name is echo). - deploy.unraid.yml: br0/auto-IP, /data volume, vault adjacency (ECHO_BASE=http://10.2.0.35:27123), secrets as SECRET: refs. - SKILL.md: prefer the echo_* tools when the connector is present; CLI recipes stay as the fallback. Spec header marked BUILT (tool count corrected to 14). - eval/test_mcp_server.py: e2e over real streamable HTTP (health/auth/ initialize/tools-list + capture->gate->merge->recall->log_session); skips cleanly when the SDK is absent. All seven suites green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env python3
|
||||
"""test_mcp_server.py — echo-mcp (2.2) end-to-end against the mock vault.
|
||||
|
||||
Spawns mcp-server/app.py + mock_olrapi and drives the real streamable-HTTP MCP
|
||||
protocol: health (open), auth (401), initialize, tools/list, and the memory-day
|
||||
tools/call flow (capture -> duplicate gate as DATA -> merge_into -> recall ->
|
||||
get_note -> log_session with heartbeat commit).
|
||||
|
||||
Requires the `mcp` package in the interpreter that runs the SERVER. Set
|
||||
ECHO_MCP_PYTHON to a venv python that has it; otherwise the current interpreter
|
||||
is tried and the suite SKIPS (exit 0) when the SDK is absent — the other suites
|
||||
don't depend on it.
|
||||
|
||||
Run: python test_mcp_server.py [--port 8862] [--mcp-port 8767]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
APP = HERE.parent / "mcp-server" / "app.py"
|
||||
KEY = "test-key-not-a-real-secret"
|
||||
TOKEN = "local-test-token"
|
||||
|
||||
failures = []
|
||||
|
||||
|
||||
def check(name, cond, detail=""):
|
||||
print(f"{'ok ' if cond else 'FAIL'} {name}" + (f" -- {detail}" if not cond else ""))
|
||||
if not cond:
|
||||
failures.append(name)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--port", type=int, default=8862)
|
||||
ap.add_argument("--mcp-port", type=int, default=8767)
|
||||
a = ap.parse_args()
|
||||
mock_base = f"http://127.0.0.1:{a.port}"
|
||||
mcp_url = f"http://127.0.0.1:{a.mcp_port}/mcp"
|
||||
|
||||
server_py = os.environ.get("ECHO_MCP_PYTHON") or sys.executable
|
||||
probe = subprocess.run([server_py, "-c", "import mcp"], capture_output=True)
|
||||
if probe.returncode != 0:
|
||||
print("SKIP: `mcp` SDK not installed for the server interpreter "
|
||||
"(set ECHO_MCP_PYTHON to a venv python that has it)")
|
||||
return 0
|
||||
|
||||
def http(method, url, body=None, headers=None, timeout=30):
|
||||
data = body.encode() if isinstance(body, str) else body
|
||||
req = urllib.request.Request(url, data=data, method=method, headers=headers or {})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return r.status, r.read().decode("utf-8", "replace")
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.read().decode("utf-8", "replace")
|
||||
except Exception as e: # noqa: BLE001
|
||||
return 0, str(e)
|
||||
|
||||
_id = [0]
|
||||
|
||||
def rpc(method, params):
|
||||
_id[0] += 1
|
||||
st, body = http("POST", mcp_url, json.dumps(
|
||||
{"jsonrpc": "2.0", "id": _id[0], "method": method, "params": params}),
|
||||
headers={"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/event-stream",
|
||||
"Authorization": f"Bearer {TOKEN}"})
|
||||
return st, (json.loads(body) if body.strip().startswith("{") else {})
|
||||
|
||||
def call(name, args):
|
||||
st, d = rpc("tools/call", {"name": name, "arguments": args})
|
||||
res = d.get("result") or {}
|
||||
payload = {}
|
||||
if res.get("content"):
|
||||
try:
|
||||
payload = json.loads(res["content"][0]["text"])
|
||||
except Exception: # noqa: BLE001
|
||||
payload = {}
|
||||
return res.get("isError", False), payload
|
||||
|
||||
mock = subprocess.Popen([sys.executable, str(HERE / "mock_olrapi.py"), "--port", str(a.port)],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
srv = None
|
||||
try:
|
||||
for _ in range(50):
|
||||
try:
|
||||
urllib.request.urlopen(f"{mock_base}/__debug__reset", data=b"", timeout=1)
|
||||
break
|
||||
except Exception:
|
||||
time.sleep(0.1)
|
||||
http("PUT", f"{mock_base}/vault/_agent/echo-vault.md",
|
||||
"---\nschema_version: 4\n---\n# marker\n",
|
||||
headers={"Authorization": f"Bearer {KEY}"})
|
||||
|
||||
env = dict(os.environ, ECHO_BASE=mock_base, ECHO_KEY=KEY, ECHO_MCP_TOKEN=TOKEN,
|
||||
ECHO_MCP_PORT=str(a.mcp_port), ECHO_STATE_DIR=tempfile.mkdtemp(),
|
||||
ECHO_TODAY="2026-07-28")
|
||||
srv = subprocess.Popen([server_py, str(APP)], env=env,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
||||
for _ in range(60):
|
||||
st, _b = http("GET", f"http://127.0.0.1:{a.mcp_port}/health", timeout=2)
|
||||
if st == 200:
|
||||
break
|
||||
time.sleep(0.5)
|
||||
|
||||
st, body = http("GET", f"http://127.0.0.1:{a.mcp_port}/health")
|
||||
try:
|
||||
hb = json.loads(body)
|
||||
except Exception: # noqa: BLE001
|
||||
hb = {}
|
||||
check("health is open and green", st == 200 and hb.get("ok") is True
|
||||
and hb.get("vault_reachable") is True, body[:200])
|
||||
st, _b = http("POST", mcp_url, "{}", headers={"Content-Type": "application/json"})
|
||||
check("MCP endpoint requires the bearer token (401)", st == 401, str(st))
|
||||
|
||||
st, d = rpc("initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
|
||||
"clientInfo": {"name": "eval", "version": "0"}})
|
||||
check("initialize answers with serverInfo",
|
||||
d.get("result", {}).get("serverInfo", {}).get("name") == "echo-mcp", json.dumps(d)[:200])
|
||||
st, d = rpc("tools/list", {})
|
||||
tools = [t["name"] for t in d.get("result", {}).get("tools", [])]
|
||||
check("tools/list exposes the full profile (14 tools)", len(tools) == 14, str(tools))
|
||||
|
||||
e, b = call("echo_capture", {"title": "Vera Lumen", "kind": "person",
|
||||
"body": "CTO at Fluxcorp, met at the summit."})
|
||||
check("capture create over MCP", not e and b.get("action") == "created"
|
||||
and b.get("path") == "resources/people/vera-lumen.md", json.dumps(b))
|
||||
e, b = call("echo_capture", {"title": "Vera Lumen Jr", "kind": "person"})
|
||||
check("duplicate gate is data, not isError", not e
|
||||
and b.get("action") == "duplicate-gate" and b.get("candidates"), json.dumps(b))
|
||||
e, b = call("echo_capture", {"title": "Vera Lumen Jr", "kind": "person",
|
||||
"merge_into": "vera-lumen", "body": "Follow-up."})
|
||||
check("merge_into resolves the gate as an update", not e and b.get("action") == "updated",
|
||||
json.dumps(b))
|
||||
e, b = call("echo_recall", {"query": "fluxcorp", "budget_chars": 900})
|
||||
check("recall over MCP finds by body term", not e and any(
|
||||
h["path"] == "resources/people/vera-lumen.md" for h in b.get("primary", [])),
|
||||
json.dumps(b)[:300])
|
||||
e, b = call("echo_get_note", {"path": "resources/people/vera-lumen.md"})
|
||||
check("get_note returns frontmatter + content", not e
|
||||
and b.get("frontmatter", {}).get("type") == "person", json.dumps(b)[:200])
|
||||
e, b = call("echo_get_note", {"path": "../etc/passwd"})
|
||||
check("get_note rejects path traversal", not b.get("ok"))
|
||||
e, b = call("echo_log_session", {"slug": "mcp-e2e", "hhmm": "2345", "apply": True,
|
||||
"log_body": "---\ntype: session-log\n---\n# S\n\n## Goal\ne2e\n"})
|
||||
check("log_session commits with the heartbeat", not e
|
||||
and b.get("steps", {}).get("heartbeat") == "ok", json.dumps(b))
|
||||
e, b = call("echo_capture", {"title": "Bad Kind", "kind": "wizard"})
|
||||
check("unknown kind rejected with the valid list", not b.get("ok")
|
||||
and "wizard" in b.get("error", ""), json.dumps(b))
|
||||
|
||||
print(f"\n{len(failures)} failure(s)" if failures else "\nall mcp-server tests passed")
|
||||
return 1 if failures else 0
|
||||
finally:
|
||||
if srv:
|
||||
srv.terminate()
|
||||
mock.terminate()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user