memgres 0.12.0__tar.gz → 0.12.1__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 (114) hide show
  1. {memgres-0.12.0 → memgres-0.12.1}/PKG-INFO +1 -1
  2. {memgres-0.12.0 → memgres-0.12.1}/memgres/_version.py +1 -1
  3. {memgres-0.12.0 → memgres-0.12.1}/memgres/links.py +5 -13
  4. {memgres-0.12.0 → memgres-0.12.1}/memgres/mcp_server.py +86 -44
  5. memgres-0.12.1/memgres/paths.py +49 -0
  6. {memgres-0.12.0 → memgres-0.12.1}/memgres/store.py +2 -0
  7. {memgres-0.12.0 → memgres-0.12.1}/memgres/vector/base.py +5 -1
  8. {memgres-0.12.0 → memgres-0.12.1}/memgres.egg-info/PKG-INFO +1 -1
  9. {memgres-0.12.0 → memgres-0.12.1}/memgres.egg-info/SOURCES.txt +2 -0
  10. memgres-0.12.1/tests/test_mcp_error_messages.py +207 -0
  11. {memgres-0.12.0 → memgres-0.12.1}/LICENSE +0 -0
  12. {memgres-0.12.0 → memgres-0.12.1}/README.md +0 -0
  13. {memgres-0.12.0 → memgres-0.12.1}/memgres/__init__.py +0 -0
  14. {memgres-0.12.0 → memgres-0.12.1}/memgres/admin.py +0 -0
  15. {memgres-0.12.0 → memgres-0.12.1}/memgres/admin_cli.py +0 -0
  16. {memgres-0.12.0 → memgres-0.12.1}/memgres/blame.py +0 -0
  17. {memgres-0.12.0 → memgres-0.12.1}/memgres/bootstrap.py +0 -0
  18. {memgres-0.12.0 → memgres-0.12.1}/memgres/config.py +0 -0
  19. {memgres-0.12.0 → memgres-0.12.1}/memgres/delimiters.py +0 -0
  20. {memgres-0.12.0 → memgres-0.12.1}/memgres/diffing.py +0 -0
  21. {memgres-0.12.0 → memgres-0.12.1}/memgres/embed_worker.py +0 -0
  22. {memgres-0.12.0 → memgres-0.12.1}/memgres/embeddings.py +0 -0
  23. {memgres-0.12.0 → memgres-0.12.1}/memgres/healthcheck.py +0 -0
  24. {memgres-0.12.0 → memgres-0.12.1}/memgres/identity.py +0 -0
  25. {memgres-0.12.0 → memgres-0.12.1}/memgres/indexing.py +0 -0
  26. {memgres-0.12.0 → memgres-0.12.1}/memgres/info.py +0 -0
  27. {memgres-0.12.0 → memgres-0.12.1}/memgres/lines.py +0 -0
  28. {memgres-0.12.0 → memgres-0.12.1}/memgres/migrations/0001_core.sql +0 -0
  29. {memgres-0.12.0 → memgres-0.12.1}/memgres/migrations/0002_identity.sql +0 -0
  30. {memgres-0.12.0 → memgres-0.12.1}/memgres/migrations/0003_history_author.sql +0 -0
  31. {memgres-0.12.0 → memgres-0.12.1}/memgres/migrations/0004_title.sql +0 -0
  32. {memgres-0.12.0 → memgres-0.12.1}/memgres/migrations/0005_chunk_index.sql +0 -0
  33. {memgres-0.12.0 → memgres-0.12.1}/memgres/migrations/0006_reader_floor.sql +0 -0
  34. {memgres-0.12.0 → memgres-0.12.1}/memgres/migrations/0007_embed_retry.sql +0 -0
  35. {memgres-0.12.0 → memgres-0.12.1}/memgres/migrations/0008_service_roles.sql +0 -0
  36. {memgres-0.12.0 → memgres-0.12.1}/memgres/migrations/0009_create_namespace_right.sql +0 -0
  37. {memgres-0.12.0 → memgres-0.12.1}/memgres/migrations/0010_namespace_alias.sql +0 -0
  38. {memgres-0.12.0 → memgres-0.12.1}/memgres/migrations/0011_drop_default_namespace.sql +0 -0
  39. {memgres-0.12.0 → memgres-0.12.1}/memgres/migrations/0012_user_profile.sql +0 -0
  40. {memgres-0.12.0 → memgres-0.12.1}/memgres/migrations/0013_hash_version.sql +0 -0
  41. {memgres-0.12.0 → memgres-0.12.1}/memgres/migrations/0014_access_request_no_fk.sql +0 -0
  42. {memgres-0.12.0 → memgres-0.12.1}/memgres/migrations/0015_normalize_tags.sql +0 -0
  43. {memgres-0.12.0 → memgres-0.12.1}/memgres/migrations/0016_valid_at.sql +0 -0
  44. {memgres-0.12.0 → memgres-0.12.1}/memgres/migrations/0017_memory_link.sql +0 -0
  45. {memgres-0.12.0 → memgres-0.12.1}/memgres/migrations/0018_links_built.sql +0 -0
  46. {memgres-0.12.0 → memgres-0.12.1}/memgres/migrations/0019_memory_usage.sql +0 -0
  47. {memgres-0.12.0 → memgres-0.12.1}/memgres/migrations/0020_memory_usage_no_fk.sql +0 -0
  48. {memgres-0.12.0 → memgres-0.12.1}/memgres/migrations/0021_enrollment_key.sql +0 -0
  49. {memgres-0.12.0 → memgres-0.12.1}/memgres/migrations/0022_user_disabled.sql +0 -0
  50. {memgres-0.12.0 → memgres-0.12.1}/memgres/migrations/0023_relink_after_parser_fix.sql +0 -0
  51. {memgres-0.12.0 → memgres-0.12.1}/memgres/periodic.py +0 -0
  52. {memgres-0.12.0 → memgres-0.12.1}/memgres/reembed.py +0 -0
  53. {memgres-0.12.0 → memgres-0.12.1}/memgres/relink.py +0 -0
  54. {memgres-0.12.0 → memgres-0.12.1}/memgres/schema.py +0 -0
  55. {memgres-0.12.0 → memgres-0.12.1}/memgres/search.py +0 -0
  56. {memgres-0.12.0 → memgres-0.12.1}/memgres/segments.py +0 -0
  57. {memgres-0.12.0 → memgres-0.12.1}/memgres/server.py +0 -0
  58. {memgres-0.12.0 → memgres-0.12.1}/memgres/tags.py +0 -0
  59. {memgres-0.12.0 → memgres-0.12.1}/memgres/token_cli.py +0 -0
  60. {memgres-0.12.0 → memgres-0.12.1}/memgres/vector/__init__.py +0 -0
  61. {memgres-0.12.0 → memgres-0.12.1}/memgres/vector/pgvector.py +0 -0
  62. {memgres-0.12.0 → memgres-0.12.1}/memgres/vector/qdrant.py +0 -0
  63. {memgres-0.12.0 → memgres-0.12.1}/memgres/worker.py +0 -0
  64. {memgres-0.12.0 → memgres-0.12.1}/memgres.egg-info/dependency_links.txt +0 -0
  65. {memgres-0.12.0 → memgres-0.12.1}/memgres.egg-info/entry_points.txt +0 -0
  66. {memgres-0.12.0 → memgres-0.12.1}/memgres.egg-info/requires.txt +0 -0
  67. {memgres-0.12.0 → memgres-0.12.1}/memgres.egg-info/top_level.txt +0 -0
  68. {memgres-0.12.0 → memgres-0.12.1}/pyproject.toml +0 -0
  69. {memgres-0.12.0 → memgres-0.12.1}/setup.cfg +0 -0
  70. {memgres-0.12.0 → memgres-0.12.1}/tests/test_admin_two_way.py +0 -0
  71. {memgres-0.12.0 → memgres-0.12.1}/tests/test_blame_integration.py +0 -0
  72. {memgres-0.12.0 → memgres-0.12.1}/tests/test_chunk_index.py +0 -0
  73. {memgres-0.12.0 → memgres-0.12.1}/tests/test_claim_and_reembed.py +0 -0
  74. {memgres-0.12.0 → memgres-0.12.1}/tests/test_config.py +0 -0
  75. {memgres-0.12.0 → memgres-0.12.1}/tests/test_diffing.py +0 -0
  76. {memgres-0.12.0 → memgres-0.12.1}/tests/test_embed_worker.py +0 -0
  77. {memgres-0.12.0 → memgres-0.12.1}/tests/test_embeddings.py +0 -0
  78. {memgres-0.12.0 → memgres-0.12.1}/tests/test_enrollment.py +0 -0
  79. {memgres-0.12.0 → memgres-0.12.1}/tests/test_healthcheck.py +0 -0
  80. {memgres-0.12.0 → memgres-0.12.1}/tests/test_identity_integration.py +0 -0
  81. {memgres-0.12.0 → memgres-0.12.1}/tests/test_lexical_match.py +0 -0
  82. {memgres-0.12.0 → memgres-0.12.1}/tests/test_limits.py +0 -0
  83. {memgres-0.12.0 → memgres-0.12.1}/tests/test_links.py +0 -0
  84. {memgres-0.12.0 → memgres-0.12.1}/tests/test_list.py +0 -0
  85. {memgres-0.12.0 → memgres-0.12.1}/tests/test_mcp_admin_tools.py +0 -0
  86. {memgres-0.12.0 → memgres-0.12.1}/tests/test_mcp_http_transport.py +0 -0
  87. {memgres-0.12.0 → memgres-0.12.1}/tests/test_mcp_instructions.py +0 -0
  88. {memgres-0.12.0 → memgres-0.12.1}/tests/test_mcp_recall_schema.py +0 -0
  89. {memgres-0.12.0 → memgres-0.12.1}/tests/test_mcp_tool_visibility.py +0 -0
  90. {memgres-0.12.0 → memgres-0.12.1}/tests/test_mcp_tool_visibility_http.py +0 -0
  91. {memgres-0.12.0 → memgres-0.12.1}/tests/test_migration_upgrade.py +0 -0
  92. {memgres-0.12.0 → memgres-0.12.1}/tests/test_multi_space_search.py +0 -0
  93. {memgres-0.12.0 → memgres-0.12.1}/tests/test_path_addressing.py +0 -0
  94. {memgres-0.12.0 → memgres-0.12.1}/tests/test_qdrant_ca.py +0 -0
  95. {memgres-0.12.0 → memgres-0.12.1}/tests/test_qdrant_integration.py +0 -0
  96. {memgres-0.12.0 → memgres-0.12.1}/tests/test_replace_build.py +0 -0
  97. {memgres-0.12.0 → memgres-0.12.1}/tests/test_require_title.py +0 -0
  98. {memgres-0.12.0 → memgres-0.12.1}/tests/test_required_fields.py +0 -0
  99. {memgres-0.12.0 → memgres-0.12.1}/tests/test_retention.py +0 -0
  100. {memgres-0.12.0 → memgres-0.12.1}/tests/test_roles_bootstrap.py +0 -0
  101. {memgres-0.12.0 → memgres-0.12.1}/tests/test_search_integration.py +0 -0
  102. {memgres-0.12.0 → memgres-0.12.1}/tests/test_security_followups.py +0 -0
  103. {memgres-0.12.0 → memgres-0.12.1}/tests/test_security_integration.py +0 -0
  104. {memgres-0.12.0 → memgres-0.12.1}/tests/test_segments.py +0 -0
  105. {memgres-0.12.0 → memgres-0.12.1}/tests/test_segments_store.py +0 -0
  106. {memgres-0.12.0 → memgres-0.12.1}/tests/test_server_info.py +0 -0
  107. {memgres-0.12.0 → memgres-0.12.1}/tests/test_server_integration.py +0 -0
  108. {memgres-0.12.0 → memgres-0.12.1}/tests/test_snippets.py +0 -0
  109. {memgres-0.12.0 → memgres-0.12.1}/tests/test_store_integration.py +0 -0
  110. {memgres-0.12.0 → memgres-0.12.1}/tests/test_tags.py +0 -0
  111. {memgres-0.12.0 → memgres-0.12.1}/tests/test_token_sink.py +0 -0
  112. {memgres-0.12.0 → memgres-0.12.1}/tests/test_usage.py +0 -0
  113. {memgres-0.12.0 → memgres-0.12.1}/tests/test_valid_at.py +0 -0
  114. {memgres-0.12.0 → memgres-0.12.1}/tests/test_write_ergonomics.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memgres
