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.
- {memgres-0.7.0 → memgres-0.7.2}/PKG-INFO +1 -1
- {memgres-0.7.0 → memgres-0.7.2}/memgres/_version.py +1 -1
- {memgres-0.7.0 → memgres-0.7.2}/memgres/mcp_server.py +55 -19
- {memgres-0.7.0 → memgres-0.7.2}/memgres.egg-info/PKG-INFO +1 -1
- {memgres-0.7.0 → memgres-0.7.2}/memgres.egg-info/SOURCES.txt +1 -0
- {memgres-0.7.0 → memgres-0.7.2}/tests/test_mcp_tool_visibility.py +6 -1
- memgres-0.7.2/tests/test_mcp_tool_visibility_http.py +332 -0
- {memgres-0.7.0 → memgres-0.7.2}/LICENSE +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/README.md +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/__init__.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/admin.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/admin_cli.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/blame.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/bootstrap.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/config.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/delimiters.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/diffing.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/embed_worker.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/embeddings.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/healthcheck.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/identity.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/indexing.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/info.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/lines.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/links.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/migrations/0001_core.sql +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/migrations/0002_identity.sql +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/migrations/0003_history_author.sql +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/migrations/0004_title.sql +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/migrations/0005_chunk_index.sql +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/migrations/0006_reader_floor.sql +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/migrations/0007_embed_retry.sql +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/migrations/0008_service_roles.sql +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/migrations/0009_create_namespace_right.sql +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/migrations/0010_namespace_alias.sql +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/migrations/0011_drop_default_namespace.sql +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/migrations/0012_user_profile.sql +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/migrations/0013_hash_version.sql +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/migrations/0014_access_request_no_fk.sql +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/migrations/0015_normalize_tags.sql +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/migrations/0016_valid_at.sql +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/migrations/0017_memory_link.sql +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/migrations/0018_links_built.sql +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/migrations/0019_memory_usage.sql +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/migrations/0020_memory_usage_no_fk.sql +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/periodic.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/reembed.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/relink.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/schema.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/search.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/segments.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/server.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/store.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/tags.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/vector/__init__.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/vector/base.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/vector/pgvector.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/vector/qdrant.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres/worker.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres.egg-info/dependency_links.txt +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres.egg-info/entry_points.txt +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres.egg-info/requires.txt +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/memgres.egg-info/top_level.txt +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/pyproject.toml +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/setup.cfg +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/tests/test_blame_integration.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/tests/test_chunk_index.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/tests/test_claim_and_reembed.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/tests/test_config.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/tests/test_diffing.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/tests/test_embed_worker.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/tests/test_embeddings.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/tests/test_healthcheck.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/tests/test_identity_integration.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/tests/test_lexical_match.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/tests/test_limits.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/tests/test_links.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/tests/test_list.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/tests/test_mcp_admin_tools.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/tests/test_mcp_http_transport.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/tests/test_mcp_instructions.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/tests/test_mcp_recall_schema.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/tests/test_migration_upgrade.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/tests/test_multi_space_search.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/tests/test_path_addressing.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/tests/test_qdrant_ca.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/tests/test_qdrant_integration.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/tests/test_replace_build.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/tests/test_require_title.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/tests/test_retention.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/tests/test_roles_bootstrap.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/tests/test_search_integration.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/tests/test_security_integration.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/tests/test_segments.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/tests/test_segments_store.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/tests/test_server_info.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/tests/test_server_integration.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/tests/test_snippets.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/tests/test_store_integration.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/tests/test_tags.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/tests/test_usage.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/tests/test_valid_at.py +0 -0
- {memgres-0.7.0 → memgres-0.7.2}/tests/test_write_ergonomics.py +0 -0
|
@@ -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.
|
|
51
|
-
|
|
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
|
-
|
|
278
|
+
for probe in (lambda: ctx.request_context.request, lambda: ctx.request):
|
|
279
|
+
if ctx is None:
|
|
280
|
+
break
|
|
264
281
|
try:
|
|
265
|
-
req =
|
|
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, "
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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
|
|
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
|