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