3
- Version: 0.12.0
3
+ Version: 0.12.1
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.12.0"
11
+ __version__ = "0.12.1"
@@ -52,19 +52,11 @@ from typing import Dict, List, Optional
52
52
  # edges so they are visible, never resolved — we do not own the address space.
53
53
  KNOWN_SCHEMES = ("idea", "file")
54
54
 
55
- # An ltree path: labels of [A-Za-z0-9_-] joined by dots. Deliberately strict —
56
- # this is what tells a path apart from a slug belonging to another store, and
57
- # from prose that happens to sit in double brackets.
58
- #
59
- # The hyphen is there because PATHS MAY CONTAIN ONE. Postgres has allowed `-` in
60
- # ltree labels since 13 (the minimum this project supports), `write` accepts such
61
- # a path without complaint, and real corpora are full of them —
62
- # `infra.servers.video-production`. While this pattern rejected the hyphen the
63
- # two halves of the product disagreed about what a path is: the link was stored
64
- # as prose, `_classify` returned "ignore", and the edge did not even become a
65
- # DANGLING one. It vanished, and `memory_links` answered "nothing points here",
66
- # which reads as a fact about the corpus rather than as a parser that quit.
67
- _PATH = re.compile(r"^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$")
55
+ # What a path is lives in `paths.py`, so the parser and the store cannot drift
56
+ # apart again — that drift is exactly what dropped every hyphenated link before
57
+ # 0.12.0. Kept under the old name because the rest of this module reads better
58
+ # with it.
59
+ from .paths import PATH_RE as _PATH
68
60
 
