memgres 0.7.1__tar.gz → 0.7.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.
Files changed (103) hide show
  1. {memgres-0.7.1 → memgres-0.7.2}/PKG-INFO +1 -1
  2. {memgres-0.7.1 → memgres-0.7.2}/memgres/_version.py +1 -1
  3. {memgres-0.7.1 → memgres-0.7.2}/memgres/mcp_server.py +42 -17
  4. {memgres-0.7.1 → memgres-0.7.2}/memgres.egg-info/PKG-INFO +1 -1
  5. {memgres-0.7.1 → memgres-0.7.2}/memgres.egg-info/SOURCES.txt +1 -0
  6. {memgres-0.7.1 → memgres-0.7.2}/tests/test_mcp_tool_visibility.py +6 -1
  7. memgres-0.7.2/tests/test_mcp_tool_visibility_http.py +332 -0
  8. {memgres-0.7.1 → memgres-0.7.2}/LICENSE +0 -0
  9. {memgres-0.7.1 → memgres-0.7.2}/README.md +0 -0
  10. {memgres-0.7.1 → memgres-0.7.2}/memgres/__init__.py +0 -0
  11. {memgres-0.7.1 → memgres-0.7.2}/memgres/admin.py +0 -0
  12. {memgres-0.7.1 → memgres-0.7.2}/memgres/admin_cli.py +0 -0
  13. {memgres-0.7.1 → memgres-0.7.2}/memgres/blame.py +0 -0
  14. {memgres-0.7.1 → memgres-0.7.2}/memgres/bootstrap.py +0 -0
  15. {memgres-0.7.1 → memgres-0.7.2}/memgres/config.py +0 -0
  16. {memgres-0.7.1 → memgres-0.7.2}/memgres/delimiters.py +0 -0
  17. {memgres-0.7.1 → memgres-0.7.2}/memgres/diffing.py +0 -0
  18. {memgres-0.7.1 → memgres-0.7.2}/memgres/embed_worker.py +0 -0
  19. {memgres-0.7.1 → memgres-0.7.2}/memgres/embeddings.py +0 -0
  20. {memgres-0.7.1 → memgres-0.7.2}/memgres/healthcheck.py +0 -0
  21. {memgres-0.7.1 → memgres-0.7.2}/memgres/identity.py +0 -0
  22. {memgres-0.7.1 → memgres-0.7.2}/memgres/indexing.py +0 -0
  23. {memgres-0.7.1 → memgres-0.7.2}/memgres/info.py +0 -0
  24. {memgres-0.7.1 → memgres-0.7.2}/memgres/lines.py +0 -0
  25. {memgres-0.7.1 → memgres-0.7.2}/memgres/links.py +0 -0
  26. {memgres-0.7.1 → memgres-0.7.2}/memgres/migrations/0001_core.sql +0 -0
  27. {memgres-0.7.1 → memgres-0.7.2}/memgres/migrations/0002_identity.sql +0 -0
  28. {memgres-0.7.1 → memgres-0.7.2}/memgres/migrations/0003_history_author.sql +0 -0
  29. {memgres-0.7.1 → memgres-0.7.2}/memgres/migrations/0004_title.sql +0 -0
  30. {memgres-0.7.1 → memgres-0.7.2}/memgres/migrations/0005_chunk_index.sql +0 -0
  31. {memgres-0.7.1 → memgres-0.7.2}/memgres/migrations/0006_reader_floor.sql +0 -0
  32. {memgres-0.7.1 → memgres-0.7.2}/memgres/migrations/0007_embed_retry.sql +0 -0
  33. {memgres-0.7.1 → memgres-0.7.2}/memgres/migrations/0008_service_roles.sql +0 -0
  34. {memgres-0.7.1 → memgres-0.7.2}/memgres/migrations/0009_create_namespace_right.sql +0 -0
  35. {memgres-0.7.1 → memgres-0.7.2}/memgres/migrations/0010_namespace_alias.sql +0 -0
  36. {memgres-0.7.1 → memgres-0.7.2}/memgres/migrations/0011_drop_default_namespace.sql +0 -0
  37. {memgres-0.7.1 → memgres-0.7.2}/memgres/migrations/0012_user_profile.sql +0 -0
  38. {memgres-0.7.1 → memgres-0.7.2}/memgres/migrations/0013_hash_version.sql +0 -0
  39. {memgres-0.7.1 → memgres-0.7.2}/memgres/migrations/0014_access_request_no_fk.sql +0 -0
  40. {memgres-0.7.1 → memgres-0.7.2}/memgres/migrations/0015_normalize_tags.sql +0 -0
  41. {memgres-0.7.1 → memgres-0.7.2}/memgres/migrations/0016_valid_at.sql +0 -0
  42. {memgres-0.7.1 → memgres-0.7.2}/memgres/migrations/0017_memory_link.sql +0 -0
  43. {memgres-0.7.1 → memgres-0.7.2}/memgres/migrations/0018_links_built.sql +0 -0
  44. {memgres-0.7.1 → memgres-0.7.2}/memgres/migrations/0019_memory_usage.sql +0 -0
  45. {memgres-0.7.1 → memgres-0.7.2}/memgres/migrations/0020_memory_usage_no_fk.sql +0 -0
  46. {memgres-0.7.1 → memgres-0.7.2}/memgres/periodic.py +0 -0
  47. {memgres-0.7.1 → memgres-0.7.2}/memgres/reembed.py +0 -0
  48. {memgres-0.7.1 → memgres-0.7.2}/memgres/relink.py +0 -0
  49. {memgres-0.7.1 → memgres-0.7.2}/memgres/schema.py +0 -0
  50. {memgres-0.7.1 → memgres-0.7.2}/memgres/search.py +0 -0
  51. {memgres-0.7.1 → memgres-0.7.2}/memgres/segments.py +0 -0
  52. {memgres-0.7.1 → memgres-0.7.2}/memgres/server.py +0 -0
  53. {memgres-0.7.1 → memgres-0.7.2}/memgres/store.py +0 -0
  54. {memgres-0.7.1 → memgres-0.7.2}/memgres/tags.py +0 -0
  55. {memgres-0.7.1 → memgres-0.7.2}/memgres/vector/__init__.py +0 -0
  56. {memgres-0.7.1 → memgres-0.7.2}/memgres/vector/base.py +0 -0
  57. {memgres-0.7.1 → memgres-0.7.2}/memgres/vector/pgvector.py +0 -0
  58. {memgres-0.7.1 → memgres-0.7.2}/memgres/vector/qdrant.py +0 -0
  59. {memgres-0.7.1 → memgres-0.7.2}/memgres/worker.py +0 -0
  60. {memgres-0.7.1 → memgres-0.7.2}/memgres.egg-info/dependency_links.txt +0 -0
  61. {memgres-0.7.1 → memgres-0.7.2}/memgres.egg-info/entry_points.txt +0 -0
  62. {memgres-0.7.1 → memgres-0.7.2}/memgres.egg-info/requires.txt +0 -0
  63. {memgres-0.7.1 → memgres-0.7.2}/memgres.egg-info/top_level.txt +0 -0
  64. {memgres-0.7.1 → memgres-0.7.2}/pyproject.toml +0 -0
  65. {memgres-0.7.1 → memgres-0.7.2}/setup.cfg +0 -0
  66. {memgres-0.7.1 → memgres-0.7.2}/tests/test_blame_integration.py +0 -0
  67. {memgres-0.7.1 → memgres-0.7.2}/tests/test_chunk_index.py +0 -0
  68. {memgres-0.7.1 → memgres-0.7.2}/tests/test_claim_and_reembed.py +0 -0
  69. {memgres-0.7.1 → memgres-0.7.2}/tests/test_config.py +0 -0
  70. {memgres-0.7.1 → memgres-0.7.2}/tests/test_diffing.py +0 -0
  71. {memgres-0.7.1 → memgres-0.7.2}/tests/test_embed_worker.py +0 -0
  72. {memgres-0.7.1 → memgres-0.7.2}/tests/test_embeddings.py +0 -0
  73. {memgres-0.7.1 → memgres-0.7.2}/tests/test_healthcheck.py +0 -0
  74. {memgres-0.7.1 → memgres-0.7.2}/tests/test_identity_integration.py +0 -0
  75. {memgres-0.7.1 → memgres-0.7.2}/tests/test_lexical_match.py +0 -0
  76. {memgres-0.7.1 → memgres-0.7.2}/tests/test_limits.py +0 -0
  77. {memgres-0.7.1 → memgres-0.7.2}/tests/test_links.py +0 -0
  78. {memgres-0.7.1 → memgres-0.7.2}/tests/test_list.py +0 -0
  79. {memgres-0.7.1 → memgres-0.7.2}/tests/test_mcp_admin_tools.py +0 -0
  80. {memgres-0.7.1 → memgres-0.7.2}/tests/test_mcp_http_transport.py +0 -0
  81. {memgres-0.7.1 → memgres-0.7.2}/tests/test_mcp_instructions.py +0 -0
  82. {memgres-0.7.1 → memgres-0.7.2}/tests/test_mcp_recall_schema.py +0 -0
  83. {memgres-0.7.1 → memgres-0.7.2}/tests/test_migration_upgrade.py +0 -0
  84. {memgres-0.7.1 → memgres-0.7.2}/tests/test_multi_space_search.py +0 -0
  85. {memgres-0.7.1 → memgres-0.7.2}/tests/test_path_addressing.py +0 -0
  86. {memgres-0.7.1 → memgres-0.7.2}/tests/test_qdrant_ca.py +0 -0
  87. {memgres-0.7.1 → memgres-0.7.2}/tests/test_qdrant_integration.py +0 -0
  88. {memgres-0.7.1 → memgres-0.7.2}/tests/test_replace_build.py +0 -0
  89. {memgres-0.7.1 → memgres-0.7.2}/tests/test_require_title.py +0 -0
  90. {memgres-0.7.1 → memgres-0.7.2}/tests/test_retention.py +0 -0
  91. {memgres-0.7.1 → memgres-0.7.2}/tests/test_roles_bootstrap.py +0 -0
  92. {memgres-0.7.1 → memgres-0.7.2}/tests/test_search_integration.py +0 -0
  93. {memgres-0.7.1 → memgres-0.7.2}/tests/test_security_integration.py +0 -0
  94. {memgres-0.7.1 → memgres-0.7.2}/tests/test_segments.py +0 -0
  95. {memgres-0.7.1 → memgres-0.7.2}/tests/test_segments_store.py +0 -0
  96. {memgres-0.7.1 → memgres-0.7.2}/tests/test_server_info.py +0 -0
  97. {memgres-0.7.1 → memgres-0.7.2}/tests/test_server_integration.py +0 -0
  98. {memgres-0.7.1 → memgres-0.7.2}/tests/test_snippets.py +0 -0
  99. {memgres-0.7.1 → memgres-0.7.2}/tests/test_store_integration.py +0 -0
  100. {memgres-0.7.1 → memgres-0.7.2}/tests/test_tags.py +0 -0
  101. {memgres-0.7.1 → memgres-0.7.2}/tests/test_usage.py +0 -0
  102. {memgres-0.7.1 → memgres-0.7.2}/tests/test_valid_at.py +0 -0
  103. {memgres-0.7.1 → memgres-0.7.2}/tests/test_write_ergonomics.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memgres
