memgraph-context-graph 0.12.2__tar.gz → 0.12.3__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 (104) hide show
  1. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/CONTEXT.md +8 -0
  2. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/PKG-INFO +1 -1
  3. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/docs/runtime-setup.md +5 -0
  4. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/docs/sdk-memory.md +4 -0
  5. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/pyproject.toml +1 -1
  6. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/cli.py +14 -0
  7. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/connectors.py +1 -1
  8. memgraph_context_graph-0.12.3/src/memgraph_context_graph/graph_version.py +327 -0
  9. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/hooks/runner.py +35 -4
  10. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/mcp_server.py +18 -3
  11. memgraph_context_graph-0.12.3/src/memgraph_context_graph/refused_events.py +317 -0
  12. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/sdk/__init__.py +32 -1
  13. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/session.py +16 -3
  14. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/setup/cli.py +4 -1
  15. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/setup/tool.py +2 -2
  16. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/setup/update.py +24 -1
  17. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/tools.py +9 -0
  18. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/tests/conftest.py +11 -0
  19. memgraph_context_graph-0.12.3/tests/test_graph_version.py +249 -0
  20. memgraph_context_graph-0.12.3/tests/test_refused_events.py +335 -0
  21. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/tests/test_session.py +9 -0
  22. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/tests/test_setup.py +2 -0
  23. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/.gitignore +0 -0
  24. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/LICENSE +0 -0
  25. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/README.md +0 -0
  26. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/docs/adr/0001-runtime-adapter-terminology.md +0 -0
  27. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/docs/adr/0002-config-file-only-hook-resolution.md +0 -0
  28. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/docs/adr/0003-config-path-override.md +0 -0
  29. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/docs/adr/0004-user-id-in-session-start-event.md +0 -0
  30. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/docs/adr/0005-config-file-covers-llm-key-and-subprocess-env.md +0 -0
  31. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/docs/adr/0006-memory-tool-writes-explicit-memory.md +0 -0
  32. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/docs/adr/0007-spawned-processes-read-the-config-file-themselves.md +0 -0
  33. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/docs/command-hooks.md +0 -0
  34. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/docs/configuration.md +0 -0
  35. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/docs/extending.md +0 -0
  36. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/docs/memory.md +0 -0
  37. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/docs/research/2026-10-codex-memory.md +0 -0
  38. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/examples/sdk_claude_agent.py +0 -0
  39. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/examples/sdk_openai_agents.py +0 -0
  40. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/__init__.py +0 -0
  41. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/adapters/__init__.py +0 -0
  42. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/adapters/_identity.py +0 -0
  43. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/adapters/_opencode_plugin.js +0 -0
  44. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/adapters/_spec.py +0 -0
  45. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/adapters/agent_mcp.py +0 -0
  46. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/adapters/agent_memory.py +0 -0
  47. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/adapters/antigravity_cli.py +0 -0
  48. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/adapters/claude_code.py +0 -0
  49. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/adapters/claude_code_memory.py +0 -0
  50. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/adapters/codex.py +0 -0
  51. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/adapters/codex_memory.py +0 -0
  52. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/adapters/copilot_cli.py +0 -0
  53. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/adapters/cursor.py +0 -0
  54. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/adapters/grok.py +0 -0
  55. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/adapters/opencode.py +0 -0
  56. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/capture_log.py +0 -0
  57. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/capture_policy.py +0 -0
  58. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/config_schema.py +0 -0
  59. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/events.py +0 -0
  60. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/forget.py +0 -0
  61. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/hooks/__init__.py +0 -0
  62. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/hooks/cli.py +0 -0
  63. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/hooks/runtime_plugin.py +0 -0
  64. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/link.py +0 -0
  65. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/memory_backend.py +0 -0
  66. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/protocols.py +0 -0
  67. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/sdk/claude.py +0 -0
  68. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/sdk/openai.py +0 -0
  69. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/setup/__init__.py +0 -0
  70. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/setup/agents.py +0 -0
  71. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/setup/engine.py +0 -0
  72. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/setup/memgraph.py +0 -0
  73. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/setup/prompts.py +0 -0
  74. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/setup/uninstall.py +0 -0
  75. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/spaces.py +0 -0
  76. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/src/memgraph_context_graph/terminal.py +0 -0
  77. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/tests/__init__.py +0 -0
  78. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/tests/fake_agent_cli.py +0 -0
  79. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/tests/test_agent_memory_wiring.py +0 -0
  80. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/tests/test_antigravity_cli_adapter.py +0 -0
  81. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/tests/test_capture_log.py +0 -0
  82. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/tests/test_capture_policy.py +0 -0
  83. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/tests/test_claude_code_adapter.py +0 -0
  84. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/tests/test_claude_code_memory.py +0 -0
  85. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/tests/test_codex_adapter.py +0 -0
  86. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/tests/test_codex_memory.py +0 -0
  87. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/tests/test_config_cli.py +0 -0
  88. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/tests/test_connectors.py +0 -0
  89. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/tests/test_context_before_tool.py +0 -0
  90. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/tests/test_copilot_cli_adapter.py +0 -0
  91. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/tests/test_cursor_adapter.py +0 -0
  92. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/tests/test_dev_scripts.py +0 -0
  93. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/tests/test_events.py +0 -0
  94. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/tests/test_grok_adapter.py +0 -0
  95. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/tests/test_hook_cli.py +0 -0
  96. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/tests/test_identity.py +0 -0
  97. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/tests/test_link.py +0 -0
  98. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/tests/test_opencode_adapter.py +0 -0
  99. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/tests/test_plugin_hooks.py +0 -0
  100. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/tests/test_runtime_plugin.py +0 -0
  101. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/tests/test_setup_docker.py +0 -0
  102. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/tests/test_spaces.py +0 -0
  103. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/tests/test_terminal.py +0 -0
  104. {memgraph_context_graph-0.12.2 → memgraph_context_graph-0.12.3}/tests/test_tools.py +0 -0