69
61
  _LINK = re.compile(r"\[\[([^\[\]\n]+)\]\]")
70
62
  _FENCE = re.compile(r"```.*?```|~~~.*?~~~", re.S)
@@ -32,8 +32,10 @@ from typing_extensions import Annotated
32
32
 
33
33
  try: # mcp SDK >= 2.0 renamed the module fastmcp -> mcpserver
34
34
  from mcp.server.mcpserver import Context
35
+ from mcp.server.mcpserver.exceptions import ToolError
35
36
  except ImportError: # mcp SDK 1.x
36
37
  from mcp.server.fastmcp import Context
38
+ from mcp.server.fastmcp.exceptions import ToolError
37
39
 
38
40
  from . import admin, identity
39
41
  from .config import Config, load
@@ -269,6 +271,40 @@ def _mcp(name: str, instructions: Optional[str] = None):
269
271
  return server
270
272
 
271
273
 
274
+ # Refusals whose text is written FOR the caller and safe to show them: what is
275
+ # missing, which namespace to name, why the diff did not apply, what they may
276
+ # not do. Everything else — a psycopg error, a bug — stays masked, because those
277
+ # messages carry schema and internals rather than instructions.
278
+ _SPEAKABLE = (ValueError, PermissionError, LookupError)
279
+
280
+
281
+ def _speaking(fn):
282
+ """Re-raise a domain refusal as the SDK's own error type, which is the only
283
+ kind whose message reaches the client.
284
+
285
+ Everything else the SDK catches becomes `Error executing tool <name>` with no
286
+ detail — and that is what the caller sees. The refusals here are written to
287
+ be read: "this deployment requires `source` … as an ADDRESS", "you can reach
288
+ 2 namespaces, name the one you mean". Masking them turns a fixable mistake
289
+ into a mystery, and an agent that cannot see the reason repeats the same call
290
+ — which is exactly what happened: five identical edits in a row, then the
291
+ same on reads, against a server that was explaining itself into the void.
292
+ """
293
+ import functools
294
+
295
+ @functools.wraps(fn)
296
+ def wrapper(*args, **kwargs):
297
+ try:
298
+ return fn(*args, **kwargs)
299
+ except ToolError:
300
+ raise
301
+ except _SPEAKABLE as e:
302
+ # `from e` keeps the original in the server log, where the traceback
303
+ # belongs; the client gets the sentence, not the stack.
304
+ raise ToolError(str(e)) from e
305
+ return wrapper
306
+
307
+
272
308
  def _mem(m) -> dict:
273
309
  return m.to_dict(stringify_dates=True) # MCP layer needs plain strings
274
310
 
@@ -292,6 +328,12 @@ def build_server(cfg: Optional[Config] = None):
292
328
  _worker, cfg, backend = wire_server(cfg, embedder)
293
329
  mcp = _mcp("memgres", instructions=_instruction_text())
294
330
 
331
+ def tool(*d_args, **d_kwargs):
332
+ """`mcp.tool()`, but the refusal text survives the trip to the client."""
333
+ def deco(fn):
334
+ return mcp.tool(*d_args, **d_kwargs)(_speaking(fn))
335
+ return deco
336
+
295
337
  import os as _os
296
338
 
297
339
  # The control-plane tools. `auto` registers them wherever there are
@@ -389,7 +431,7 @@ def build_server(cfg: Optional[Config] = None):
389
431
  """
390
432
  return identity.resolve(conn, cfg, token)
391
433
 
392
- @mcp.tool()
434
+ @tool()
393
435
  def memory_write(body: Optional[str] = None, id: Optional[str] = None,
394
436
  at: Optional[str] = None,
395
437
  if_moved: IfMovedArg = "error",
@@ -445,7 +487,7 @@ def build_server(cfg: Optional[Config] = None):
445
487
  reason=reason, valid_at=valid_at, space=space,
446
488
  space_id=space_id))
447
489
 
448
- @mcp.tool()
490
+ @tool()
449
491
  def memory_get(id: Optional[str] = None, at: Optional[str] = None,
450
492
  if_moved: Literal["follow", "error"] = "follow",
451
493
  lines: Optional[str] = None,
@@ -471,7 +513,7 @@ def build_server(cfg: Optional[Config] = None):
471
513
  if_moved=if_moved, lines=lines,
472
514
  space=space, space_id=space_id))
473
515
 
474
- @mcp.tool()
516
+ @tool()
475
517
  def memory_recall(query: str, k: int = 10,
476
518
  mode: Literal["lexical", "semantic", "hybrid", "auto"] = "auto",
477
519
  match: Optional[Literal["any", "all"]] = None,
@@ -515,7 +557,7 @@ def build_server(cfg: Optional[Config] = None):
515
557
  snippet=snippet, full_body=full_body, bodies=bodies,
516
558
  match_tags=match_tags, space=space, space_id=space_id)]
517
559
 
518
- @mcp.tool()
560
+ @tool()
519
561
  def memory_list(path_prefix: Optional[str] = None,
520
562
  tags: Optional[List[str]] = None, limit: int = 50,
521
563
  offset: int = 0, bodies: bool = False,
@@ -543,7 +585,7 @@ def build_server(cfg: Optional[Config] = None):
543
585
  limit=limit, offset=offset, bodies=bodies,
544
586
  match_tags=match_tags, space=space, space_id=space_id)
545
587
 
546
- @mcp.tool()
588
+ @tool()
547
589
  def memory_tags(prefix: Optional[str] = None, k: int = 50,
548
590
  space: Spaces = None, space_id: Spaces = None,
549
591
  ctx: Context = None) -> List[dict]:
@@ -558,7 +600,7 @@ def build_server(cfg: Optional[Config] = None):
558
600
  return _store(conn).tags(_token(ctx), prefix=prefix, k=k,
559
601
  space=space, space_id=space_id)
560
602
 
561
- @mcp.tool()
603
+ @tool()
562
604
  def memory_links(id: Optional[str] = None, at: Optional[str] = None,
563
605
  direction: Literal["in", "out", "both"] = "both",
564
606
  space: Optional[str] = None, space_id: Optional[str] = None,
@@ -579,7 +621,7 @@ def build_server(cfg: Optional[Config] = None):
579
621
  at=at or None, direction=direction,
580
622
  space=space, space_id=space_id)
581
623
 
582
- @mcp.tool()
624
+ @tool()
583
625
  def memory_server_info(ctx: Context = None) -> dict:
584
626
  """The server's version + schema_version and its effective limits and
