memgres 0.7.0__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.0 → memgres-0.7.2}/PKG-INFO +1 -1
  2. {memgres-0.7.0 → memgres-0.7.2}/memgres/_version.py +1 -1
  3. {memgres-0.7.0 → memgres-0.7.2}/memgres/mcp_server.py +55 -19
  4. {memgres-0.7.0 → memgres-0.7.2}/memgres.egg-info/PKG-INFO +1 -1
  5. {memgres-0.7.0 → memgres-0.7.2}/memgres.egg-info/SOURCES.txt +1 -0
  6. {memgres-0.7.0 → 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.0 → memgres-0.7.2}/LICENSE +0 -0
  9. {memgres-0.7.0 → memgres-0.7.2}/README.md +0 -0
  10. {memgres-0.7.0 → memgres-0.7.2}/memgres/__init__.py +0 -0
  11. {memgres-0.7.0 → memgres-0.7.2}/memgres/admin.py +0 -0
  12. {memgres-0.7.0 → memgres-0.7.2}/memgres/admin_cli.py +0 -0
  13. {memgres-0.7.0 → memgres-0.7.2}/memgres/blame.py +0 -0
  14. {memgres-0.7.0 → memgres-0.7.2}/memgres/bootstrap.py +0 -0
  15. {memgres-0.7.0 → memgres-0.7.2}/memgres/config.py +0 -0
  16. {memgres-0.7.0 → memgres-0.7.2}/memgres/delimiters.py +0 -0
  17. {memgres-0.7.0 → memgres-0.7.2}/memgres/diffing.py +0 -0
  18. {memgres-0.7.0 → memgres-0.7.2}/memgres/embed_worker.py +0 -0
  19. {memgres-0.7.0 → memgres-0.7.2}/memgres/embeddings.py +0 -0
  20. {memgres-0.7.0 → memgres-0.7.2}/memgres/healthcheck.py +0 -0
  21. {memgres-0.7.0 → memgres-0.7.2}/memgres/identity.py +0 -0
  22. {memgres-0.7.0 → memgres-0.7.2}/memgres/indexing.py +0 -0
  23. {memgres-0.7.0 → memgres-0.7.2}/memgres/info.py +0 -0
  24. {memgres-0.7.0 → memgres-0.7.2}/memgres/lines.py +0 -0
  25. {memgres-0.7.0 → memgres-0.7.2}/memgres/links.py +0 -0
  26. {memgres-0.7.0 → memgres-0.7.2}/memgres/migrations/0001_core.sql +0 -0
  27. {memgres-0.7.0 → memgres-0.7.2}/memgres/migrations/0002_identity.sql +0 -0
  28. {memgres-0.7.0 → memgres-0.7.2}/memgres/migrations/0003_history_author.sql +0 -0
  29. {memgres-0.7.0 → memgres-0.7.2}/memgres/migrations/0004_title.sql +0 -0
  30. {memgres-0.7.0 → memgres-0.7.2}/memgres/migrations/0005_chunk_index.sql +0 -0
  31. {memgres-0.7.0 → memgres-0.7.2}/memgres/migrations/0006_reader_floor.sql +0 -0
  32. {memgres-0.7.0 → memgres-0.7.2}/memgres/migrations/0007_embed_retry.sql +0 -0
  33. {memgres-0.7.0 → memgres-0.7.2}/memgres/migrations/0008_service_roles.sql +0 -0
  34. {memgres-0.7.0 → memgres-0.7.2}/memgres/migrations/0009_create_namespace_right.sql +0 -0
  35. {memgres-0.7.0 → memgres-0.7.2}/memgres/migrations/0010_namespace_alias.sql +0 -0
  36. {memgres-0.7.0 → memgres-0.7.2}/memgres/migrations/0011_drop_default_namespace.sql +0 -0
  37. {memgres-0.7.0 → memgres-0.7.2}/memgres/migrations/0012_user_profile.sql +0 -0
  38. {memgres-0.7.0 → memgres-0.7.2}/memgres/migrations/0013_hash_version.sql +0 -0
  39. {memgres-0.7.0 → memgres-0.7.2}/memgres/migrations/0014_access_request_no_fk.sql +0 -0
  40. {memgres-0.7.0 → memgres-0.7.2}/memgres/migrations/0015_normalize_tags.sql +0 -0
  41. {memgres-0.7.0 → memgres-0.7.2}/memgres/migrations/0016_valid_at.sql +0 -0
  42. {memgres-0.7.0 → memgres-0.7.2}/memgres/migrations/0017_memory_link.sql +0 -0
  43. {memgres-0.7.0 → memgres-0.7.2}/memgres/migrations/0018_links_built.sql +0 -0
  44. {memgres-0.7.0 → memgres-0.7.2}/memgres/migrations/0019_memory_usage.sql +0 -0
  45. {memgres-0.7.0 → memgres-0.7.2}/memgres/migrations/0020_memory_usage_no_fk.sql +0 -0
  46. {memgres-0.7.0 → memgres-0.7.2}/memgres/periodic.py +0 -0
  47. {memgres-0.7.0 → memgres-0.7.2}/memgres/reembed.py +0 -0
  48. {memgres-0.7.0 → memgres-0.7.2}/memgres/relink.py +0 -0
  49. {memgres-0.7.0 → memgres-0.7.2}/memgres/schema.py +0 -0
  50. {memgres-0.7.0 → memgres-0.7.2}/memgres/search.py +0 -0
  51. {memgres-0.7.0 → memgres-0.7.2}/memgres/segments.py +0 -0
  52. {memgres-0.7.0 → memgres-0.7.2}/memgres/server.py +0 -0
  53. {memgres-0.7.0 → memgres-0.7.2}/memgres/store.py +0 -0
  54. {memgres-0.7.0 → memgres-0.7.2}/memgres/tags.py +0 -0
  55. {memgres-0.7.0 → memgres-0.7.2}/memgres/vector/__init__.py +0 -0
  56. {memgres-0.7.0 → memgres-0.7.2}/memgres/vector/base.py +0 -0
  57. {memgres-0.7.0 → memgres-0.7.2}/memgres/vector/pgvector.py +0 -0
  58. {memgres-0.7.0 → memgres-0.7.2}/memgres/vector/qdrant.py +0 -0
  59. {memgres-0.7.0 → memgres-0.7.2}/memgres/worker.py +0 -0
  60. {memgres-0.7.0 → memgres-0.7.2}/memgres.egg-info/dependency_links.txt +0 -0
  61. {memgres-0.7.0 → memgres-0.7.2}/memgres.egg-info/entry_points.txt +0 -0
  62. {memgres-0.7.0 → memgres-0.7.2}/memgres.egg-info/requires.txt +0 -0
  63. {memgres-0.7.0 → memgres-0.7.2}/memgres.egg-info/top_level.txt +0 -0
  64. {memgres-0.7.0 → memgres-0.7.2}/pyproject.toml +0 -0
  65. {memgres-0.7.0 → memgres-0.7.2}/setup.cfg +0 -0
  66. {memgres-0.7.0 → memgres-0.7.2}/tests/test_blame_integration.py +0 -0
  67. {memgres-0.7.0 → memgres-0.7.2}/tests/test_chunk_index.py +0 -0
  68. {memgres-0.7.0 → memgres-0.7.2}/tests/test_claim_and_reembed.py +0 -0
  69. {memgres-0.7.0 → memgres-0.7.2}/tests/test_config.py +0 -0
  70. {memgres-0.7.0 → memgres-0.7.2}/tests/test_diffing.py +0 -0
  71. {memgres-0.7.0 → memgres-0.7.2}/tests/test_embed_worker.py +0 -0
  72. {memgres-0.7.0 → memgres-0.7.2}/tests/test_embeddings.py +0 -0
  73. {memgres-0.7.0 → memgres-0.7.2}/tests/test_healthcheck.py +0 -0
  74. {memgres-0.7.0 → memgres-0.7.2}/tests/test_identity_integration.py +0 -0
  75. {memgres-0.7.0 → memgres-0.7.2}/tests/test_lexical_match.py +0 -0
  76. {memgres-0.7.0 → memgres-0.7.2}/tests/test_limits.py +0 -0
  77. {memgres-0.7.0 → memgres-0.7.2}/tests/test_links.py +0 -0
  78. {memgres-0.7.0 → memgres-0.7.2}/tests/test_list.py +0 -0
  79. {memgres-0.7.0 → memgres-0.7.2}/tests/test_mcp_admin_tools.py +0 -0
  80. {memgres-0.7.0 → memgres-0.7.2}/tests/test_mcp_http_transport.py +0 -0
  81. {memgres-0.7.0 → memgres-0.7.2}/tests/test_mcp_instructions.py +0 -0
  82. {memgres-0.7.0 → memgres-0.7.2}/tests/test_mcp_recall_schema.py +0 -0
  83. {memgres-0.7.0 → memgres-0.7.2}/tests/test_migration_upgrade.py +0 -0
  84. {memgres-0.7.0 → memgres-0.7.2}/tests/test_multi_space_search.py +0 -0
  85. {memgres-0.7.0 → memgres-0.7.2}/tests/test_path_addressing.py +0 -0
  86. {memgres-0.7.0 → memgres-0.7.2}/tests/test_qdrant_ca.py +0 -0
  87. {memgres-0.7.0 → memgres-0.7.2}/tests/test_qdrant_integration.py +0 -0
  88. {memgres-0.7.0 → memgres-0.7.2}/tests/test_replace_build.py +0 -0
  89. {memgres-0.7.0 → memgres-0.7.2}/tests/test_require_title.py +0 -0
  90. {memgres-0.7.0 → memgres-0.7.2}/tests/test_retention.py +0 -0
  91. {memgres-0.7.0 → memgres-0.7.2}/tests/test_roles_bootstrap.py +0 -0
  92. {memgres-0.7.0 → memgres-0.7.2}/tests/test_search_integration.py +0 -0
  93. {memgres-0.7.0 → memgres-0.7.2}/tests/test_security_integration.py +0 -0
  94. {memgres-0.7.0 → memgres-0.7.2}/tests/test_segments.py +0 -0
  95. {memgres-0.7.0 → memgres-0.7.2}/tests/test_segments_store.py +0 -0
  96. {memgres-0.7.0 → memgres-0.7.2}/tests/test_server_info.py +0 -0
  97. {memgres-0.7.0 → memgres-0.7.2}/tests/test_server_integration.py +0 -0
  98. {memgres-0.7.0 → memgres-0.7.2}/tests/test_snippets.py +0 -0
  99. {memgres-0.7.0 → memgres-0.7.2}/tests/test_store_integration.py +0 -0
  100. {memgres-0.7.0 → memgres-0.7.2}/tests/test_tags.py +0 -0
  101. {memgres-0.7.0 → memgres-0.7.2}/tests/test_usage.py +0 -0
  102. {memgres-0.7.0 → memgres-0.7.2}/tests/test_valid_at.py +0 -0
  103. {memgres-0.7.0 → 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.0
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.0"
11
+ __version__ = "0.7.2"
@@ -47,8 +47,19 @@ Spaces = Optional[Union[str, List[str]]]
47
47
 
48
48
  # The MCP `initialize` response carries a server-side `instructions` string; a
49
49
  # client that honors it (e.g. Claude Code) loads it ONCE at connect, so it guides
50
- # the model without inflating every tool response. Kept small on purpose.
51
- MCP_INSTRUCTION_MAX_BYTES = 2048
50
+ # the model without inflating every tool response.
51
+ #
52
+ # A GUARDRAIL, not a target. The real cost of a long instruction is not the tokens
53
+ # — it is loaded once, and 4 KB is well under a thousand of them — but that it sits
54
+ # in the system prompt and is re-read every turn: a rulebook is followed less
55
+ # reliably the more it says. Keep the always-true rules inline and point at a
56
+ # memory for anything situational; the store the agent is being told about is
57
+ # itself the place for depth.
58
+ #
59
+ # Counted in BYTES, which matters more than it looks: non-Latin text costs two
60
+ # bytes per character, so the same rulebook in Russian fits half as much. 2048 was
61
+ # too tight for a bilingual deployment to say what it had to.
62
+ MCP_INSTRUCTION_MAX_BYTES = 4096
52
63
 
53
64
 
54
65
  # ─── what each tool needs before it is worth showing ─────────────────────────
@@ -259,12 +270,20 @@ def build_server(cfg: Optional[Config] = None):
259
270
 
260
271
  Security does not depend on the schema-pruning below: even if the `token`
261
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.
262
277
  req = None
263
- if ctx is not None:
278
+ for probe in (lambda: ctx.request_context.request, lambda: ctx.request):
279
+ if ctx is None:
280
+ break
264
281
  try:
265
- req = ctx.request_context.request
282
+ req = probe()
266
283
  except Exception:
267
284
  req = None
285
+ if req is not None:
286
+ break
268
287
  if req is not None:
269
288
  try:
270
289
  tok = identity.bearer_token(req.headers.get("authorization"),
@@ -941,7 +960,7 @@ def build_server(cfg: Optional[Config] = None):
941
960
  "can_manage_own_tokens", "can_administer_deployment",
942
961
  "has_admin_ceiling", "is_admin")}