3
- Version: 0.7.1
3
+ Version: 0.7.2
4
4
  Summary: Drop-in memory for AI agents: one Postgres, lexical + semantic recall, diff-versioned history, GDPR-erasable.
5
5
  Author: mozgsml
6
6
  License-Expression: MIT
@@ -8,4 +8,4 @@ here at release; nowhere else carries the number.
8
8
  PEP 440: a ``.devN`` suffix marks an unreleased build ahead of the last tag.
9
9
  """
10
10
 
11
- __version__ = "0.7.1"
11
+ __version__ = "0.7.2"
@@ -270,12 +270,20 @@ def build_server(cfg: Optional[Config] = None):
270
270
 
271
271
  Security does not depend on the schema-pruning below: even if the `token`
272
272
  argument stays visible, a header/env pin still wins here."""
273
+ # The two SDK generations hand the request out differently: 1.x wraps it
274
+ # in a `Context` (`.request_context.request`), 2.x passes the
275
+ # `ServerRequestContext` itself, which carries `.request`. Accept both —
276
+ # reading only one shape is how the header stopped being seen at all.
273
277
  req = None
274
- if ctx is not None:
278
+ for probe in (lambda: ctx.request_context.request, lambda: ctx.request):
279
+ if ctx is None:
280
+ break
275
281
  try:
276
- req = ctx.request_context.request
282
+ req = probe()
277
283
  except Exception:
278
284
  req = None
285
+ if req is not None:
286
+ break
279
287
  if req is not None:
280
288
  try:
281
289
  tok = identity.bearer_token(req.headers.get("authorization"),
@@ -952,7 +960,7 @@ def build_server(cfg: Optional[Config] = None):
952
960
  "can_manage_own_tokens", "can_administer_deployment",
953
961
  "has_admin_ceiling", "is_admin")}
954
962
 
955
- def _caps_for_caller():
963
+ def _caps_for_caller(ctx):
956
964
  """This request's capabilities, or None if the caller is unknown.
957
965
 
958
966
  Only a failed CREDENTIAL answers None. A database that is briefly
@@ -966,10 +974,6 @@ def build_server(cfg: Optional[Config] = None):
966
974
  # One implicit caller who may do everything; there is no
967
975
  # credential to resolve and nothing to withhold.
968
976
  return _all_caps
969
- try:
970
- ctx = mcp.get_context()
971
- except Exception:
972
- ctx = None
973
977
  with pool.connection() as conn:
974
978
  try:
975
979
  # `touch=False`: listing tools is asking ABOUT the
@@ -982,9 +986,9 @@ def build_server(cfg: Optional[Config] = None):
982
986
  return None
983
987
  return admin.capabilities(conn, p)
984
988
 
985
- def _keep(tools):
989
+ def _keep(tools, ctx):
986
990
  allowed = set(visible_tools([t.name for t in tools],
987
- _caps_for_caller(), _identity_on))
991
+ _caps_for_caller(ctx), _identity_on))
988
992
  return [t for t in tools if t.name in allowed]
989
993
 
990
994
  # Where the filter attaches differs by SDK generation, so both are
@@ -1002,16 +1006,37 @@ def build_server(cfg: Optional[Config] = None):
1002
1006
  # every client still seeing every tool, no error anywhere. A guard that
1003
1007
  # turns "this build cannot do what you configured" into silence is worse
1004
1008
  # than the crash it prevents, so an unrecognised SDK now says so.
1005
- if hasattr(mcp, "_handle_list_tools"): # mcp 2.x
1006
- _unfiltered = mcp.list_tools
1007
-
1008
- async def _list_visible_tools():
1009
- return _keep(await _unfiltered())
1010
-
1011
- mcp.list_tools = _list_visible_tools
1009
+ if hasattr(mcp, "_lowlevel_server"): # mcp 2.x
1010
+ # Wrap the REQUEST HANDLER, not `list_tools()`. The handler is the
1011
+ # only place the caller is knowable: it is handed the
1012
+ # `ServerRequestContext` carrying the HTTP request, while
1013
+ # `list_tools()` takes no arguments at all. Filtering there meant
1014
+ # asking who was calling with nothing to ask — on 2.x `MCPServer` has
1015
+ # no `get_context()` either, so every caller resolved to "unknown"
1016
+ # and every HTTP client, whatever its rights, was shown the read-only
1017
+ # subset. Registered through the public `add_request_handler`, which
1018
+ # documents that it replaces an existing entry; assigning the bound
1019
+ # method on the instance would not have worked, because the lowlevel
1020
+ # server captures it at construction.
1021
+ from mcp_types import PaginatedRequestParams
1022
+
1023
+ _low = mcp._lowlevel_server
1024
+ _inner = _low._request_handlers["tools/list"].handler
1025
+
1026
+ async def _list_visible_tools(ctx, params=None):
1027
+ res = await _inner(ctx, params)
1028
+ return res.model_copy(update={"tools": _keep(res.tools, ctx)})
1029
+
1030
+ _low.add_request_handler("tools/list", PaginatedRequestParams,
1031
+ _list_visible_tools)
1012
1032
  elif hasattr(mcp, "_mcp_server"): # mcp 1.x
1013
1033
  async def _list_visible_tools():
1014
- return _keep(await mcp.list_tools())
1034
+ # 1.x keeps the request in a contextvar `get_context()` reads.
1035
+ try:
1036
+ ctx = mcp.get_context()
1037
+ except Exception:
1038
+ ctx = None
1039
+ return _keep(await mcp.list_tools(), ctx)
1015
1040
 
1016
1041
  mcp._mcp_server.list_tools()(_list_visible_tools)
1017
1042
  else: # pragma: no cover
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memgres
3
- Version: 0.7.1
3
+ Version: 0.7.2
4
4
  Summary: Drop-in memory for AI agents: one Postgres, lexical + semantic recall, diff-versioned history, GDPR-erasable.
5
5
  Author: mozgsml
6
6
  License-Expression: MIT
@@ -77,6 +77,7 @@ tests/test_mcp_http_transport.py
77
77
  tests/test_mcp_instructions.py
78
78
  tests/test_mcp_recall_schema.py
79
79
  tests/test_mcp_tool_visibility.py
80
+ tests/test_mcp_tool_visibility_http.py
80
81
  tests/test_migration_upgrade.py
81
82
  tests/test_multi_space_search.py
82
83
  tests/test_path_addressing.py
@@ -153,7 +153,12 @@ def _listed(mcp):
153
153
  handler = mcp._mcp_server.request_handlers[mcp_types.ListToolsRequest]
154
154
  result = asyncio.run(handler(None))
155
155
  return [t.name for t in result.root.tools]
156
- return [t.name for t in asyncio.run(mcp.list_tools())] # mcp 2.x
156
+ # mcp 2.x: go through the REGISTERED REQUEST HANDLER for the same reason.
157
+ # `mcp.list_tools()` is the unfiltered inventory — the filter now wraps the
158
+ # handler, because that is the only place the caller is knowable. Calling
159
+ # the attribute here would assert against a list no client ever receives.
160
+ handler = mcp._lowlevel_server._request_handlers["tools/list"].handler
161
+ return [t.name for t in asyncio.run(handler(None, None)).tools]
157
162
 
158
163
 
159
164
  @pytest.fixture
@@ -0,0 +1,332 @@
1
+ """Tool visibility over the HTTP transport — with the credential in a HEADER.
2
+
3
+ This file exists because its absence shipped a bug. Every other visibility test
4
+ supplies the caller through `MEMGRES_TOKEN`, the env pin — the one path that
5
+ resolves a token WITHOUT the request context. The header path, which is the only
6
+ way an HTTP deployment identifies anyone, was never exercised, so nothing noticed
7
+ that on mcp 2.x the filter had no way to reach the request at all: `MCPServer` has
8
+ no `get_context()`, the failure was swallowed into "caller unknown", and every
9
+ client of a managed HTTP endpoint — superadmin included — was handed the read-only
10
+ subset. The suite was green the whole time.
11
+
12
+ So these tests speak the wire protocol to a REAL server in a subprocess: initialize,
13
+ notifications/initialized, tools/list, with whatever headers the scenario is about.
14
+ Slower than the rest of the suite, and the cost is the point — nothing between the
15
+ test and the client's own path is stubbed.
16
+ """
17
+
18
+ import json
19
+ import os
20
+ import socket
21
+ import subprocess
22
+ import sys
23
+ import time
24
+ import urllib.error
25
+ import urllib.request
26
+ from pathlib import Path
27
+
28
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
29
+
30
+ import pytest # noqa: E402
31
+
32
+ psycopg = pytest.importorskip("psycopg")
33
+ pytest.importorskip("psycopg_pool")
34
+ pytest.importorskip("mcp")
35
+
36
+ from memgres import identity # noqa: E402
37
+
38
+ DSN = os.environ.get("MEMGRES_TEST_DSN",
39
+ "postgresql://memgres:memgres@localhost:55432/memgres")
40
+
41
+
42
+ def _reachable() -> bool:
43
+ try:
44
+ psycopg.connect(DSN, connect_timeout=2).close()
45
+ return True
46
+ except Exception:
47
+ return False
48
+
49
+
50
+ pytestmark = pytest.mark.skipif(not _reachable(), reason="no test Postgres")
51
+
52
+ # No proxy for these: the developer environment may export HTTP_PROXY, and a
53
+ # request to 127.0.0.1 that goes out through a proxy fails in a way that looks
54
+ # like the server never started.
55
+ _OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
56
+
57
+
58
+ def _free_port() -> int:
59
+ with socket.socket() as s:
60
+ s.bind(("127.0.0.1", 0))
61
+ return s.getsockname()[1]
62
+
63
+
64
+ class Server:
65
+ """A real `memgres-mcp` on the HTTP transport, talked to over the wire."""
66
+
67
+ def __init__(self, **env_extra):
68
+ self.port = _free_port()
69
+ env = {k: v for k, v in os.environ.items() if not k.startswith("MEMGRES_")}
70
+ env.update(MEMGRES_DATABASE_URL=DSN, MEMGRES_EMBED_PROVIDER="none",
71
+ MEMGRES_FTS_LANGUAGE="simple", MEMGRES_REQUIRE_TITLE="false",
72
+ MEMGRES_MCP_TRANSPORT="http", MEMGRES_MCP_HOST="127.0.0.1",
73
+ MEMGRES_MCP_PORT=str(self.port))
74
+ env.update(env_extra)
75
+ self.proc = subprocess.Popen([sys.executable, "-m", "memgres.mcp_server"],
76
+ env=env, stdout=subprocess.PIPE,
77
+ stderr=subprocess.STDOUT, text=True)
78
+ self.url = f"http://127.0.0.1:{self.port}/mcp"
79
+ self._wait()
80
+
81
+ def _wait(self, timeout: float = 60.0) -> None:
82
+ deadline = time.time() + timeout
83
+ while time.time() < deadline:
84
+ if self.proc.poll() is not None:
85
+ raise RuntimeError("server exited:\n" + (self.proc.stdout.read() or ""))
86
+ try:
87
+ _OPENER.open(self.url, timeout=1)
88
+ except urllib.error.HTTPError:
89
+ return # answering HTTP at all is enough
90
+ except Exception:
91
+ time.sleep(0.3)
92
+ raise RuntimeError("server never came up")
93
+
94
+ def tools(self, token=None, header="authorization"):
95
+ """The tool names a client with these headers actually receives."""
96
+ headers = {}
97
+ if token is not None:
98
+ headers[header] = ("Bearer " + token if header == "authorization"
99
+ else token)
100
+ sid = self._rpc({"jsonrpc": "2.0", "id": 1, "method": "initialize",
101
+ "params": {"protocolVersion": "2025-06-18",
102
+ "capabilities": {},
103
+ "clientInfo": {"name": "t", "version": "1"}}},
104
+ headers)[0]
105
+ self._rpc({"jsonrpc": "2.0", "method": "notifications/initialized",
106
+ "params": {}}, headers, sid)
107
+ res = self._rpc({"jsonrpc": "2.0", "id": 2, "method": "tools/list",
108
+ "params": {}}, headers, sid)[1]
109
+ return sorted(t["name"] for t in res["result"]["tools"])
110
+
111
+ def call(self, name, args, token=None):
112
+ headers = {"authorization": "Bearer " + token} if token else {}
113
+ sid = self._rpc({"jsonrpc": "2.0", "id": 1, "method": "initialize",
114
+ "params": {"protocolVersion": "2025-06-18",
115
+ "capabilities": {},
116
+ "clientInfo": {"name": "t", "version": "1"}}},
117
+ headers)[0]
118
+ self._rpc({"jsonrpc": "2.0", "method": "notifications/initialized",
119
+ "params": {}}, headers, sid)
120
+ return self._rpc({"jsonrpc": "2.0", "id": 2, "method": "tools/call",
121
+ "params": {"name": name, "arguments": args}},
122
+ headers, sid)[1]
123
+
124
+ def _rpc(self, payload, headers, sid=None):
125
+ h = {"Content-Type": "application/json",
126
+ "Accept": "application/json, text/event-stream", **headers}
127
+ if sid:
128
+ h["Mcp-Session-Id"] = sid
129
+ req = urllib.request.Request(self.url, json.dumps(payload).encode(), h)
130
+ resp = _OPENER.open(req, timeout=30)
131
+ body = resp.read().decode()
132
+ if "data:" in body: # SSE framing
133
+ body = "".join(l[6:] for l in body.splitlines() if l.startswith("data: "))
134
+ return resp.headers.get("Mcp-Session-Id"), (json.loads(body) if body.strip() else None)
135
+
136
+ def stop(self):
137
+ self.proc.terminate()
138
+ try:
139
+ self.proc.wait(timeout=10)
140
+ except Exception:
141
+ self.proc.kill()
142
+
143
+
144
+ def _fresh_db():
145
+ with psycopg.connect(DSN, autocommit=True) as c, c.cursor() as cur:
146
+ cur.execute("SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
147
+ "WHERE datname = current_database() AND pid <> pg_backend_pid()")
148
+ cur.execute("DROP SCHEMA public CASCADE; CREATE SCHEMA public;")
149
+
150
+
151
+ @pytest.fixture(scope="module")
152
+ def managed():
153
+ """One managed endpoint plus a credential for every tier that exists.
154
+
155
+ Module-scoped on purpose: the scenarios differ only in the HEADER they send,
156
+ so they can share a server — and the credentials must be minted before it
157
+ starts, because the schema has to exist for the token rows to live in.
158
+ """
159
+ _fresh_db()
160
+ root = identity.new_token()
161
+ boot = Server(MEMGRES_KEY_MODE="managed", MEMGRES_ADMIN_TOKEN=root,
162
+ MEMGRES_ADMIN_ROLE="superadmin") # migrates + seeds root
163
+ boot.stop()
164
+
165
+ conn = psycopg.connect(DSN, autocommit=True)
166
+ uid = identity.create_user(conn, name="worker")
167
+ nsid = identity.create_namespace(conn, uid, "work")
168
+ writer, _ = identity.issue_token(conn, uid, permission="write")
169
+ reader, _ = identity.issue_token(conn, uid, permission="read")
170
+ scoped, _ = identity.issue_token(conn, uid, namespace_id=nsid,
171
+ permission="write")
172
+ muid = identity.create_user(conn, name="manager")
173
+ identity.set_role(conn, muid, "user_manager")
174
+ manager, _ = identity.issue_token(conn, muid, permission="admin")
175
+
176
+ srv = Server(MEMGRES_KEY_MODE="managed", MEMGRES_ADMIN_TOKEN=root,
177
+ MEMGRES_ADMIN_ROLE="superadmin")
178
+ yield srv, {"root": root, "writer": writer, "reader": reader,
179
+ "scoped": scoped, "manager": manager}
180
+ srv.stop()
181
+ conn.close()
182
+
183
+
184
+ def _admin(names):
185
+ return [n for n in names if n.startswith("memory_admin_")]
186
+
187
+
188
+ # ─── the credential in the header decides, and it is actually read ───────────
189
+ def test_a_superadmin_header_is_shown_the_control_plane(managed):
190
+ """The regression this file was written for. On a managed HTTP endpoint the
191
+ filter could not reach the request, so this list came back read-only."""
192
+ srv, tok = managed
193
+ names = srv.tools(tok["root"])
194
+ assert "memory_write" in names
195
+ assert "memory_admin_create_namespace" in names
196
+ assert "memory_admin_set_role" in names # deployment-wide tier
197
+
198
+
199
+ def test_the_header_changes_the_answer(managed):
200
+ """Two callers, one endpoint, different lists — the property that proves the
201
+ header is read rather than ignored. Identical lists were the bug."""
202
+ srv, tok = managed
203
+ assert srv.tools(tok["root"]) != srv.tools(tok["reader"])
204
+
205
+
206
+ def test_a_reader_is_shown_no_write_tools(managed):
207
+ srv, tok = managed
208
+ names = srv.tools(tok["reader"])
209
+ assert "memory_recall" in names and "memory_get" in names
210
+ assert "memory_write" not in names
211
+ assert "memory_move" not in names and "memory_forget" not in names
212
+
213
+
214
+ def test_a_writer_is_shown_no_control_plane(managed):
215
+ srv, tok = managed
216
+ names = srv.tools(tok["writer"])
217
+ assert "memory_write" in names
218
+ assert _admin(names) == []
219
+
220
+
221
+ def test_a_user_manager_gets_the_user_tier_but_not_the_deployment_tier(managed):
222
+ srv, tok = managed
223
+ names = srv.tools(tok["manager"])
224
+ assert "memory_admin_create_user" in names
225
+ assert "memory_admin_create_namespace" in names
226
+ assert "memory_admin_set_role" not in names # deployment-wide
227
+ assert "memory_admin_adopt_orphans" not in names
228
+
229
+
230
+ def test_a_scoped_credential_is_narrowed_even_though_its_owner_is_not(managed):
231
+ """Rights are the token's, not the person's: a namespace-scoped token cannot
232
+ create another namespace even though its user may."""
233
+ srv, tok = managed
234
+ assert "memory_create_space" not in srv.tools(tok["scoped"])
235
+
236
+
237
+ def test_the_other_header_spelling_works_too(managed):
238
+ """`X-Memgres-Token` is documented alongside `Authorization: Bearer`. A
239
+ documented way in that quietly yields the anonymous list is the same failure
240
+ in a second costume."""
241
+ srv, tok = managed
242
+ assert srv.tools(tok["root"], header="x-memgres-token") == srv.tools(tok["root"])
243
+
244
+
245
+ # ─── an unusable credential must not look like a poor one ────────────────────
246
+ def test_no_header_at_all_gets_the_read_surface(managed):
247
+ srv, _ = managed
248
+ names = srv.tools(None)
249
+ assert "memory_recall" in names # a working server, not a wall
250
+ assert "memory_write" not in names and _admin(names) == []
251
+
252
+
253
+ def test_a_garbage_token_gets_the_same_read_surface(managed):
254
+ srv, _ = managed
255
+ assert srv.tools("mgk_not_a_real_token") == srv.tools(None)
256
+
257
+
258
+ def test_hiding_is_not_how_a_tool_is_refused(managed):
259
+ """A hidden tool called anyway must still FAIL, and must not come back as if
260
+ it did not exist. Hiding is a display economy; the refusal is the call path's
261
+ job, and if a rights problem surfaced as "unknown tool" it would read as a
262
+ broken server while the real check sat in a display table.
263
+
264
+ Only the failure and the absence of a not-found story are asserted. Whether
265
+ the reason survives into the client's view is the SDK's rendering, and newer
266
+ mcp releases replace a tool's exception with a generic "error executing
267
+ tool" — an argument for reading the server log, not for asserting wording
268
+ this project does not own."""
269
+ srv, tok = managed
270
+ res = srv.call("memory_write", {"body": "x", "path": "a.b", "title": "T"},
271
+ token=tok["reader"])
272
+ text = json.dumps(res).lower()
273
+ assert res["result"]["isError"] is True or res["result"].get("iserror") is True
274
+ for missing in ("unknown tool", "not found", "no such tool"):
275
+ assert missing not in text
276
+
277
+
278
+ # ─── the other deployment shapes ─────────────────────────────────────────────
279
+ def test_a_single_tenant_endpoint_hides_identity_and_shows_the_rest():
280
+ """`single` has one implicit caller who may do everything, so nothing is
281
+ withheld — but the identity tools have nothing to be about."""
282
+ _fresh_db()
283
+ srv = Server(MEMGRES_KEY_MODE="single")
284
+ try:
285
+ names = srv.tools(None)
286
+ assert "memory_write" in names
287
+ assert "memory_whoami" not in names
288
+ assert "memory_list_spaces" not in names
289
+ assert _admin(names) == []
290
+ finally:
291
+ srv.stop()
292
+
293
+
294
+ def test_a_pinned_token_still_decides_when_no_header_is_sent():
295
+ """The env pin is how a stdio deployment names its caller, and it must keep
296
+ working on http — it was the ONLY path the old tests covered."""
297
+ _fresh_db()
298
+ root = identity.new_token()
299
+ boot = Server(MEMGRES_KEY_MODE="managed", MEMGRES_ADMIN_TOKEN=root,
300
+ MEMGRES_ADMIN_ROLE="superadmin")
301
+ boot.stop()
302
+ conn = psycopg.connect(DSN, autocommit=True)
303
+ uid = identity.create_user(conn, name="worker")
304
+ reader, _ = identity.issue_token(conn, uid, permission="read")
305
+ conn.close()
306
+
307
+ srv = Server(MEMGRES_KEY_MODE="managed", MEMGRES_ADMIN_TOKEN=root,
308
+ MEMGRES_ADMIN_ROLE="superadmin", MEMGRES_TOKEN=reader)
309
+ try:
310
+ assert "memory_write" not in srv.tools(None)
311
+ # ...and a header still wins over the pin, as `_token` documents.
312
+ assert "memory_write" in srv.tools(root)
313
+ finally:
314
+ srv.stop()
315
+
316
+
317
+ def test_visibility_off_shows_everything_to_everyone():
318
+ _fresh_db()
319
+ root = identity.new_token()
320
+ boot = Server(MEMGRES_KEY_MODE="managed", MEMGRES_ADMIN_TOKEN=root,
321
+ MEMGRES_ADMIN_ROLE="superadmin")
322
+ boot.stop()
323
+ srv = Server(MEMGRES_KEY_MODE="managed", MEMGRES_ADMIN_TOKEN=root,
324
+ MEMGRES_ADMIN_ROLE="superadmin",
325
+ MEMGRES_MCP_TOOL_VISIBILITY="off")
326
+ try:
327
+ anonymous = srv.tools(None)
328
+ assert "memory_write" in anonymous
329
+ assert "memory_admin_set_role" in anonymous
330
+ assert anonymous == srv.tools(root)
331
+ finally:
332
+ srv.stop()
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