585
627
  capabilities (write ceilings, embed provider/model/dim, available recall
@@ -589,7 +631,7 @@ def build_server(cfg: Optional[Config] = None):
589
631
  dim = embedder.dim if embedder is not None else None
590
632
  return server_info(cfg, embed_dim=dim)
591
633
 
592
- @mcp.tool()
634
+ @tool()
593
635
  def memory_blame(id: Optional[str] = None, at: Optional[str] = None,
594
636
  grouped: bool = True, lines: Optional[str] = None,
595
637
  space: Optional[str] = None, space_id: Optional[str] = None,
@@ -609,7 +651,7 @@ def build_server(cfg: Optional[Config] = None):
609
651
  return s.annotate(tok, id, lines=want, at=at, space=space,
610
652
  space_id=space_id)
611
653
 
612
- @mcp.tool()
654
+ @tool()
613
655
  def memory_history(id: Optional[str] = None, at: Optional[str] = None,
614
656
  space: Optional[str] = None,
615
657
  space_id: Optional[str] = None,
@@ -622,7 +664,7 @@ def build_server(cfg: Optional[Config] = None):
622
664
  return _store(conn).history(_token(ctx), id, at=at,
623
665
  space=space, space_id=space_id)
624
666
 
625
- @mcp.tool()
667
+ @tool()
626
668
  def memory_move(new_path: str, id: Optional[str] = None,
627
669
  at: Optional[str] = None,
628
670
  if_moved: Literal["error", "follow"] = "error",
@@ -638,7 +680,7 @@ def build_server(cfg: Optional[Config] = None):
638
680
  reason=reason, space=space,
639
681
  space_id=space_id))
640
682
 
641
- @mcp.tool()
683
+ @tool()
642
684
  def memory_forget(id: Optional[str] = None, at: Optional[str] = None,
643
685
  space: Optional[str] = None,
644
686
  space_id: Optional[str] = None,
@@ -652,7 +694,7 @@ def build_server(cfg: Optional[Config] = None):
652
694
  _token(ctx), id, at=at, space=space, space_id=space_id)}
653
695
 
654
696
  # ─── identity: spaces & tokens (open/managed modes) ─────────────────────
655
- @mcp.tool()
697
+ @tool()
656
698
  def memory_list_spaces(ctx: Context = None) -> List[dict]:
657
699
  """List the namespaces you can reach — your own plus any shared with you —
658
700
  with each one's id, name, description, permission, and your `alias` for it
@@ -661,7 +703,7 @@ def build_server(cfg: Optional[Config] = None):
661
703
  with pool.connection() as conn, conn.transaction():
662
704
  return identity.list_spaces(conn, _uid(conn, _token(ctx)))
663
705
 
664
- @mcp.tool()
706
+ @tool()
665
707
  def memory_create_space(name: str, description: str = "",
666
708
  instruction: str = "",
667
709
  ctx: Context = None) -> dict:
@@ -675,7 +717,7 @@ def build_server(cfg: Optional[Config] = None):
675
717
  description=description, instruction=instruction)
676
718
  return {"id": nsid, "name": name}
677
719
 
678
- @mcp.tool()
720
+ @tool()
679
721
  def memory_set_alias(alias: str, space_id: str,
680
722
  ctx: Context = None) -> dict:
681
723
  """Give a namespace a name of your own, for when a bare name is ambiguous
@@ -688,7 +730,7 @@ def build_server(cfg: Optional[Config] = None):
688
730
  identity.create_alias(conn, uid, alias, space_id)
689
731
  return {"alias": alias, "space_id": space_id}
690
732
 
691
- @mcp.tool()
733
+ @tool()
692
734
  def memory_drop_alias(alias: str,
693
735
  ctx: Context = None) -> dict:
694
736
  """Remove one of your namespace aliases. The namespace itself is
@@ -698,7 +740,7 @@ def build_server(cfg: Optional[Config] = None):
698
740
  return {"dropped": identity.drop_alias(conn, uid, alias)}
699
741
 
700
742
  if cfg.key_mode == "managed":
701
- @mcp.tool()
743
+ @tool()
702
744
  def memory_enroll(key: EnrollKeyArg, ctx: Context = None) -> dict:
703
745
  """Claim an account with a one-time enrollment key.
704
746
 
@@ -734,7 +776,7 @@ def build_server(cfg: Optional[Config] = None):
734
776
  "tools you can now use are not showing yet.")
735
777
  return out
736
778
 