943
962
 
944
- def _caps_for_caller():
963
+ def _caps_for_caller(ctx):
945
964
  """This request's capabilities, or None if the caller is unknown.
946
965
 
947
966
  Only a failed CREDENTIAL answers None. A database that is briefly
@@ -955,10 +974,6 @@ def build_server(cfg: Optional[Config] = None):
955
974
  # One implicit caller who may do everything; there is no
956
975
  # credential to resolve and nothing to withhold.
957
976
  return _all_caps
958
- try:
959
- ctx = mcp.get_context()
960
- except Exception:
961
- ctx = None
962
977
  with pool.connection() as conn:
963
978
  try:
964
979
  # `touch=False`: listing tools is asking ABOUT the
@@ -971,9 +986,9 @@ def build_server(cfg: Optional[Config] = None):
971
986
  return None
972
987
  return admin.capabilities(conn, p)
973
988
 
974
- def _keep(tools):
989
+ def _keep(tools, ctx):
975
990
  allowed = set(visible_tools([t.name for t in tools],
976
- _caps_for_caller(), _identity_on))
991
+ _caps_for_caller(ctx), _identity_on))
977
992
  return [t for t in tools if t.name in allowed]
978
993
 
979
994
  # Where the filter attaches differs by SDK generation, so both are
@@ -991,16 +1006,37 @@ def build_server(cfg: Optional[Config] = None):
991
1006
  # every client still seeing every tool, no error anywhere. A guard that
992
1007
  # turns "this build cannot do what you configured" into silence is worse
993
1008
  # than the crash it prevents, so an unrecognised SDK now says so.
994
- if hasattr(mcp, "_handle_list_tools"): # mcp 2.x
995
- _unfiltered = mcp.list_tools
996
-
997
- async def _list_visible_tools():
998
- return _keep(await _unfiltered())
999
-
1000
- 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)
1001
1032
  elif hasattr(mcp, "_mcp_server"): # mcp 1.x
1002
1033
  async def _list_visible_tools():
1003
- 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)
1004
1040
 
1005
1041
  mcp._mcp_server.list_tools()(_list_visible_tools)
1006
1042
  else: # pragma: no cover
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memgres
3
- Version: 0.7.0
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