@@ -78,6 +78,14 @@ _Avoid_: MCP tool (the protocol it is served over, not the thing), Graph Connect
78
78
  `ConnectorSpec` (`connectors.py`): a graph component's name, its graph class, how to build its **Graph Connector** from **Hook Configuration**, and an optional `doctor` check. Published via the `memgraph_context_graph.connectors` entry-point group; the hook runner, schema setup and `doctor` find connectors only through it. The hub keeps just what it needs before a component is installed: each installable connector's requirement (`INSTALLABLE`) and the default set.
79
79
  _Avoid_: Component (the package), Graph Connector (the object a registration builds)
80
80
 
81
+ **Graph Epoch**:
82
+ The version of one component's shape in the graph, an integer that only a migration raises; every graph is at epoch 1 until one does. The graph states per component the oldest epoch that may write and read it, `(:ContextGraphSchema {component, epoch, min_writer_epoch, min_reader_epoch})`, and every client checks that before writing or reading (`graph_version.py`): a client older than the graph stops, never corrupts. No node means no constraint. Clients record themselves as `(:ContextGraphClient)`, so a migration can see who still writes an old shape. Not a package version: a release that leaves the graph's shape alone keeps the epoch.
83
+ _Avoid_: schema version, package version, migration (raises an epoch; isn't one)
84
+
85
+ **Refused-Event Spool**:
86
+ Where a connector's events go while the graph refuses it (**Graph Epoch**): a local, 0600, size-capped JSONL file beside the capture-failure log (`refused_events.py`), holding each event as the capture policy left it, original timestamp included. `update` replays it into the connectors the graph allows again. Its line format is versioned and read by later releases, so a change keeps a reader for every earlier format.
87
+ _Avoid_: queue, buffer, outbox (nothing sends it on its own; `update` replays it)
88
+
81
89
  ## Relationships
82
90
 
83
91
  - **Memgraph Context Graph** belongs to broader **Context Graph** family.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: memgraph-context-graph
3
- Version: 0.12.2
3
+ Version: 0.12.3
4
4
  Summary: Connect agent SDKs to context-graph components (actions-graph, skills-graph, etc.)
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -76,6 +76,11 @@ and constraints. It then runs `doctor`'s checks. Name runtimes to refresh only t
76
76
  and the graph's data are untouched, and running it again changes nothing.
77
77
  Running agent sessions keep the hooks they loaded: restart them. Outside the uv tool (a virtualenv, say) the
78
78
  package step only warns, and you upgrade it yourself first.
79
+ Once a newer Context Graph has reshaped your graph, an older install stops writing (and, if needed, reading) the
80
+ parts it no longer knows instead of corrupting them: the session-start context, the capture-failure log and
81
+ `doctor` say so, and `update` brings it level. What the agents did meanwhile isn't lost: it is kept, redacted
82
+ like any capture, in `refused-events.jsonl` beside the capture-failure log (readable only by you, at most 64 MB),
83
+ and `update` adds it to the graph with its original times.
79
84
 
80
85
  **Identity and connection.** setup writes `~/.config/context-graph/config.toml`; hooks read their configuration from that file at runtime (see [Configuration](configuration.md) — env vars are **not** read at hook time). To change your identity later:
81
86
 
@@ -31,6 +31,10 @@ from memgraph_context_graph.sdk import ContextGraphMemory
31
31
  memory = ContextGraphMemory(user_id="alice")
32
32
  ```
33
33
 
34
+ A long-running app keeps working across a Context Graph upgrade elsewhere. About every ten minutes it checks whether
35
+ a newer Context Graph has reshaped the graph. If it has, the app stops writing the parts it no longer knows, keeps
36
+ those events on the machine, and `memgraph-context-graph update` adds them once this install is upgraded.
37
+
34
38
  ## Claude Agent SDK
35
39
 
36
40
  The SDK's hook callbacks get Claude Code's own hook payloads, so the `claude-code` rules translate them, under `source_sdk = "claude-agent-sdk"`. Python callbacks get no `SessionStart` or `SessionEnd`, which changes two things:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "memgraph-context-graph"
3
- version = "0.12.2"
3
+ version = "0.12.3"
4
4
  description = "Connect agent SDKs to context-graph components (actions-graph, skills-graph, etc.)"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -405,6 +405,7 @@ def doctor_checks(runtimes: Sequence[str], connectors: Sequence[str]) -> list[_C
405
405
  # produce one, the config check already says why, and the rest would only repeat it.
406
406
  if _edition_resolves():
407
407
  checks.append(_check_memgraph())
408
+ checks.append(_check_graph_version(connectors))
408
409
  for connector in connectors:
409
410
  checks.append(_check_connector(connector))
410
411
  if any(normalize_connector_name(connector) == "sessions-graph" for connector in connectors):
@@ -503,6 +504,19 @@ def _check_memgraph() -> _CheckResult:
503
504
  _close(db)
504
505
 
505
506
 
507
+ def _check_graph_version(connectors: Sequence[str]) -> _CheckResult:
508
+ """Whether a newer Context Graph has reshaped the graph past what this install can write or read."""
509
+ from memgraph_context_graph import graph_version
510
+
511
+ rules = graph_version.check_configured(fresh=True)
512
+ components = [graph_version.HUB, *(normalize_connector_name(connector) for connector in connectors)]
513
+ refused = [rules.refuse_write(c) for c in components if not rules.may_write(c)]
514
+ refused += [rules.refuse_read(c) for c in components if not rules.may_read(c)]
515
+ if refused:
516
+ return {"name": "graph version", "ok": False, "detail": "; ".join(dict.fromkeys(map(str, refused)))}
517
+ return {"name": "graph version", "ok": True, "detail": "this install can write and read the graph"}
518
+
519
+
506
520
  def _close(db: Any) -> None:
507
521
  if db is not None:
508
522
  db.close()
@@ -60,7 +60,7 @@ class ConnectorSpec:
60
60
  INSTALLABLE: dict[str, str] = {
61
61
  "skills-graph": "skills-graph[memgraph-context-graph]>=0.4.0",
62
62
  "actions-graph": "actions-graph[memgraph-context-graph]>=0.7.1",
63
- "sessions-graph": "sessions-graph[memgraph-context-graph]>=0.12.2",
63
+ "sessions-graph": "sessions-graph[memgraph-context-graph]>=0.12.3",
64
64
  "resources-graph": "resources-graph[memgraph-context-graph]>=0.3.0",
65
65
  }
66
66
 
@@ -0,0 +1,327 @@
1
+ """Whether this client may write or read a graph that a newer Context Graph may have reshaped.
2
+
3
+ A later release may migrate a component's part of the graph to a new shape.
4
+ Clients installed before it keep running: on other machines, against a shared
5
+ Enterprise server, or as a harness's long-lived MCP server. They would write
6
+ the old shape into the new graph, or read it and find nothing. So the graph
7
+ says, per component, the oldest shape a client must know to write or read it:
8
+
9
+ (:ContextGraphSchema {component, epoch, min_writer_epoch, min_reader_epoch})
10
+
11
+ and every client checks that before it writes or reads. This rule is the one
12
+ part of graph versioning frozen into every installed client, so it stays this
13
+ small. The migrations, their ledger and ``epoch`` itself belong to the release
14
+ that writes these nodes. A component with no node has no constraint, so every
15
+ graph written before this module allows everything.
16
+
17
+ ``memgraph-context-graph`` is the component of the shared Session Node, which
18
+ every connector writes through, so a constraint on it holds for all of them.
19
+
20
+ A client also records itself on each check as
21
+ ``(:ContextGraphClient {client_id, version, baseline_epoch, epochs, last_seen})``.
22
+ Before a migration removes an old shape, it can then see whether clients that
23
+ still write that shape are about.
24
+
25
+ Checks are cached per graph for :data:`CHECK_INTERVAL` in a state file beside
26
+ the capture-failure log, so a hook pays for one at most every few minutes. A
27
+ check that can't reach the graph allows everything and isn't cached: the
28
+ connectors then fail and log on their own.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import contextlib
34
+ import json
35
+ import os
36
+ import uuid
37
+ from dataclasses import dataclass, field
38
+ from datetime import datetime, timedelta, timezone
39
+ from typing import TYPE_CHECKING, Any
40
+
41
+ from memgraph_context_graph import capture_log
42
+
43
+ if TYPE_CHECKING:
44
+ from collections.abc import Callable, Mapping
45
+ from contextlib import AbstractContextManager
46
+ from pathlib import Path
47
+
48
+ #: The component that owns the Session Node; a constraint on it covers every connector.
49
+ HUB = "memgraph-context-graph"
50
+ #: The shape every component is at in a graph no migration has touched.
51
+ BASELINE_EPOCH = 1
52
+ #: The epoch of each component's shape this release writes and reads, where it differs from the baseline.
53
+ EPOCHS: dict[str, int] = {}
54
+ #: How long a check holds before the graph is asked again.
55
+ CHECK_INTERVAL = timedelta(minutes=10)
56
+
57
+ UPDATE_COMMAND = "memgraph-context-graph update"
58
+
59
+
60
+ class GraphTooNewError(Exception):
61
+ """The graph's shape for a component is newer than this client can safely write or read."""
62
+
63
+
64
+ def known_epoch(component: str) -> int:
65
+ """The epoch of *component*'s shape this release writes and reads."""
66
+ return EPOCHS.get(_normalize(component), BASELINE_EPOCH)
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class Rules:
71
+ """What the graph asks of its clients: per component, the oldest epoch that may write and read it."""
72
+
73
+ min_writer: Mapping[str, int] = field(default_factory=dict)
74
+ min_reader: Mapping[str, int] = field(default_factory=dict)
75
+
76
+ def may_write(self, component: str) -> bool:
77
+ """Whether this client may write *component*'s part of the graph."""
78
+ return not self._newer(self.min_writer, component)
79
+
80
+ def may_read(self, component: str) -> bool:
81
+ """Whether this client may read *component*'s part of the graph."""
82
+ return not self._newer(self.min_reader, component)
83
+
84
+ def refuse_write(self, component: str) -> GraphTooNewError:
85
+ """The error to report for a write :meth:`may_write` refuses."""
86
+ return GraphTooNewError(_refusal("write", self._newer(self.min_writer, component)))
87
+
88
+ def refuse_read(self, component: str) -> GraphTooNewError:
89
+ """The error to report for a read :meth:`may_read` refuses."""
90
+ return GraphTooNewError(_refusal("read", self._newer(self.min_reader, component)))
91
+
92
+ def notice(self) -> str | None:
93
+ """One line for the model's session-start context while anything is refused, else None."""
94
+ writes = sorted(c for c, epoch in self.min_writer.items() if epoch > known_epoch(c))
95
+ reads = sorted(c for c, epoch in self.min_reader.items() if epoch > known_epoch(c))
96
+ if not writes and not reads:
97
+ return None
98
+ paused = ", ".join(dict.fromkeys(writes + reads))
99
+ # Refused writes are spooled for replay (refused_events); refused reads have nothing to keep.
100
+ kept = " What this session does is kept on this machine and added after the update." if writes else ""
101
+ return (
102
+ f"Context Graph is paused for {paused}: a newer Context Graph upgraded this graph, and the "
103
+ f"version installed here can't safely use it.{kept} Ask the user to run `{UPDATE_COMMAND}`."
104
+ )
105
+
106
+ def _newer(self, required: Mapping[str, int], component: str) -> list[str]:
107
+ """The components among *component* and the hub whose required epoch is above what this client knows."""
108
+ return [c for c in dict.fromkeys((HUB, _normalize(component))) if required.get(c, 0) > known_epoch(c)]
109
+
110
+
111
+ def _refusal(action: str, components: list[str]) -> str:
112
+ return (
113
+ f"this graph needs a newer Context Graph to {action} {', '.join(components)} than the one installed "
114
+ f"here; run {UPDATE_COMMAND}"
115
+ )
116
+
117
+
118
+ def setup(db: Any) -> None:
119
+ """Create the constraints and indexes the check and the client record use; safe to repeat."""
120
+ db.query("CREATE CONSTRAINT ON (s:ContextGraphSchema) ASSERT s.component IS UNIQUE;")
121
+ db.query("CREATE INDEX ON :ContextGraphSchema;")
122
+ db.query("CREATE CONSTRAINT ON (c:ContextGraphClient) ASSERT c.client_id IS UNIQUE;")
123
+ db.query("CREATE INDEX ON :ContextGraphClient(client_id);")
124
+
125
+
126
+ def setup_at(memgraph_env: dict[str, str]) -> None:
127
+ """:func:`setup` on the graph a resolved Memgraph environment (``resolve_memgraph_env``) names."""
128
+ with _connect(memgraph_env) as db:
129
+ setup(db)
130
+
131
+
132
+ def check(
133
+ connect: Callable[[], AbstractContextManager[Any]],
134
+ *,
135
+ url: str,
136
+ database: str = "",
137
+ fresh: bool = False,
138
+ strict: bool = False,
139
+ now: datetime | None = None,
140
+ ) -> Rules:
141
+ """The graph's rules for this client, from the cache while it holds, else from the graph.
142
+
143
+ Args:
144
+ connect: Opens a client for the graph, called only when the cache doesn't hold.
145
+ url: The graph's Bolt URL; with *database*, what the cache keys on.
146
+ database: The Memgraph database, empty for the default one.
147
+ fresh: Ask the graph even while the cache holds.
148
+ strict: Raise when the graph can't be asked, instead of allowing everything.
149
+ now: The current time, for tests.
150
+
151
+ Returns:
152
+ The rules; no rules (everything allowed) when the graph can't be asked and not *strict*.
153
+ """
154
+ now = now or datetime.now(timezone.utc)
155
+ state = _load_state()
156
+ key = _graph_key(url, database)
157
+ cached = state["graphs"].get(key)
158
+ if not fresh and cached is not None and _holds(cached, now):
159
+ return _rules_from(cached.get("rules"))
160
+ try:
161
+ with connect() as db:
162
+ rules = read_rules(db)
163
+ # A client that can read but not write the graph (a shared space) still gets its rules.
164
+ with contextlib.suppress(Exception):
165
+ _record_client(db, state["client_id"], now)
166
+ except Exception:
167
+ if strict:
168
+ raise
169
+ return Rules()
170
+ state["graphs"][key] = {"checked_at": now.isoformat(), "rules": _rules_to(rules)}
171
+ _save_state(state)
172
+ return rules
173
+
174
+
175
+ def cached_rules(url: str, database: str = "") -> Rules:
176
+ """The rules this client last read from the graph, without asking it; none when it never has."""
177
+ cached = _load_state()["graphs"].get(_graph_key(url, database))
178
+ return _rules_from(cached.get("rules")) if cached is not None else Rules()
179
+
180
+
181
+ def check_configured(*, fresh: bool = False) -> Rules:
182
+ """:func:`check` for the graph the config file names; no rules when the config can't name one."""
183
+ from memgraph_context_graph.adapters._identity import resolve_memgraph_env
184
+
185
+ try:
186
+ env = resolve_memgraph_env()
187
+ except Exception:
188
+ return Rules()
189
+ return check_at(env, fresh=fresh)
190
+
191
+
192
+ def check_at(memgraph_env: dict[str, str], *, fresh: bool = False, strict: bool = False) -> Rules:
193
+ """:func:`check` for the graph a resolved Memgraph environment (``resolve_memgraph_env``) names."""
194
+ return check(
195
+ lambda: _connect(memgraph_env),
196
+ url=memgraph_env["MEMGRAPH_URL"],
197
+ database=memgraph_env["MEMGRAPH_DATABASE"],
198
+ fresh=fresh,
199
+ strict=strict,
200
+ )
201
+
202
+
203
+ def _connect(memgraph_env: dict[str, str]) -> AbstractContextManager[Any]:
204
+ from memgraph_context_graph.hooks.runner import memgraph_kwargs
205
+ from memgraph_toolbox.api.memgraph import Memgraph
206
+
207
+ return contextlib.closing(Memgraph(**memgraph_kwargs(memgraph_env)))
208
+
209
+
210
+ def configured_notice() -> str | None:
211
+ """The session-start notice for the graph the config file names, from the cache alone; never raises."""
212
+ from memgraph_context_graph.adapters._identity import resolve_memgraph_env
213
+
214
+ try:
215
+ env = resolve_memgraph_env()
216
+ return cached_rules(env["MEMGRAPH_URL"], env["MEMGRAPH_DATABASE"]).notice()
217
+ except Exception:
218
+ return None
219
+
220
+
221
+ def read_rules(db: Any) -> Rules:
222
+ """The graph's rules, read from its ``(:ContextGraphSchema)`` nodes."""
223
+ rows = db.query(
224
+ "MATCH (s:ContextGraphSchema) "
225
+ "RETURN s.component AS component, s.min_writer_epoch AS min_writer, s.min_reader_epoch AS min_reader"
226
+ )
227
+ min_writer: dict[str, int] = {}
228
+ min_reader: dict[str, int] = {}
229
+ for row in rows:
230
+ component = row.get("component")
231
+ if not isinstance(component, str):
232
+ continue
233
+ if isinstance(row.get("min_writer"), int):
234
+ min_writer[_normalize(component)] = row["min_writer"]
235
+ if isinstance(row.get("min_reader"), int):
236
+ min_reader[_normalize(component)] = row["min_reader"]
237
+ return Rules(min_writer, min_reader)
238
+
239
+
240
+ def _client_version() -> str:
241
+ from memgraph_context_graph import __version__
242
+
243
+ return __version__
244
+
245
+
246
+ def _record_client(db: Any, client_id: str, now: datetime) -> None:
247
+ from memgraph_toolbox.api.memgraph import merge_with_retry
248
+
249
+ merge_with_retry(
250
+ db,
251
+ """
252
+ MERGE (c:ContextGraphClient {client_id: $client_id})
253
+ SET c.version = $version, c.baseline_epoch = $baseline, c.epochs = $epochs, c.last_seen = $now
254
+ """,
255
+ params={
256
+ "client_id": client_id,
257
+ "version": _client_version(),
258
+ "baseline": BASELINE_EPOCH,
259
+ "epochs": dict(EPOCHS),
260
+ "now": now.isoformat(),
261
+ },
262
+ )
263
+
264
+
265
+ def _holds(cached: dict[str, Any], now: datetime) -> bool:
266
+ try:
267
+ checked_at = datetime.fromisoformat(cached["checked_at"])
268
+ except (KeyError, TypeError, ValueError):
269
+ return False
270
+ return timedelta(0) <= now - checked_at < CHECK_INTERVAL
271
+
272
+
273
+ def _rules_to(rules: Rules) -> dict[str, dict[str, int]]:
274
+ return {"min_writer": dict(rules.min_writer), "min_reader": dict(rules.min_reader)}
275
+
276
+
277
+ def _rules_from(raw: Any) -> Rules:
278
+ if not isinstance(raw, dict):
279
+ return Rules()
280
+
281
+ def epochs(name: str) -> dict[str, int]:
282
+ value = raw.get(name)
283
+ if not isinstance(value, dict):
284
+ return {}
285
+ return {str(c): e for c, e in value.items() if isinstance(e, int)}
286
+
287
+ return Rules(epochs("min_writer"), epochs("min_reader"))
288
+
289
+
290
+ def _graph_key(url: str, database: str) -> str:
291
+ return f"{url}#{database}"
292
+
293
+
294
+ def _normalize(component: str) -> str:
295
+ from memgraph_context_graph.tools import normalize_connector_name
296
+
297
+ return normalize_connector_name(component)
298
+
299
+
300
+ def state_path() -> Path:
301
+ """The cache file: beside the capture-failure log, so a config chosen with ``CONTEXT_GRAPH_CONFIG`` gets its own."""
302
+ log = capture_log.path()
303
+ return log.with_name(log.name.removesuffix("capture-failures.jsonl") + "graph-version.json")
304
+
305
+
306
+ def _load_state() -> dict[str, Any]:
307
+ try:
308
+ state = json.loads(state_path().read_text(encoding="utf-8"))
309
+ except (OSError, ValueError):
310
+ state = {}
311
+ if not isinstance(state, dict):
312
+ state = {}
313
+ if not isinstance(state.get("client_id"), str):
314
+ state["client_id"] = uuid.uuid4().hex
315
+ if not isinstance(state.get("graphs"), dict):
316
+ state["graphs"] = {}
317
+ return state
318
+
319
+
320
+ def _save_state(state: dict[str, Any]) -> None:
321
+ """Write the cache whole, through a rename, so a hook running at the same moment never reads half of it."""
322
+ target = state_path()
323
+ with contextlib.suppress(OSError):
324
+ target.parent.mkdir(parents=True, exist_ok=True)
325
+ temporary = target.with_name(f"{target.name}.{os.getpid()}.tmp")
326
+ temporary.write_text(json.dumps(state), encoding="utf-8")
327
+ os.replace(temporary, target)
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import argparse
6
+ import contextlib
6
7
  import json
7
8
  import os
8
9
  import sys
@@ -10,7 +11,7 @@ import threading
10
11
  import time
11
12
  from typing import TYPE_CHECKING, Any, NoReturn, TypedDict
12
13
 
13
- from memgraph_context_graph import capture_log
14
+ from memgraph_context_graph import capture_log, graph_version, refused_events
14
15
  from memgraph_context_graph.capture_policy import CapturePolicy, policy_from_config
15
16
  from memgraph_context_graph.connectors import (
16
17
  INSTALLABLE,
@@ -68,6 +69,7 @@ def create_link(
68
69
  memgraph_env: dict[str, str] | None = None,
69
70
  on_error: ConnectorFailureHandler | None = None,
70
71
  policy: CapturePolicy | None = None,
72
+ spool_refused: bool = True,
71
73
  ) -> AgentLink:
72
74
  """Create an AgentLink with optional connectors named by CLI/config.
73
75
 
@@ -79,6 +81,13 @@ def create_link(
79
81
  Memgraph raises, since no connector could run. Without it each connector
80
82
  connects on its own, as library callers always have.
81
83
 
84
+ With *memgraph_env*, a connector whose part of the graph a newer Context
85
+ Graph has reshaped (:mod:`memgraph_context_graph.graph_version`) is not
86
+ added: it fails like one that can't be set up, with a
87
+ :class:`~memgraph_context_graph.graph_version.GraphTooNewError`, and
88
+ its events are spooled for ``update`` to replay
89
+ (:mod:`memgraph_context_graph.refused_events`).
90
+
82
91
  Args:
83
92
  connector_names: Connectors to enable, by distribution name.
84
93
  memgraph_env: Resolved Memgraph settings (``resolve_memgraph_env``).
@@ -86,6 +95,8 @@ def create_link(
86
95
  event, is reported here and the others still run. Without it the
87
96
  first failure raises.
88
97
  policy: The capture policy every event passes before a connector sees it.
98
+ spool_refused: Keep refused connectors' events for replay; off for the
99
+ replay itself, which keeps them on its own terms.
89
100
 
90
101
  Raises:
91
102
  ValueError: if a connector name is not supported, or Memgraph can't be reached.
@@ -98,13 +109,28 @@ def create_link(
98
109
 
99
110
  link = AgentLink(on_error=report if on_error is not None else None, policy=policy)
100
111
  shared = _hook_memgraph(memgraph_env) if names and memgraph_env is not None else None
112
+ rules = (
113
+ graph_version.check(
114
+ lambda: contextlib.nullcontext(shared),
115
+ url=memgraph_env["MEMGRAPH_URL"],
116
+ database=memgraph_env["MEMGRAPH_DATABASE"],
117
+ )
118
+ if shared is not None and memgraph_env is not None
119
+ else graph_version.Rules()
120
+ )
121
+ refused: list[str] = []
101
122
  for name in names:
102
123
  try:
124
+ if not rules.may_write(name):
125
+ refused.append(name)
126
+ raise rules.refuse_write(name)
103
127
  link.add_connector(_build_connector(name, memgraph_env, memgraph=shared))
104
128
  except Exception as exc:
105
129
  if on_error is None:
106
130
  raise
107
131
  on_error(name, None, exc)
132
+ if refused and spool_refused:
133
+ link.add_connector(refused_events.Spool(refused))
108
134
  return link
109
135
 
110
136
 
@@ -420,11 +446,17 @@ def session_start_text(connector_names: list[str], payload: dict[str, Any] | Non
420
446
  its index of the user's memory files, standing in for the harness's own
421
447
  memory it replaces (#484). Each runtime wraps the text in the shape its
422
448
  session-start hook output takes (``RuntimeSpec.session_context``).
449
+
450
+ While a newer Context Graph's graph refuses this client, a notice comes first
451
+ (:func:`~memgraph_context_graph.graph_version.configured_notice`), so the
452
+ model can tell the user to update.
423
453
  """
424
454
  from memgraph_context_graph.tools import session_hints
425
455
 
456
+ notice = graph_version.configured_notice()
457
+ lines = [notice] if notice else []
426
458
  try:
427
- hints = session_hints(connector_names, payload=_with_cwd(payload or {}))
459
+ lines += session_hints(connector_names, payload=_with_cwd(payload or {}))
428
460
  except Exception as exc: # A tool that fails to load must not fail the hook; the model just isn't told.
429
461
  capture_log.record_failure(
430
462
  "respond",
@@ -433,8 +465,7 @@ def session_start_text(connector_names: list[str], payload: dict[str, Any] | Non
433
465
  hook_event="SessionStart",
434
466
  session_id=_payload_session_id(payload or {}),
435
467
  )
436
- return None
437
- return "\n".join(hints) or None
468
+ return "\n".join(lines) or None
438
469
 
439
470
 
440
471
  def _with_cwd(payload: dict[str, Any]) -> dict[str, Any]:
@@ -20,7 +20,9 @@ from __future__ import annotations
20
20
  import os
21
21
  from typing import TYPE_CHECKING, Any
22
22
 
23
- from memgraph_context_graph.tools import Tool, ToolError, is_available, load_tools
23
+ from memgraph_context_graph import graph_version
24
+ from memgraph_context_graph.graph_version import GraphTooNewError
25
+ from memgraph_context_graph.tools import Tool, ToolError, is_available, load_tools, writes_graph
24
26
 
25
27
  if TYPE_CHECKING:
26
28
  from mcp.server.lowlevel import Server
@@ -67,8 +69,8 @@ def build_server(tools: dict[str, Tool] | None = None, *, instructions: str | No
67
69
  return _error(f"Unknown tool: {name}")
68
70
  try:
69
71
  # Tools query Memgraph synchronously; a thread keeps the server responsive.
70
- result = await to_thread.run_sync(tool.call, arguments or {}, config)
71
- except ToolError as exc:
72
+ result = await to_thread.run_sync(_call_checked, tool, arguments or {}, config)
73
+ except (ToolError, GraphTooNewError) as exc:
72
74
  return _error(str(exc))
73
75
  return types.CallToolResult(content=[types.TextContent(type="text", text=result.text)])
74
76
 
@@ -87,6 +89,19 @@ def build_server(tools: dict[str, Tool] | None = None, *, instructions: str | No
87
89
  return Server(SERVER_NAME, instructions=instructions, on_list_tools=on_list_tools, on_call_tool=on_call_tool)
88
90
 
89
91
 
92
+ def _call_checked(tool: Tool, arguments: dict[str, Any], config: Any) -> Any:
93
+ """Call *tool* unless a newer Context Graph reshaped the graph it reads, or writes (``writes_graph``).
94
+
95
+ The server outlives package upgrades, so it checks on each call rather than once at start; the check is cached.
96
+ """
97
+ rules = graph_version.check_configured()
98
+ if not rules.may_read(tool.connector):
99
+ raise rules.refuse_read(tool.connector)
100
+ if writes_graph(tool) and not rules.may_write(tool.connector):
101
+ raise rules.refuse_write(tool.connector)
102
+ return tool.call(arguments, config)
103
+
104
+
90
105
  def session_instructions(tools: dict[str, Tool]) -> str | None:
91
106
  """The session-start text for the directory the harness started this server in."""
92
107
  from memgraph_context_graph.hooks.runner import session_start_text