memgres 0.5.0__tar.gz → 0.5.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {memgres-0.5.0 → memgres-0.5.2}/PKG-INFO +1 -1
- {memgres-0.5.0 → memgres-0.5.2}/memgres/_version.py +1 -1
- memgres-0.5.2/memgres/healthcheck.py +88 -0
- {memgres-0.5.0 → memgres-0.5.2}/memgres/mcp_server.py +12 -3
- {memgres-0.5.0 → memgres-0.5.2}/memgres.egg-info/PKG-INFO +1 -1
- {memgres-0.5.0 → memgres-0.5.2}/memgres.egg-info/SOURCES.txt +3 -0
- {memgres-0.5.0 → memgres-0.5.2}/memgres.egg-info/entry_points.txt +1 -0
- {memgres-0.5.0 → memgres-0.5.2}/pyproject.toml +1 -0
- memgres-0.5.2/tests/test_healthcheck.py +146 -0
- memgres-0.5.2/tests/test_mcp_http_transport.py +86 -0
- {memgres-0.5.0 → memgres-0.5.2}/LICENSE +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/README.md +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/memgres/__init__.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/memgres/admin_cli.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/memgres/blame.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/memgres/bootstrap.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/memgres/config.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/memgres/diffing.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/memgres/embed_worker.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/memgres/embeddings.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/memgres/identity.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/memgres/indexing.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/memgres/info.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/memgres/migrations/0001_core.sql +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/memgres/migrations/0002_identity.sql +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/memgres/migrations/0003_history_author.sql +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/memgres/migrations/0004_title.sql +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/memgres/migrations/0005_chunk_index.sql +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/memgres/migrations/0006_reader_floor.sql +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/memgres/migrations/0007_embed_retry.sql +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/memgres/migrations/0008_service_roles.sql +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/memgres/reembed.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/memgres/schema.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/memgres/search.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/memgres/segments.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/memgres/server.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/memgres/store.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/memgres/vector/__init__.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/memgres/vector/base.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/memgres/vector/pgvector.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/memgres/vector/qdrant.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/memgres/worker.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/memgres.egg-info/dependency_links.txt +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/memgres.egg-info/requires.txt +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/memgres.egg-info/top_level.txt +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/setup.cfg +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/tests/test_blame_integration.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/tests/test_chunk_index.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/tests/test_claim_and_reembed.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/tests/test_config.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/tests/test_diffing.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/tests/test_embed_worker.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/tests/test_embeddings.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/tests/test_identity_integration.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/tests/test_lexical_match.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/tests/test_limits.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/tests/test_list.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/tests/test_mcp_instructions.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/tests/test_mcp_recall_schema.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/tests/test_migration_upgrade.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/tests/test_qdrant_ca.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/tests/test_qdrant_integration.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/tests/test_replace_build.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/tests/test_roles_bootstrap.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/tests/test_search_integration.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/tests/test_security_integration.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/tests/test_segments.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/tests/test_segments_store.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/tests/test_server_info.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/tests/test_server_integration.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/tests/test_snippets.py +0 -0
- {memgres-0.5.0 → memgres-0.5.2}/tests/test_store_integration.py +0 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Container liveness probe that knows which role the container is running.
|
|
2
|
+
|
|
3
|
+
One image, two entrypoints: ``memgres-server`` (REST, ``MEMGRES_HTTP_PORT``,
|
|
4
|
+
answers ``/healthz``) and ``memgres-mcp`` (MCP, ``MEMGRES_MCP_PORT``, answers
|
|
5
|
+
``/mcp`` and has no ``/healthz``). The old Dockerfile HEALTHCHECK probed a
|
|
6
|
+
hardcoded ``localhost:8080/healthz``, so an MCP container was reported unhealthy
|
|
7
|
+
forever — it never listens on 8080. A permanently-red healthcheck is worse than
|
|
8
|
+
none: it masks real failures and blocks anything waiting on ``service_healthy``.
|
|
9
|
+
|
|
10
|
+
The role comes from PID 1's argv, not from an env convention, so it stays right
|
|
11
|
+
even when a deployment sets MCP variables on a REST container or vice versa.
|
|
12
|
+
|
|
13
|
+
Two details that make the probe honest rather than merely green:
|
|
14
|
+
|
|
15
|
+
* **127.0.0.1, not ``localhost``** — a server bound to ``0.0.0.0`` listens on
|
|
16
|
+
IPv4 only, while ``localhost`` may resolve to ``::1`` first and be refused.
|
|
17
|
+
* **For MCP, any HTTP answer means healthy.** Streamable HTTP rejects a bare GET
|
|
18
|
+
with 400; that response still proves the port is bound and the app is serving.
|
|
19
|
+
Only a refused connection or a timeout is a real failure.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
import urllib.error
|
|
25
|
+
import urllib.request
|
|
26
|
+
|
|
27
|
+
#: MEMGRES_MCP_TRANSPORT values that make the MCP server listen on a socket.
|
|
28
|
+
#: Anything else (i.e. stdio) is spoken over the process's own pipes.
|
|
29
|
+
HTTP_TRANSPORTS = ("http", "streamable-http", "streamable_http")
|
|
30
|
+
|
|
31
|
+
_TIMEOUT = 3
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _pid1_argv() -> list:
|
|
35
|
+
"""PID 1's argv — the command the container was actually started with."""
|
|
36
|
+
try:
|
|
37
|
+
with open("/proc/1/cmdline", "rb") as fh:
|
|
38
|
+
return [a for a in fh.read().decode("utf-8", "replace").split("\0") if a]
|
|
39
|
+
except OSError:
|
|
40
|
+
return [] # not Linux / no procfs — fall back to REST
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def role(argv=None) -> str:
|
|
44
|
+
"""``"mcp"`` or ``"rest"``. REST is the default, matching the image's CMD."""
|
|
45
|
+
argv = _pid1_argv() if argv is None else argv
|
|
46
|
+
for arg in argv:
|
|
47
|
+
if "memgres-mcp" in arg or "mcp_server" in arg:
|
|
48
|
+
return "mcp"
|
|
49
|
+
return "rest"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _answers(url: str) -> bool:
|
|
53
|
+
"""True if something at `url` speaks HTTP — any status, including errors."""
|
|
54
|
+
try:
|
|
55
|
+
urllib.request.urlopen(url, timeout=_TIMEOUT)
|
|
56
|
+
return True
|
|
57
|
+
except urllib.error.HTTPError:
|
|
58
|
+
return True # bound and serving (MCP GET → 400)
|
|
59
|
+
except (urllib.error.URLError, OSError):
|
|
60
|
+
return False # refused, unresolved, timed out
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _status_ok(url: str) -> bool:
|
|
64
|
+
"""True only on a 200 — used where a real health endpoint exists."""
|
|
65
|
+
try:
|
|
66
|
+
return urllib.request.urlopen(url, timeout=_TIMEOUT).status == 200
|
|
67
|
+
except (urllib.error.HTTPError, urllib.error.URLError, OSError):
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def check() -> int:
|
|
72
|
+
"""Exit code for the probe: 0 healthy, 1 unhealthy."""
|
|
73
|
+
if role() == "mcp":
|
|
74
|
+
transport = os.environ.get("MEMGRES_MCP_TRANSPORT", "stdio").lower()
|
|
75
|
+
if transport not in HTTP_TRANSPORTS:
|
|
76
|
+
return 0 # stdio: no socket; docker already sees the process
|
|
77
|
+
port = os.environ.get("MEMGRES_MCP_PORT", "8765")
|
|
78
|
+
return 0 if _answers(f"http://127.0.0.1:{port}/mcp") else 1
|
|
79
|
+
port = os.environ.get("MEMGRES_HTTP_PORT", "8080")
|
|
80
|
+
return 0 if _status_ok(f"http://127.0.0.1:{port}/healthz") else 1
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def main(): # pragma: no cover - entrypoint
|
|
84
|
+
sys.exit(check())
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__": # pragma: no cover
|
|
88
|
+
main()
|
|
@@ -426,9 +426,18 @@ def main(): # pragma: no cover - entrypoint
|
|
|
426
426
|
server = build_server()
|
|
427
427
|
transport = os.environ.get("MEMGRES_MCP_TRANSPORT", "stdio").lower()
|
|
428
428
|
if transport in ("http", "streamable-http", "streamable_http"):
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
429
|
+
host = os.environ.get("MEMGRES_MCP_HOST", "0.0.0.0")
|
|
430
|
+
port = int(os.environ.get("MEMGRES_MCP_PORT", "8765"))
|
|
431
|
+
# mcp SDK 1.x (FastMCP) reads host/port from `settings`; 2.x (MCPServer)
|
|
432
|
+
# dropped those fields and takes them as run() kwargs (forwarded to
|
|
433
|
+
# run_streamable_http_async). Pick the path the installed SDK supports.
|
|
434
|
+
fields = getattr(type(server.settings), "model_fields", {})
|
|
435
|
+
if "host" in fields:
|
|
436
|
+
server.settings.host = host
|
|
437
|
+
server.settings.port = port
|
|
438
|
+
server.run(transport="streamable-http")
|
|
439
|
+
else:
|
|
440
|
+
server.run(transport="streamable-http", host=host, port=port)
|
|
432
441
|
else:
|
|
433
442
|
server.run()
|
|
434
443
|
|
|
@@ -10,6 +10,7 @@ memgres/config.py
|
|
|
10
10
|
memgres/diffing.py
|
|
11
11
|
memgres/embed_worker.py
|
|
12
12
|
memgres/embeddings.py
|
|
13
|
+
memgres/healthcheck.py
|
|
13
14
|
memgres/identity.py
|
|
14
15
|
memgres/indexing.py
|
|
15
16
|
memgres/info.py
|
|
@@ -46,10 +47,12 @@ tests/test_config.py
|
|
|
46
47
|
tests/test_diffing.py
|
|
47
48
|
tests/test_embed_worker.py
|
|
48
49
|
tests/test_embeddings.py
|
|
50
|
+
tests/test_healthcheck.py
|
|
49
51
|
tests/test_identity_integration.py
|
|
50
52
|
tests/test_lexical_match.py
|
|
51
53
|
tests/test_limits.py
|
|
52
54
|
tests/test_list.py
|
|
55
|
+
tests/test_mcp_http_transport.py
|
|
53
56
|
tests/test_mcp_instructions.py
|
|
54
57
|
tests/test_mcp_recall_schema.py
|
|
55
58
|
tests/test_migration_upgrade.py
|
|
@@ -41,6 +41,7 @@ memgres-mcp = "memgres.mcp_server:main"
|
|
|
41
41
|
memgres-worker = "memgres.worker:main"
|
|
42
42
|
memgres-reembed = "memgres.reembed:main"
|
|
43
43
|
memgres-grant-superadmin = "memgres.admin_cli:main"
|
|
44
|
+
memgres-healthcheck = "memgres.healthcheck:main"
|
|
44
45
|
|
|
45
46
|
[build-system]
|
|
46
47
|
requires = ["setuptools>=68"]
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""The container probe must match the role the container is running.
|
|
2
|
+
|
|
3
|
+
Regression guard: the Dockerfile HEALTHCHECK used to hit a fixed
|
|
4
|
+
``localhost:8080/healthz``, which the MCP entrypoint never serves — every MCP
|
|
5
|
+
container sat "unhealthy" forever while serving fine. The last test here is that
|
|
6
|
+
exact scenario.
|
|
7
|
+
|
|
8
|
+
No database needed: these exercise role detection and the probes against a
|
|
9
|
+
throwaway HTTP server.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import socket
|
|
13
|
+
import threading
|
|
14
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
from memgres import healthcheck as hc
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _serve(status: int):
|
|
22
|
+
"""Run a one-route HTTP server on a free port; yields its port."""
|
|
23
|
+
class Handler(BaseHTTPRequestHandler):
|
|
24
|
+
def do_GET(self): # noqa: N802 - stdlib naming
|
|
25
|
+
self.send_response(status)
|
|
26
|
+
self.end_headers()
|
|
27
|
+
self.wfile.write(b"{}")
|
|
28
|
+
|
|
29
|
+
def log_message(self, *a): # keep pytest output clean
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
srv = HTTPServer(("127.0.0.1", 0), Handler)
|
|
33
|
+
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
|
34
|
+
return srv
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _free_port() -> int:
|
|
38
|
+
s = socket.socket()
|
|
39
|
+
s.bind(("127.0.0.1", 0))
|
|
40
|
+
port = s.getsockname()[1]
|
|
41
|
+
s.close()
|
|
42
|
+
return port
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# ─── role detection ────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
@pytest.mark.parametrize("argv", [
|
|
48
|
+
["/usr/local/bin/memgres-mcp"],
|
|
49
|
+
["python", "-m", "memgres.mcp_server"],
|
|
50
|
+
])
|
|
51
|
+
def test_role_detects_mcp(argv):
|
|
52
|
+
assert hc.role(argv) == "mcp"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@pytest.mark.parametrize("argv", [
|
|
56
|
+
["/usr/local/bin/memgres-server"],
|
|
57
|
+
[], # no procfs → default
|
|
58
|
+
])
|
|
59
|
+
def test_role_defaults_to_rest(argv):
|
|
60
|
+
assert hc.role(argv) == "rest"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ─── probes ────────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
def test_answers_accepts_any_http_status():
|
|
66
|
+
"""A bare GET to Streamable HTTP returns 400 — still proof it is serving."""
|
|
67
|
+
srv = _serve(400)
|
|
68
|
+
try:
|
|
69
|
+
assert hc._answers(f"http://127.0.0.1:{srv.server_port}/mcp") is True
|
|
70
|
+
finally:
|
|
71
|
+
srv.shutdown()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_answers_false_when_nothing_listens():
|
|
75
|
+
assert hc._answers(f"http://127.0.0.1:{_free_port()}/mcp") is False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_status_ok_requires_200():
|
|
79
|
+
srv = _serve(500)
|
|
80
|
+
try:
|
|
81
|
+
assert hc._status_ok(f"http://127.0.0.1:{srv.server_port}/healthz") is False
|
|
82
|
+
finally:
|
|
83
|
+
srv.shutdown()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ─── check() per role ──────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
def test_mcp_http_healthy_when_port_answers(monkeypatch):
|
|
89
|
+
srv = _serve(400)
|
|
90
|
+
try:
|
|
91
|
+
monkeypatch.setattr(hc, "role", lambda *a: "mcp")
|
|
92
|
+
monkeypatch.setenv("MEMGRES_MCP_TRANSPORT", "http")
|
|
93
|
+
monkeypatch.setenv("MEMGRES_MCP_PORT", str(srv.server_port))
|
|
94
|
+
assert hc.check() == 0
|
|
95
|
+
finally:
|
|
96
|
+
srv.shutdown()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_mcp_http_unhealthy_when_dead(monkeypatch):
|
|
100
|
+
monkeypatch.setattr(hc, "role", lambda *a: "mcp")
|
|
101
|
+
monkeypatch.setenv("MEMGRES_MCP_TRANSPORT", "http")
|
|
102
|
+
monkeypatch.setenv("MEMGRES_MCP_PORT", str(_free_port()))
|
|
103
|
+
assert hc.check() == 1
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_mcp_stdio_is_healthy_without_a_socket(monkeypatch):
|
|
107
|
+
"""stdio speaks over pipes; there is no port to probe, so never fail it."""
|
|
108
|
+
monkeypatch.setattr(hc, "role", lambda *a: "mcp")
|
|
109
|
+
monkeypatch.delenv("MEMGRES_MCP_TRANSPORT", raising=False)
|
|
110
|
+
assert hc.check() == 0
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_rest_healthy_on_200(monkeypatch):
|
|
114
|
+
srv = _serve(200)
|
|
115
|
+
try:
|
|
116
|
+
monkeypatch.setattr(hc, "role", lambda *a: "rest")
|
|
117
|
+
monkeypatch.setenv("MEMGRES_HTTP_PORT", str(srv.server_port))
|
|
118
|
+
assert hc.check() == 0
|
|
119
|
+
finally:
|
|
120
|
+
srv.shutdown()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_rest_honours_configured_port(monkeypatch):
|
|
124
|
+
"""The old probe hardcoded 8080 and broke on a remapped REST port."""
|
|
125
|
+
srv = _serve(200)
|
|
126
|
+
try:
|
|
127
|
+
monkeypatch.setattr(hc, "role", lambda *a: "rest")
|
|
128
|
+
monkeypatch.setenv("MEMGRES_HTTP_PORT", str(srv.server_port))
|
|
129
|
+
assert hc.check() == 0
|
|
130
|
+
monkeypatch.setenv("MEMGRES_HTTP_PORT", str(_free_port()))
|
|
131
|
+
assert hc.check() == 1
|
|
132
|
+
finally:
|
|
133
|
+
srv.shutdown()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_regression_mcp_container_without_8080_is_healthy(monkeypatch):
|
|
137
|
+
"""THE BUG: MCP serves on 8765 and nothing listens on 8080 — must be green."""
|
|
138
|
+
srv = _serve(400)
|
|
139
|
+
try:
|
|
140
|
+
monkeypatch.setattr(hc, "role", lambda *a: "mcp")
|
|
141
|
+
monkeypatch.setenv("MEMGRES_MCP_TRANSPORT", "streamable-http")
|
|
142
|
+
monkeypatch.setenv("MEMGRES_MCP_PORT", str(srv.server_port))
|
|
143
|
+
monkeypatch.setenv("MEMGRES_HTTP_PORT", "8080") # nothing there, as in prod
|
|
144
|
+
assert hc.check() == 0
|
|
145
|
+
finally:
|
|
146
|
+
srv.shutdown()
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""The http transport must actually bind its port.
|
|
2
|
+
|
|
3
|
+
Regression guard for the mcp SDK 2.x drift: `main()` used to set
|
|
4
|
+
`server.settings.host`, which 2.x's MCPServer rejects ("Settings object has no
|
|
5
|
+
field host"), so the server crash-looped and never served. This spawns the real
|
|
6
|
+
entrypoint over HTTP and asserts the port answers.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import socket
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.request
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import pytest
|
|
19
|
+
|
|
20
|
+
psycopg = pytest.importorskip("psycopg")
|
|
21
|
+
|
|
22
|
+
DSN = os.environ.get("MEMGRES_TEST_DSN",
|
|
23
|
+
"postgresql://memgres:memgres@localhost:55432/memgres")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _reachable() -> bool:
|
|
27
|
+
try:
|
|
28
|
+
psycopg.connect(DSN, connect_timeout=2).close()
|
|
29
|
+
return True
|
|
30
|
+
except Exception:
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
pytestmark = pytest.mark.skipif(not _reachable(), reason="no test Postgres")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _free_port() -> int:
|
|
38
|
+
s = socket.socket()
|
|
39
|
+
s.bind(("127.0.0.1", 0))
|
|
40
|
+
port = s.getsockname()[1]
|
|
41
|
+
s.close()
|
|
42
|
+
return port
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_http_transport_binds_and_answers():
|
|
46
|
+
port = _free_port()
|
|
47
|
+
env = {**os.environ,
|
|
48
|
+
"MEMGRES_DATABASE_URL": DSN,
|
|
49
|
+
"MEMGRES_KEY_MODE": "single",
|
|
50
|
+
"MEMGRES_EMBED_PROVIDER": "none",
|
|
51
|
+
"MEMGRES_MCP_TRANSPORT": "http",
|
|
52
|
+
"MEMGRES_MCP_HOST": "127.0.0.1",
|
|
53
|
+
"MEMGRES_MCP_PORT": str(port)}
|
|
54
|
+
root = str(Path(__file__).resolve().parent.parent)
|
|
55
|
+
env["PYTHONPATH"] = root + os.pathsep + env.get("PYTHONPATH", "")
|
|
56
|
+
proc = subprocess.Popen([sys.executable, "-m", "memgres.mcp_server"],
|
|
57
|
+
env=env, stdout=subprocess.PIPE,
|
|
58
|
+
stderr=subprocess.STDOUT, text=True)
|
|
59
|
+
url = f"http://127.0.0.1:{port}/mcp"
|
|
60
|
+
try:
|
|
61
|
+
deadline = time.time() + 25
|
|
62
|
+
answered = False
|
|
63
|
+
while time.time() < deadline:
|
|
64
|
+
if proc.poll() is not None:
|
|
65
|
+
out = proc.stdout.read() if proc.stdout else ""
|
|
66
|
+
pytest.fail(f"server exited early (code {proc.returncode}):\n{out}")
|
|
67
|
+
try:
|
|
68
|
+
urllib.request.urlopen(url, timeout=2)
|
|
69
|
+
answered = True # 2xx (unlikely for a bare GET)
|
|
70
|
+
break
|
|
71
|
+
except urllib.error.HTTPError:
|
|
72
|
+
answered = True # bound and speaking HTTP (e.g. 400/406)
|
|
73
|
+
break
|
|
74
|
+
except (urllib.error.URLError, ConnectionError, OSError):
|
|
75
|
+
time.sleep(0.5) # not up yet — retry
|
|
76
|
+
assert answered, f"{url} never answered — the transport did not bind"
|
|
77
|
+
finally:
|
|
78
|
+
proc.terminate()
|
|
79
|
+
try:
|
|
80
|
+
proc.wait(timeout=10)
|
|
81
|
+
except subprocess.TimeoutExpired:
|
|
82
|
+
proc.kill()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
if __name__ == "__main__": # pragma: no cover
|
|
86
|
+
sys.exit(pytest.main([__file__, "-v"]))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|