737
- @mcp.tool()
779
+ @tool()
738
780
  def memory_issue_token(permission: str = "write", space: Optional[str] = None,
739
781
  space_id: Optional[str] = None, label: str = "",
740
782
  expires_days: Optional[int] = None,
@@ -789,7 +831,7 @@ def build_server(cfg: Optional[Config] = None):
789
831
  out.update({"permission": permission, "namespace_id": nsid})
790
832
  return out
791
833
 
792
- @mcp.tool()
834
+ @tool()
793
835
  def memory_request_access(space_id: str, permission: str = "read",
794
836
  ctx: Context = None) -> dict:
795
837
  """Ask the owner of a namespace to let you in.
@@ -804,14 +846,14 @@ def build_server(cfg: Optional[Config] = None):
804
846
  namespace_id=space_id,
805
847
  permission=permission)
806
848
 
807
- @mcp.tool()
849
+ @tool()
808
850
  def memory_list_tokens(ctx: Context = None) -> List[dict]:
809
851
  """List your tokens (metadata only — never the secret)."""
810
852
  with pool.connection() as conn, conn.transaction():
811
853
  out = identity.list_tokens(conn, _admin_uid(conn, _token(ctx)))
812
854
  return _iso(out, *_TOKEN_TIMES)
813
855
 
814
- @mcp.tool()
856
+ @tool()
815
857
  def memory_revoke_token(token_id: str,
816
858
  ctx: Context = None) -> dict:
817
859
  """Revoke one of your tokens by id (kills it immediately)."""
@@ -822,7 +864,7 @@ def build_server(cfg: Optional[Config] = None):
822
864
  raise identity.AuthError("not your token")
823
865
  return {"revoked": identity.revoke_token(conn, token_id)}
824
866
 
825
- @mcp.tool()
867
+ @tool()
826
868
  def memory_whoami(ctx: Context = None) -> dict:
827
869
  """Who you are and what you may do: user id, service role, this
828
870
  credential's permission ceiling and namespace scope, plus the
@@ -834,7 +876,7 @@ def build_server(cfg: Optional[Config] = None):
834
876
  # ─── control plane: provisioning (authorized in `admin`, not here) ───────
835
877
  if admin_surface:
836
878
 
837
- @mcp.tool()
879
+ @tool()
838
880
  def memory_admin_list_users(role: Optional[str] = None, limit: int = 50,
839
881
  offset: int = 0,
840
882
  ctx: Context = None) -> List[dict]:
@@ -846,7 +888,7 @@ def build_server(cfg: Optional[Config] = None):
846
888
  role=role, limit=limit, offset=offset)
847
889
  return _iso(out, "created_at")
848
890
 
849
- @mcp.tool()
891
+ @tool()
850
892
  def memory_admin_create_user(name: str = "", description: str = "",
851
893
  role: str = "user",
852
894
  can_create_namespace: bool = False,
@@ -872,7 +914,7 @@ def build_server(cfg: Optional[Config] = None):
872
914
  department=department, position=position)
873
915
  return {"id": uid}
874
916
 
875
- @mcp.tool()
917
+ @tool()
876
918
  def memory_admin_edit_user(user_id: str,
877
919
  name: Optional[str] = None,
878
920
  description: Optional[str] = None,
@@ -891,7 +933,7 @@ def build_server(cfg: Optional[Config] = None):
891
933
  conn, _principal(conn, _token(ctx)), user_id=user_id,
892
934
  **{k: v for k, v in fields.items() if v is not None})
893
935
 
894
- @mcp.tool()
936
+ @tool()
895
937
  def memory_admin_set_can_create_namespace(user_id: str, allowed: bool,
896
938
  ctx: Context = None) -> dict:
897
939
  """Grant or withdraw a user's right to create namespaces. Without it
@@ -902,7 +944,7 @@ def build_server(cfg: Optional[Config] = None):
902
944
  conn, _principal(conn, _token(ctx)),
903
945
  user_id=user_id, allowed=allowed)
904
946
 
905
- @mcp.tool()
947
+ @tool()
906
948
  def memory_admin_set_role(user_id: str, role: str,
907
949
  ctx: Context = None) -> dict:
908
950
  """Set a user's service role (user | user_manager | superadmin).
@@ -912,7 +954,7 @@ def build_server(cfg: Optional[Config] = None):
912
954
  return admin.set_role(conn, _principal(conn, _token(ctx)),
913
955
  user_id=user_id, role=role)
914
956
 
915
- @mcp.tool()
957
+ @tool()
916
958
  def memory_admin_list_namespaces(owner_user_id: Optional[str] = None,
917
959
  limit: int = 50, offset: int = 0,
918
960
  ctx: Context = None) -> List[dict]:
@@ -925,7 +967,7 @@ def build_server(cfg: Optional[Config] = None):
925
967
  owner_user_id=owner_user_id, limit=limit, offset=offset)
926
968
  return _iso(out, "created_at")
927
969
 
928
- @mcp.tool()
970
+ @tool()
929
971
  def memory_admin_create_namespace(name: str, owner_user_id: str,
930
972
  description: str = "",
931
973
  instruction: str = "",
@@ -940,7 +982,7 @@ def build_server(cfg: Optional[Config] = None):
940
982
  description=description, instruction=instruction)
941
983
  return {"id": nsid}
942
984
 
943
- @mcp.tool()
985
+ @tool()
944
986
  def memory_admin_edit_namespace(space_id: str,
945
987
  description: Optional[str] = None,
946
988
  instruction: Optional[str] = None,
@@ -959,7 +1001,7 @@ def build_server(cfg: Optional[Config] = None):
959
1001
  namespace_id=space_id, description=description,
960
1002
  instruction=instruction, name=name)
961
1003
 
962
- @mcp.tool()
1004
+ @tool()
963
1005
  def memory_admin_count_orphans(ctx: Context = None) -> dict:
964
1006
  """How many memories are stranded in the pre-identity namespace.
965
1007
  `single` mode stores everything under one nameless namespace; after a
@@ -969,7 +1011,7 @@ def build_server(cfg: Optional[Config] = None):
969
1011
  with pool.connection() as conn:
970
1012
  return admin.count_orphans(conn, _principal(conn, _token(ctx)))
971
1013
 
972
- @mcp.tool()
1014
+ @tool()
973
1015
  def memory_admin_adopt_orphans(space_id: str,
974
1016
  ctx: Context = None) -> dict:
975
1017
  """Move every stranded `single`-mode memory into a real namespace.
@@ -981,7 +1023,7 @@ def build_server(cfg: Optional[Config] = None):
981
1023
  conn, _principal(conn, _token(ctx)),
982
1024
  namespace_id=space_id, vectors=backend)
983
1025
 
984
- @mcp.tool()
1026
+ @tool()
985
1027
  def memory_admin_add_member(space_id: str, user_id: str,
986
1028
  permission: str = "read",
987
1029
  ctx: Context = None) -> dict:
@@ -994,7 +1036,7 @@ def build_server(cfg: Optional[Config] = None):
994
1036
  namespace_id=space_id, user_id=user_id,
995
1037
  permission=permission)
996
1038
 
997
- @mcp.tool()
1039
+ @tool()
998
1040
  def memory_admin_remove_member(space_id: str, user_id: str,
999
1041
  ctx: Context = None) -> dict:
1000
1042
  """Take a shared namespace away again. `removed: false` means they
@@ -1009,7 +1051,7 @@ def build_server(cfg: Optional[Config] = None):
1009
1051
  return admin.remove_member(conn, _principal(conn, _token(ctx)),
1010
1052
  namespace_id=space_id, user_id=user_id)
1011
1053
 
1012
- @mcp.tool()
1054
+ @tool()
1013
1055
  def memory_admin_transfer_namespace(
1014
1056
  space_id: str, new_owner_user_id: str,
1015
1057
  keep_previous_owner: Optional[str] = "admin",
@@ -1028,7 +1070,7 @@ def build_server(cfg: Optional[Config] = None):
1028
1070
  new_owner_user_id=new_owner_user_id,
1029
1071
  keep_previous_owner=keep_previous_owner)
1030
1072
 
1031
- @mcp.tool()
1073
+ @tool()
1032
1074
  def memory_admin_list_requests(space_id: str,
1033
1075
  ctx: Context = None) -> List[dict]:
1034
1076
  """Pending requests to join a namespace you administer — who asked,
@@ -1038,7 +1080,7 @@ def build_server(cfg: Optional[Config] = None):
1038
1080
  namespace_id=space_id)
1039
1081
  return _iso(out, "created_at")
1040
1082
 
1041
- @mcp.tool()
1083
+ @tool()
1042
1084
  def memory_admin_decide_access(request_id: str, approve: bool = True,
1043
1085
  expect_permission: Optional[str] = None,
1044
1086
  ctx: Context = None) -> dict:
@@ -1056,7 +1098,7 @@ def build_server(cfg: Optional[Config] = None):
1056
1098
  expect_permission=expect_permission)
1057
1099
  return {"request_id": request_id, "approved": approve}
1058
1100
 
1059
- @mcp.tool()
1101
+ @tool()
1060
1102
  def memory_admin_set_disabled(user_id: str, disabled: bool = True,
1061
1103
  ctx: Context = None) -> dict:
1062
1104
  """Switch an account off, or back on.
@@ -1070,7 +1112,7 @@ def build_server(cfg: Optional[Config] = None):
1070
1112
  return admin.set_disabled(conn, _principal(conn, _token(ctx)),
1071
1113
  user_id=user_id, disabled=disabled)
1072
1114
 
1073
- @mcp.tool()
1115
+ @tool()
1074
1116
  def memory_admin_list_members(space_id: str,
1075
1117
  ctx: Context = None) -> List[dict]:
1076
1118
  """Who can reach a namespace — the owner first, then everyone shared
@@ -1080,7 +1122,7 @@ def build_server(cfg: Optional[Config] = None):
1080
1122
  namespace_id=space_id)
1081
1123
  return _iso(out, "created_at")
1082
1124
 
1083
- @mcp.tool()
1125
+ @tool()
1084
1126
  def memory_admin_issue_token(user_id: str, permission: str = "write",
1085
1127
  space_id: Optional[str] = None,
1086
1128
  label: str = "",
@@ -1112,7 +1154,7 @@ def build_server(cfg: Optional[Config] = None):
1112
1154
  out["warning"] = minted["warning"]
1113
1155
  return out
1114
1156
 
1115
- @mcp.tool()
1157
+ @tool()
1116
1158
  def memory_admin_create_enrollment(
1117
1159
  user_id: str, permission: str = "write",
1118
1160
  space_id: Optional[str] = None, label: str = "",
@@ -1146,7 +1188,7 @@ def build_server(cfg: Optional[Config] = None):
1146
1188
  out["expires_at"] = out["expires_at"].isoformat()
1147
1189
  return admin.deliver_key(out, cfg.token_sink)
1148
1190
 
1149
- @mcp.tool()
1191
+ @tool()
1150
1192
  def memory_admin_list_enrollments(user_id: Optional[str] = None,
1151
1193
  ctx: Context = None) -> List[dict]:
1152
1194
  """Enrollment keys and what became of them — `state` is pending,
@@ -1158,7 +1200,7 @@ def build_server(cfg: Optional[Config] = None):
1158
1200
  user_id=user_id)
1159
1201
  return _iso(out, "created_at", "expires_at", "used_at", "revoked_at")
1160
1202
 
1161
- @mcp.tool()
1203
+ @tool()
1162
1204
  def memory_admin_revoke_enrollment(enrollment_id: str,
1163
1205
  ctx: Context = None) -> dict:
1164
1206
  """Kill an unredeemed enrollment key. False means it was already
@@ -1169,7 +1211,7 @@ def build_server(cfg: Optional[Config] = None):
1169
1211
  conn, _principal(conn, _token(ctx)),
1170
1212
  enrollment_id=enrollment_id)}
1171
1213
 
1172
- @mcp.tool()
1214
+ @tool()
1173
1215
  def memory_admin_list_tokens(user_id: str,
1174
1216
  ctx: Context = None) -> List[dict]:
1175
1217
  """A user's tokens — metadata only, never the secret."""
@@ -1178,7 +1220,7 @@ def build_server(cfg: Optional[Config] = None):
1178
1220
  user_id=user_id)
1179
1221
  return _iso(out, *_TOKEN_TIMES)
1180
1222
 
1181
- @mcp.tool()
1223
+ @tool()
1182
1224
  def memory_admin_revoke_token(token_id: str,
1183
1225
  ctx: Context = None) -> dict:
1184
1226
  """Kill any user's token immediately. False means it was already
@@ -0,0 +1,49 @@
1
+ """What a tree path is — in ONE place.
2
+
3
+ The shape was written down twice before, in the link parser and in whatever SQL
4
+ happened to cast a string to `ltree`, and the two disagreed: a hyphen was legal
5
+ to store and illegal to link, so half the links in a real corpus silently
6
+ vanished (fixed in 0.12.0). This module exists so a third definition does not
7
+ appear.
8
+
9
+ It also gives the check a place to happen BEFORE the value reaches Postgres. An
10
+ unvalidated prefix arrived at the database as `%s::ltree` and came back as
11
+ `psycopg.errors.SyntaxError: ltree syntax error at character 1` — a message
12
+ about our schema, not about their input, and one the caller can do nothing with.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from typing import Optional
19
+
20
+ # Labels of word characters (letters in ANY script, digits, underscore) or
21
+ # hyphens, joined by dots.
22
+ #
23
+ # Two things had to be widened here, each because a real path was being called
24
+ # invalid. The hyphen: Postgres has allowed it in ltree labels since 13, the
25
+ # minimum this project supports. Non-ASCII letters: `ops.тариф` stores and reads
26
+ # perfectly well, and an existing test says so — a check that rejected it would
27
+ # have made a working path unwritable, which is worse than the error it prevents.
28
+ #
29
+ # The parser for `[[links]]` uses the SAME alphabet on purpose. When it was
30
+ # narrower, every hyphenated link was dropped as prose; a narrower rule here
31
+ # would recreate that, only for Cyrillic.
32
+ PATH_RE = re.compile(r"^[\w-]+(\.[\w-]+)*$", re.UNICODE)
33
+
34
+
35
+ def is_path(value: Optional[str]) -> bool:
36
+ return bool(value) and bool(PATH_RE.match(value))
37
+
38
+
39
+ def check_path(value: Optional[str], field: str = "path") -> Optional[str]:
40
+ """Return `value` unchanged, or raise ValueError naming the field and the
41
+ shape. Empty/None passes — "no path given" is not a malformed one."""
42
+ if value is None or value == "":
43
+ return value
44
+ if not PATH_RE.match(value):
45
+ raise ValueError(
46
+ f"`{field}` is not a tree path: {value!r}. A path is labels of "
47
+ f"letters (any script), digits, `_` or `-` joined by dots, like "
48
+ f"`ops.memory.onboarding` — no spaces, slashes or leading dots.")
49
+ return value
@@ -35,6 +35,7 @@ from . import identity
35
35
  from .config import Config
36
36
  from .delimiters import write_warnings
37
37
  from .lines import parse_line_spec
38
+ from .paths import check_path
38
39
  from .diffing import DiffConflict, apply_diff, byte_len, content_hash, make_diff
39
40
  from .embeddings import Embedder, get_embedder
40
41
  from .links import parse_links, rewrite_targets
@@ -625,6 +626,7 @@ class Store:
625
626
  # stored one way and filtered another.
626
627
  tags = normalize_tags(tags)
627
628
  valid_at = _as_date(valid_at)
629
+ check_path(path, "path")
628
630
  if id is None and at is None:
629
631
  self._check_path_free(ns, path, if_moved)
630
632
  m = self._create(ns, author, body, path, tags, source, reason,
@@ -139,8 +139,12 @@ def build_filters(ns, tags: Optional[Sequence[str]], path_prefix: Optional[str],
139
139
  # input would filter nothing or everything depending on a mode flag the
140
140
  # caller may not have set. Neither is an answer; "no tags requested" is.
141
141
  if path_prefix:
142
+ # Checked here, not by Postgres: an unvalidated prefix came back as
143
+ # `ltree syntax error at character 1`, which tells the caller nothing
144
+ # about what they passed.
145
+ from ..paths import check_path
142
146
  sql.append("path <@ %s::ltree") # subtree of the prefix
143
- params.append(path_prefix)
147
+ params.append(check_path(path_prefix, "path_prefix"))
144
148
  return " AND ".join(sql), params
145
149
 
146
150
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memgres
3
- Version: 0.12.0
3
+ Version: 0.12.1
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
@@ -19,6 +19,7 @@ memgres/info.py
19
19
  memgres/lines.py
20
20
  memgres/links.py
21
21
  memgres/mcp_server.py
22
+ memgres/paths.py
22
23
  memgres/periodic.py
23
24
  memgres/reembed.py
24
25
  memgres/relink.py
@@ -79,6 +80,7 @@ tests/test_limits.py
79
80
  tests/test_links.py
80
81
  tests/test_list.py
81
82
  tests/test_mcp_admin_tools.py
83
+ tests/test_mcp_error_messages.py
82
84
  tests/test_mcp_http_transport.py
83
85
  tests/test_mcp_instructions.py
84
86
  tests/test_mcp_recall_schema.py
@@ -0,0 +1,207 @@
1
+ """A refusal the caller cannot read is a refusal that gets repeated.
2
+
3
+ The server was already answering carefully — "this deployment requires `source`
4
+ … as an ADDRESS", "you can reach 2 namespaces, name the one you mean" — and the
5
+ caller saw `Error executing tool memory_write` with nothing else. The MCP SDK
6
+ masks every exception except its own `ToolError`, so all that text went to the
7
+ server log and nowhere else. In production an agent hit the same wall five times
8
+ in a row with the same edit, then moved on to reads and hit it again.
9
+
10
+ So the tools now re-raise domain refusals as `ToolError` — and only those. A
11
+ psycopg error or a genuine bug stays masked, because those messages describe our
12
+ schema rather than the caller's mistake.
13
+
14
+ The second half is the one that produced no sentence at all: a `path_prefix`
15
+ that is not a path reached Postgres as `%s::ltree` and came back as `ltree
16
+ syntax error at character 1`. Validated up front now, in the one place that says
17
+ what a path is.
18
+ """
19
+
20
+ import json
21
+ import os
22
+ import socket
23
+ import subprocess
24
+ import sys
25
+ import time
26
+ import urllib.error
27
+ import urllib.request
28
+ from pathlib import Path
29
+
30
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
31
+
32
+ import pytest # noqa: E402
33
+
34
+ psycopg = pytest.importorskip("psycopg")
35
+
36
+ from memgres.paths import check_path, is_path # noqa: E402
37
+
38
+ DSN = os.environ.get("MEMGRES_TEST_DSN",
39
+ "postgresql://memgres:memgres@localhost:55432/memgres")
40
+
41
+
42
+ # ─── what a path is, in one place ────────────────────────────────────────────
43
+ def test_a_path_is_labels_joined_by_dots():
44
+ assert is_path("ops.memory.onboarding")
45
+ assert is_path("infra.servers.video-production") # hyphen: ltree allows it
46
+ assert is_path("ops.тариф") # any script: so does ltree
47
+ assert check_path("ops.x", "path") == "ops.x"
48
+
49
+
50
+ def test_what_is_not_a_path_is_refused_with_a_sentence():
51
+ for bad in ("not a path", "ops/x", ".leading", "ops..x", "конец "):
52
+ with pytest.raises(ValueError) as e:
53
+ check_path(bad, "path_prefix")
54
+ msg = str(e.value)
55
+ assert "path_prefix" in msg # which argument
56
+ assert repr(bad) in msg # what they passed
57
+ assert "joined by dots" in msg # what it should look like
58
+
59
+
60
+ def test_no_path_is_not_a_bad_path():
61
+ """`None`/empty means "not filtering", and refusing it would break every
62
+ unfiltered list."""
63
+ assert check_path(None, "path_prefix") is None
64
+ assert check_path("", "path_prefix") == ""
65
+
66
+
67
+ # ─── the wrapper: which failures may speak ───────────────────────────────────
68
+ def test_domain_refusals_become_the_error_type_the_client_sees():
69
+ from memgres.mcp_server import ToolError, _speaking
70
+
71
+ def refuses():
72
+ raise ValueError("say which namespace you mean")
73
+
74
+ with pytest.raises(ToolError) as e:
75
+ _speaking(refuses)()
76
+ assert "say which namespace you mean" in str(e.value)
77
+
78
+
79
+ def test_internal_failures_stay_masked():
80
+ """A psycopg error or a bug describes our schema, not their mistake. It must
81
+ NOT be handed to the caller — it stays whatever it was, and the SDK masks it."""
82
+ from memgres.mcp_server import ToolError, _speaking
83
+
84
+ def breaks():
85
+ raise RuntimeError("relation \"memory\" does not exist")
86
+
87
+ with pytest.raises(RuntimeError):
88
+ _speaking(breaks)()
89
+ try:
90
+ _speaking(breaks)()
91
+ except Exception as e: # noqa: BLE001 - asserting the type
92
+ assert not isinstance(e, ToolError)
93
+
94
+
95
+ def test_a_refusal_is_not_wrapped_twice():
96
+ from memgres.mcp_server import ToolError, _speaking
97
+
98
+ def refuses():
99
+ raise ToolError("already speakable")
100
+
101
+ with pytest.raises(ToolError) as e:
102
+ _speaking(refuses)()
103
+ assert str(e.value) == "already speakable"
104
+
105
+
106
+ # ─── on the wire, which is the only proof that matters ───────────────────────
107
+ def _reachable() -> bool:
108
+ try:
109
+ psycopg.connect(DSN, connect_timeout=2).close()
110
+ return True
111
+ except Exception:
112
+ return False
113
+
114
+
115
+ def _free_port() -> int:
116
+ s = socket.socket()
117
+ s.bind(("127.0.0.1", 0))
118
+ port = s.getsockname()[1]
119
+ s.close()
120
+ return port
121
+
122
+
123
+ def _rpc(url: str, payload: dict, session: str = "") -> tuple:
124
+ body = json.dumps(payload).encode()
125
+ req = urllib.request.Request(url, data=body, method="POST")
126
+ req.add_header("Content-Type", "application/json")
127
+ req.add_header("Accept", "application/json, text/event-stream")
128
+ if session:
129
+ req.add_header("Mcp-Session-Id", session)
130
+ with urllib.request.urlopen(req, timeout=15) as r:
131
+ return r.read().decode(), r.headers.get("Mcp-Session-Id", "")
132
+
133
+
134
+ @pytest.mark.skipif(not _reachable(), reason="no test Postgres")
135
+ def test_every_registered_tool_is_wrapped():
136
+ """The version-independent half of the proof.
137
+
138
+ Whether a bare `ValueError` reaches the client is up to the SDK, and it
139
+ CHANGED under us: 2.0.0 put the message into its own error text, 2.1.1 raises
140
+ `UnexpectedToolError("Error executing tool <name>")` and drops it. So the
141
+ wire test below passes on one version whether or not the fix is present. This
142
+ one asserts the fix itself — that what got registered is the wrapper — and
143
+ fails on every SDK generation if the wrapping is removed."""
144
+ os.environ.setdefault("MEMGRES_DATABASE_URL", DSN)
145
+ os.environ["MEMGRES_KEY_MODE"] = "single"
146
+ os.environ["MEMGRES_EMBED_PROVIDER"] = "none"
147
+ from memgres.mcp_server import ToolError, build_server
148
+
149
+ srv = build_server()
150
+ mgr = getattr(srv, "_tool_manager", None)
151
+ tools = getattr(mgr, "_tools", None)
152
+ assert tools, "could not reach the registered tools"
153
+ with pytest.raises(ToolError) as e:
154
+ tools["memory_list"].fn(path_prefix="not a path")
155
+ assert "not a tree path" in str(e.value)
156
+
157
+
158
+ @pytest.mark.skipif(not _reachable(), reason="no test Postgres")
159
+ def test_the_reason_reaches_the_client_over_http():
160
+ """In-process assertions cannot prove this: the masking happens in the SDK's
161
+ tool layer, on the way out. So this drives a real server over real HTTP and
162
+ reads what a caller would actually be told."""
163
+ port = _free_port()
164
+ env = {**os.environ,
165
+ "MEMGRES_DATABASE_URL": DSN,
166
+ "MEMGRES_KEY_MODE": "single",
167
+ "MEMGRES_EMBED_PROVIDER": "none",
168
+ "MEMGRES_MCP_TRANSPORT": "http",
169
+ "MEMGRES_MCP_HOST": "127.0.0.1",
170
+ "MEMGRES_MCP_PORT": str(port)}
171
+ root = str(Path(__file__).resolve().parent.parent)
172
+ env["PYTHONPATH"] = root + os.pathsep + env.get("PYTHONPATH", "")
173
+ proc = subprocess.Popen([sys.executable, "-m", "memgres.mcp_server"],
174
+ env=env, stdout=subprocess.PIPE,
175
+ stderr=subprocess.STDOUT, text=True)
176
+ url = f"http://127.0.0.1:{port}/mcp"
177
+ try:
178
+ deadline = time.time() + 30
179
+ session = ""
180
+ while time.time() < deadline:
181
+ if proc.poll() is not None:
182
+ out = proc.stdout.read() if proc.stdout else ""
183
+ pytest.fail(f"server exited early ({proc.returncode}):\n{out}")
184
+ try:
185
+ _, session = _rpc(url, {
186
+ "jsonrpc": "2.0", "id": 1, "method": "initialize",
187
+ "params": {"protocolVersion": "2025-06-18", "capabilities": {},
188
+ "clientInfo": {"name": "t", "version": "0"}}})
189
+ break
190
+ except (urllib.error.URLError, ConnectionError, OSError):
191
+ time.sleep(0.5)
192
+ assert session, "server never completed a handshake"
193
+ _rpc(url, {"jsonrpc": "2.0", "method": "notifications/initialized"}, session)
194
+
195
+ text, _ = _rpc(url, {
196
+ "jsonrpc": "2.0", "id": 2, "method": "tools/call",
197
+ "params": {"name": "memory_list",
198
+ "arguments": {"path_prefix": "not a path"}}}, session)
199
+ # The point of the fix, in one assertion: the sentence, not the mask.
200
+ assert "not a tree path" in text, text[:400]
201
+ assert "joined by dots" in text
202
+ finally:
203
+ proc.terminate()
204
+ try:
205
+ proc.wait(timeout=10)
206
+ except subprocess.TimeoutExpired:
207
+ proc.kill()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes