memgraph-context-graph 0.5.0__py3-none-any.whl

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 (33) hide show
  1. memgraph_context_graph/__init__.py +71 -0
  2. memgraph_context_graph/adapters/__init__.py +0 -0
  3. memgraph_context_graph/adapters/_identity.py +520 -0
  4. memgraph_context_graph/adapters/_opencode_plugin.js +112 -0
  5. memgraph_context_graph/adapters/_spec.py +441 -0
  6. memgraph_context_graph/adapters/antigravity_cli.py +152 -0
  7. memgraph_context_graph/adapters/claude.py +297 -0
  8. memgraph_context_graph/adapters/claude_code.py +216 -0
  9. memgraph_context_graph/adapters/claude_code_memory.py +181 -0
  10. memgraph_context_graph/adapters/codex.py +118 -0
  11. memgraph_context_graph/adapters/codex_memory.py +185 -0
  12. memgraph_context_graph/adapters/copilot_cli.py +236 -0
  13. memgraph_context_graph/adapters/cursor.py +189 -0
  14. memgraph_context_graph/adapters/grok.py +187 -0
  15. memgraph_context_graph/adapters/openai.py +240 -0
  16. memgraph_context_graph/adapters/opencode.py +168 -0
  17. memgraph_context_graph/cli.py +845 -0
  18. memgraph_context_graph/events.py +210 -0
  19. memgraph_context_graph/hooks/__init__.py +1 -0
  20. memgraph_context_graph/hooks/cli.py +184 -0
  21. memgraph_context_graph/hooks/runner.py +311 -0
  22. memgraph_context_graph/hooks/runtime_plugin.py +85 -0
  23. memgraph_context_graph/link.py +50 -0
  24. memgraph_context_graph/mcp_server.py +102 -0
  25. memgraph_context_graph/memory_backend.py +144 -0
  26. memgraph_context_graph/protocols.py +67 -0
  27. memgraph_context_graph/spaces.py +214 -0
  28. memgraph_context_graph/tools.py +130 -0
  29. memgraph_context_graph-0.5.0.dist-info/METADATA +718 -0
  30. memgraph_context_graph-0.5.0.dist-info/RECORD +33 -0
  31. memgraph_context_graph-0.5.0.dist-info/WHEEL +4 -0
  32. memgraph_context_graph-0.5.0.dist-info/entry_points.txt +11 -0
  33. memgraph_context_graph-0.5.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,311 @@
1
+ """Generic command-hook CLI runner, shared by every registered runtime plugin.
2
+
3
+ Every command-hook adapter (``adapters/codex.py``, ``adapters/claude_code.py``)
4
+ used to duplicate this exact stdin-loading / connector-construction / run
5
+ sequence, differing only in which adapter class to instantiate and a handful
6
+ of env var names. This collapses that into one implementation driven by a
7
+ ``RuntimeCLIPlugin`` (see ``runtime_plugin.py``).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import importlib
14
+ import json
15
+ import os
16
+ import sys
17
+ from typing import TYPE_CHECKING, Any, TypedDict
18
+
19
+ from memgraph_context_graph.events import ToolStartEvent
20
+ from memgraph_context_graph.link import AgentLink
21
+ from memgraph_context_graph.protocols import GraphConnector
22
+
23
+ if TYPE_CHECKING:
24
+ from collections.abc import Iterable, Sequence
25
+
26
+ from memgraph_context_graph.events import Event
27
+ from memgraph_context_graph.hooks.runtime_plugin import RuntimeCLIPlugin
28
+
29
+
30
+ def load_payload(stream: Any | None = None) -> dict[str, Any]:
31
+ """Read one JSON hook payload from a text stream (stdin by default)."""
32
+ if stream is None:
33
+ stream = sys.stdin
34
+ raw = stream.read()
35
+ if not raw.strip():
36
+ return {}
37
+ payload = json.loads(raw)
38
+ if not isinstance(payload, dict):
39
+ msg = "Hook payload must be a JSON object"
40
+ raise TypeError(msg)
41
+ return payload
42
+
43
+
44
+ def create_link(connector_names: Iterable[str] = (), *, memgraph_env: dict[str, str] | None = None) -> AgentLink:
45
+ """Create an AgentLink with optional connectors named by CLI/config.
46
+
47
+ Runtime-agnostic: connectors (skills-graph, actions-graph, sessions-graph, resources-graph)
48
+ are graph components, not tied to any particular runtime adapter.
49
+ """
50
+ link = AgentLink()
51
+ for connector_name in connector_names:
52
+ normalized = connector_name.strip().replace("-", "_")
53
+ if not normalized:
54
+ continue
55
+ if normalized == "skills_graph":
56
+ _add_skills_graph_connector(link, memgraph_env)
57
+ elif normalized == "actions_graph":
58
+ _add_actions_graph_connector(link, memgraph_env)
59
+ elif normalized == "sessions_graph":
60
+ _add_sessions_graph_connector(link, memgraph_env)
61
+ elif normalized == "resources_graph":
62
+ _add_resources_graph_connector(link, memgraph_env)
63
+ else:
64
+ msg = f"Unsupported connector: {connector_name}"
65
+ raise ValueError(msg)
66
+ return link
67
+
68
+
69
+ _SCHEMA_COMPONENTS = {
70
+ "skills_graph": ("skills_graph", "SkillGraph"),
71
+ "actions_graph": ("actions_graph", "ActionsGraph"),
72
+ "sessions_graph": ("sessions_graph", "SessionsGraph"),
73
+ "resources_graph": ("resources_graph", "ResourcesGraph"),
74
+ }
75
+
76
+
77
+ def setup_connector_schemas(connector_names: Iterable[str], *, memgraph_env: dict[str, str] | None = None) -> None:
78
+ """Create the Memgraph schema (indexes, constraints) for each named connector.
79
+
80
+ Raises:
81
+ ValueError: if a connector name is not supported.
82
+ """
83
+ kwargs = _memgraph_kwargs(memgraph_env)
84
+ for connector_name in connector_names:
85
+ normalized = connector_name.strip().replace("-", "_")
86
+ if not normalized:
87
+ continue
88
+ if normalized not in _SCHEMA_COMPONENTS:
89
+ raise ValueError(f"Unsupported connector: {connector_name}")
90
+ module_name, class_name = _SCHEMA_COMPONENTS[normalized]
91
+ component_class = getattr(importlib.import_module(module_name), class_name)
92
+ component_class(**kwargs).setup()
93
+
94
+
95
+ def _env_prefix(runtime_name: str) -> str:
96
+ return "MEMGRAPH_CONTEXT_GRAPH_" + runtime_name.strip().replace("-", "_").upper()
97
+
98
+
99
+ def _connectors_from_env(runtime_name: str) -> list[str]:
100
+ value = os.environ.get(f"{_env_prefix(runtime_name)}_CONNECTORS", "")
101
+ return [part.strip() for part in value.split(",") if part.strip()]
102
+
103
+
104
+ def _debug_log(runtime_name: str, message: str) -> None:
105
+ if os.environ.get(f"{_env_prefix(runtime_name)}_DEBUG") == "1":
106
+ print(message, file=sys.stderr)
107
+
108
+
109
+ def run_hook(plugin: RuntimeCLIPlugin, argv: Sequence[str] | None = None) -> int:
110
+ """Read one hook payload from stdin, run it through *plugin*, print a response.
111
+
112
+ This is what ``memgraph-context-graph hook run <runtime>`` invokes: parse
113
+ the shared CLI flags, resolve Memgraph config, translate the payload via
114
+ ``plugin.adapter_class``, and write back whatever ``plugin.response_for_payload``
115
+ says the runtime expects -- identical flow for every registered runtime.
116
+ """
117
+ parser = argparse.ArgumentParser(description=f"Bridge {plugin.name} hooks to memgraph-context-graph.")
118
+ parser.add_argument(
119
+ "--connector",
120
+ action="append",
121
+ default=None,
122
+ help="Graph connector to enable. Currently supported: skills-graph, actions-graph, sessions-graph, resources-graph.",
123
+ )
124
+ parser.add_argument(
125
+ "--session-id",
126
+ default=None,
127
+ help="Override the session id from the hook payload.",
128
+ )
129
+ parser.add_argument(
130
+ "--event-name",
131
+ default=None,
132
+ help="Supply the event name when the runtime payload omits it.",
133
+ )
134
+ parser.add_argument("--memgraph-url", default=None, help="Memgraph Bolt URL. Overrides config file value.")
135
+ parser.add_argument("--memgraph-user", default=None, help="Memgraph username. Overrides config file value.")
136
+ parser.add_argument("--memgraph-password", default=None, help="Memgraph password. Overrides config file value.")
137
+ parser.add_argument("--memgraph-database", default=None, help="Memgraph database. Overrides config file value.")
138
+ parser.add_argument(
139
+ "--strict",
140
+ action="store_true",
141
+ help="Return a non-zero status if the hook payload cannot be recorded.",
142
+ )
143
+ args = parser.parse_args(argv)
144
+
145
+ connector_names = args.connector
146
+ if connector_names is None:
147
+ connector_names = _connectors_from_env(plugin.name)
148
+
149
+ from memgraph_context_graph.adapters._identity import resolve_memgraph_env
150
+
151
+ payload: dict[str, Any] = {}
152
+ try:
153
+ # Inside the guard: an edition the config can't resolve skips the hook like any other failure.
154
+ memgraph_env = resolve_memgraph_env(
155
+ url=args.memgraph_url,
156
+ user=args.memgraph_user,
157
+ password=args.memgraph_password,
158
+ database=args.memgraph_database,
159
+ )
160
+ payload = load_payload()
161
+ if args.event_name is not None:
162
+ payload.setdefault("hook_event_name", args.event_name)
163
+ link = create_link(connector_names, memgraph_env=memgraph_env)
164
+ tool_starts = _ToolStarts()
165
+ link.add_connector(tool_starts)
166
+ adapter = plugin.adapter_class(link, session_id=args.session_id)
167
+ adapter.handle_payload(payload)
168
+ _print_response(plugin, payload, connector_names, before_tool=_context_before_tool(link, tool_starts.events))
169
+ except Exception as exc:
170
+ strict_env = os.environ.get(f"{_env_prefix(plugin.name)}_STRICT") == "1"
171
+ if args.strict or strict_env:
172
+ raise
173
+ _print_response(plugin, payload, connector_names)
174
+ _debug_log(plugin.name, f"memgraph-context-graph {plugin.name} hook skipped: {exc}")
175
+ return 0
176
+
177
+
178
+ def _print_response(
179
+ plugin: RuntimeCLIPlugin, payload: dict[str, Any], connector_names: list[str], *, before_tool: Sequence[str] = ()
180
+ ) -> None:
181
+ response = plugin.response_for_payload(payload) or {}
182
+ if payload.get("hook_event_name") == "SessionStart":
183
+ response.update(session_start_context(connector_names, payload))
184
+ render = getattr(plugin, "context_before_tool_response", None)
185
+ if before_tool and render is not None:
186
+ response.update(render("\n".join(before_tool)) or {})
187
+ if response:
188
+ print(json.dumps(response))
189
+
190
+
191
+ class _ToolStarts(GraphConnector):
192
+ """Keeps the tool-start events one payload produced, to ask the connectors about afterwards."""
193
+
194
+ def __init__(self) -> None:
195
+ self.events: list[ToolStartEvent] = []
196
+
197
+ def supports(self, event: Event) -> bool:
198
+ return isinstance(event, ToolStartEvent)
199
+
200
+ def on_event(self, event: Event) -> None:
201
+ if isinstance(event, ToolStartEvent):
202
+ self.events.append(event)
203
+
204
+
205
+ def _context_before_tool(link: AgentLink, events: list[ToolStartEvent]) -> list[str]:
206
+ """Every connector's line for the tools about to run; a connector that fails adds nothing."""
207
+ lines: list[str] = []
208
+ for event in events:
209
+ for connector in link.connectors:
210
+ try:
211
+ line = connector.context_before_tool(event)
212
+ except Exception:
213
+ line = None
214
+ if line:
215
+ lines.append(line)
216
+ return lines
217
+
218
+
219
+ def session_start_context(connector_names: list[str], payload: dict[str, Any] | None = None) -> dict[str, Any]:
220
+ """The SessionStart output telling the model which memory tools it has; empty when none.
221
+
222
+ Recall is announced in one line and never pushes retrieved content: the
223
+ model calls it when a question needs memory (#394). The ``memory`` tool
224
+ adds its index of the user's memory files instead, standing in for the
225
+ harness's own memory it replaces (#484). Claude Code and Codex read the
226
+ same shape.
227
+ """
228
+ from memgraph_context_graph.tools import session_hints
229
+
230
+ try:
231
+ hints = session_hints(connector_names, payload=payload)
232
+ except Exception: # A tool that fails to load must not fail the hook.
233
+ return {}
234
+ if not hints:
235
+ return {}
236
+ return {"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": "\n".join(hints)}}
237
+
238
+
239
+ def _add_skills_graph_connector(link: AgentLink, memgraph_env: dict[str, str] | None = None) -> None:
240
+ try:
241
+ from skills_graph import SkillGraph
242
+ from skills_graph.connector import SkillGraphConnector
243
+ except ImportError as exc:
244
+ msg = "skills-graph is required for the skills-graph connector"
245
+ raise ImportError(msg) from exc
246
+
247
+ kwargs = _memgraph_kwargs(memgraph_env)
248
+ graph = SkillGraph(**kwargs)
249
+ link.add_connector(SkillGraphConnector(graph))
250
+
251
+
252
+ def _add_sessions_graph_connector(link: AgentLink, memgraph_env: dict[str, str] | None = None) -> None:
253
+ try:
254
+ from sessions_graph import SessionsGraph
255
+ from sessions_graph.connector import SessionsGraphConnector
256
+ except ImportError as exc:
257
+ msg = "sessions-graph is required for the sessions-graph connector"
258
+ raise ImportError(msg) from exc
259
+
260
+ from memgraph_context_graph.adapters._identity import resolve_auto_reconcile
261
+
262
+ kwargs = _memgraph_kwargs(memgraph_env)
263
+ graph = SessionsGraph(**kwargs)
264
+ link.add_connector(SessionsGraphConnector(graph, auto_reconcile=resolve_auto_reconcile()))
265
+
266
+
267
+ def _add_actions_graph_connector(link: AgentLink, memgraph_env: dict[str, str] | None = None) -> None:
268
+ try:
269
+ from actions_graph import ActionsGraph
270
+ from actions_graph.connector import ActionsGraphConnector
271
+ except ImportError as exc:
272
+ msg = "actions-graph is required for the actions-graph connector"
273
+ raise ImportError(msg) from exc
274
+
275
+ kwargs = _memgraph_kwargs(memgraph_env)
276
+ graph = ActionsGraph(**kwargs)
277
+ link.add_connector(ActionsGraphConnector(graph))
278
+
279
+
280
+ def _add_resources_graph_connector(link: AgentLink, memgraph_env: dict[str, str] | None = None) -> None:
281
+ try:
282
+ from resources_graph import ResourcesGraph
283
+ from resources_graph.connector import ResourcesGraphConnector
284
+ except ImportError as exc:
285
+ msg = "resources-graph is required for the resources-graph connector"
286
+ raise ImportError(msg) from exc
287
+
288
+ kwargs = _memgraph_kwargs(memgraph_env)
289
+ link.add_connector(ResourcesGraphConnector(ResourcesGraph(**kwargs)))
290
+
291
+
292
+ # A closed key set (never `memgraph`) so `Component(**kwargs)` below can't be
293
+ # read as colliding with that parameter's `Memgraph | None` type, unlike a
294
+ # plain `dict[str, str]`.
295
+ class _MemgraphKwargs(TypedDict, total=False):
296
+ url: str
297
+ username: str
298
+ password: str
299
+ database: str
300
+
301
+
302
+ def _memgraph_kwargs(memgraph_env: dict[str, str] | None) -> _MemgraphKwargs:
303
+ """Convert resolved memgraph env dict to kwargs for graph component constructors."""
304
+ if memgraph_env is None:
305
+ return {}
306
+ return {
307
+ "url": memgraph_env["MEMGRAPH_URL"],
308
+ "username": memgraph_env["MEMGRAPH_USER"],
309
+ "password": memgraph_env["MEMGRAPH_PASSWORD"],
310
+ "database": memgraph_env["MEMGRAPH_DATABASE"],
311
+ }
@@ -0,0 +1,85 @@
1
+ """Registry for command-hook runtime plugins.
2
+
3
+ A command-hook runtime (Codex, Claude Code, ...) registers itself by exposing
4
+ an object with this shape under the ``memgraph_context_graph.runtimes`` entry
5
+ point group, in its own package's ``pyproject.toml``:
6
+
7
+ [project.entry-points."memgraph_context_graph.runtimes"]
8
+ codex = "memgraph_context_graph.adapters.codex:PLUGIN"
9
+
10
+ No changes to memgraph-context-graph itself are needed to add a new runtime.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from importlib import metadata
16
+ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
17
+
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Mapping
20
+
21
+ _ENTRY_POINT_GROUP = "memgraph_context_graph.runtimes"
22
+
23
+
24
+ @runtime_checkable
25
+ class RuntimeCLIPlugin(Protocol):
26
+ """What a runtime must supply to be usable by memgraph-context-graph's CLI.
27
+
28
+ ``init`` is optional -- a runtime that has no project-local hook-config
29
+ file to generate (or hasn't implemented one yet) may omit it; callers
30
+ should use ``getattr(plugin, "init", None)`` rather than assume it exists.
31
+ """
32
+
33
+ # Both read-only: a plain mutable attribute here would require every
34
+ # plugin's own fields to be writable with these exact types (invariance),
35
+ # rejecting real plugins that are frozen dataclasses (name) or declare a
36
+ # narrower adapter_class (type[RuntimeAdapter] instead of type[Any]).
37
+ @property
38
+ def name(self) -> str: ...
39
+
40
+ @property
41
+ def adapter_class(self) -> type[Any]: ...
42
+
43
+ @property
44
+ def probe_payload(self) -> Mapping[str, Any]: ...
45
+
46
+ def response_for_payload(self, payload: dict[str, Any]) -> dict[str, Any] | None: ...
47
+
48
+ def build_hooks_config(self, command: str, *, timeout: int = 30) -> dict[str, Any]: ...
49
+
50
+ # Deliberately not a protocol member beyond this point: `init` is optional
51
+ # (see class docstring) and accessed via `getattr(plugin, "init", None)`,
52
+ # never through a RuntimeCLIPlugin-typed reference.
53
+
54
+
55
+ class UnknownRuntimeError(KeyError):
56
+ """Raised when no plugin is registered for a requested runtime name."""
57
+
58
+
59
+ def _normalize(name: str) -> str:
60
+ return name.strip().replace("_", "-")
61
+
62
+
63
+ def load_runtime_plugins() -> dict[str, RuntimeCLIPlugin]:
64
+ """Discover every registered runtime plugin, keyed by its normalized ``name``."""
65
+ plugins: dict[str, RuntimeCLIPlugin] = {}
66
+ for entry_point in metadata.entry_points(group=_ENTRY_POINT_GROUP):
67
+ plugin = entry_point.load()
68
+ plugins[_normalize(plugin.name)] = plugin
69
+ return plugins
70
+
71
+
72
+ def get_runtime_plugin(name: str) -> RuntimeCLIPlugin:
73
+ """Look up a registered runtime plugin by name.
74
+
75
+ Raises ``UnknownRuntimeError`` (listing what *is* registered) if none
76
+ matches -- the CLI catches this to print a clear error instead of a
77
+ traceback.
78
+ """
79
+ plugins = load_runtime_plugins()
80
+ normalized = _normalize(name)
81
+ if normalized not in plugins:
82
+ available = ", ".join(sorted(plugins)) or "(none registered)"
83
+ msg = f"Unknown runtime: {name!r}. Available: {available}"
84
+ raise UnknownRuntimeError(msg)
85
+ return plugins[normalized]
@@ -0,0 +1,50 @@
1
+ """AgentLink — the central hub that routes events to graph connectors.
2
+
3
+ An ``AgentLink`` instance holds a list of :class:`GraphConnector` instances.
4
+ Runtime adapters call :meth:`emit` to broadcast events to every registered
5
+ connector.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ from .events import Event
14
+ from .protocols import GraphConnector
15
+
16
+
17
+ class AgentLink:
18
+ """Routes :class:`Event` objects from runtime adapters to graph connectors.
19
+
20
+ Usage::
21
+
22
+ link = AgentLink()
23
+ link.add_connector(SkillGraphConnector(skill_graph))
24
+ link.add_connector(MyGraphConnector(graph))
25
+
26
+ adapter = ClaudeAdapter(link, session_id="s-1")
27
+ hooks = adapter.get_runtime_hooks()
28
+ """
29
+
30
+ def __init__(self) -> None:
31
+ self._connectors: list[GraphConnector] = []
32
+
33
+ def add_connector(self, connector: GraphConnector) -> None:
34
+ """Register a graph connector."""
35
+ self._connectors.append(connector)
36
+
37
+ def remove_connector(self, connector: GraphConnector) -> None:
38
+ """Unregister a graph connector."""
39
+ self._connectors.remove(connector)
40
+
41
+ @property
42
+ def connectors(self) -> list[GraphConnector]:
43
+ """Return all registered connectors (read-only snapshot)."""
44
+ return list(self._connectors)
45
+
46
+ def emit(self, event: Event) -> None:
47
+ """Broadcast an event to all connectors that support it."""
48
+ for connector in self._connectors:
49
+ if connector.supports(event):
50
+ connector.on_event(event)
@@ -0,0 +1,102 @@
1
+ """``memgraph-context-graph mcp``: a stdio MCP server for every registered tool.
2
+
3
+ The harness starts it (the Claude Code and Codex plugins bundle it), so like
4
+ the hooks it reads configuration from the config file alone. Needs the
5
+ ``mcp`` extra: ``memgraph-context-graph[mcp]``.
6
+
7
+ A result carries the tool's text only, never ``structuredContent``: Claude
8
+ Code and Codex both show the model the structured JSON *instead of* the text
9
+ when a result has both, and the text is the form recall was benchmarked in.
10
+ Programs get the JSON from the CLI (``memgraph-context-graph recall --json``).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ from memgraph_context_graph.tools import Tool, ToolError, is_available, load_tools
18
+
19
+ if TYPE_CHECKING:
20
+ from mcp.server.lowlevel import Server
21
+
22
+ SERVER_NAME = "context-graph"
23
+
24
+
25
+ def build_server(tools: dict[str, Tool] | None = None) -> Server:
26
+ """An MCP server exposing ``tools``, all registered tools by default.
27
+
28
+ Each listing and call re-checks the config file, so a tool whose
29
+ ``available`` check fails (e.g. ``memory`` before the user opts in) is
30
+ hidden and refused. Harnesses list tools once per session, so turning a
31
+ tool on shows up in the next session.
32
+
33
+ Supports mcp 1.x, which registers handlers through decorators, and 2.x,
34
+ which takes them as constructor arguments; the protocol is the same.
35
+ """
36
+ import mcp.types as types
37
+ from anyio import to_thread
38
+ from mcp.server.lowlevel import Server
39
+
40
+ from memgraph_context_graph.adapters._identity import reload_config
41
+
42
+ tools = load_tools() if tools is None else tools
43
+
44
+ def current_config() -> Any:
45
+ # The server lives as long as the harness session; re-read so
46
+ # `setup --memory-backend` or `config set` take effect mid-session.
47
+ return reload_config()
48
+
49
+ async def list_tools() -> list[types.Tool]:
50
+ config = current_config()
51
+ return [
52
+ types.Tool(name=tool.name, description=tool.description, inputSchema=tool.input_schema)
53
+ for tool in tools.values()
54
+ if is_available(tool, config)
55
+ ]
56
+
57
+ async def call_tool(name: str, arguments: dict[str, Any] | None) -> types.CallToolResult:
58
+ config = current_config()
59
+ tool = tools.get(name)
60
+ if tool is None or not is_available(tool, config):
61
+ return _error(f"Unknown tool: {name}")
62
+ try:
63
+ # Tools query Memgraph synchronously; a thread keeps the server responsive.
64
+ result = await to_thread.run_sync(tool.call, arguments or {}, config)
65
+ except ToolError as exc:
66
+ return _error(str(exc))
67
+ return types.CallToolResult(content=[types.TextContent(type="text", text=result.text)])
68
+
69
+ if hasattr(Server, "call_tool"): # mcp 1.x
70
+ server: Server = Server(SERVER_NAME)
71
+ server.list_tools()(list_tools)
72
+ server.call_tool()(call_tool)
73
+ return server
74
+
75
+ async def on_list_tools(ctx: Any, params: Any) -> types.ListToolsResult:
76
+ return types.ListToolsResult(tools=await list_tools())
77
+
78
+ async def on_call_tool(ctx: Any, params: Any) -> types.CallToolResult:
79
+ return await call_tool(params.name, params.arguments)
80
+
81
+ return Server(SERVER_NAME, on_list_tools=on_list_tools, on_call_tool=on_call_tool)
82
+
83
+
84
+ def serve() -> int:
85
+ """Serve over stdio until the harness closes the stream."""
86
+ import anyio
87
+ from mcp.server.stdio import stdio_server
88
+
89
+ server = build_server()
90
+
91
+ async def run() -> None:
92
+ async with stdio_server() as (read, write):
93
+ await server.run(read, write, server.create_initialization_options())
94
+
95
+ anyio.run(run)
96
+ return 0
97
+
98
+
99
+ def _error(message: str) -> Any:
100
+ import mcp.types as types
101
+
102
+ return types.CallToolResult(content=[types.TextContent(type="text", text=message)], isError=True)
@@ -0,0 +1,144 @@
1
+ """Switching a user's harness memory between the harness's own and Context Graph.
2
+
3
+ Neither Claude Code nor Codex lets another store stand in for its built-in
4
+ memory, so "Context Graph as the memory backend" means: turn the harness's
5
+ memory off in its own user-level config, record ``[memory] backend =
6
+ "context-graph"`` in ours (which turns on the ``memory`` tool and the
7
+ session-start index), and bring the harness's existing memories across once,
8
+ so opting in never starts from empty.
9
+
10
+ A runtime plugin takes part by exposing ``native_memory``, an object with the
11
+ :class:`NativeMemory` shape. Runtimes without one can't switch.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import TYPE_CHECKING, Any, Protocol
19
+
20
+ from memgraph_context_graph.adapters._identity import (
21
+ MEMORY_BACKEND_GRAPH,
22
+ MEMORY_BACKEND_NATIVE,
23
+ load_config,
24
+ write_config,
25
+ )
26
+ from memgraph_context_graph.spaces import spaces_for
27
+
28
+ if TYPE_CHECKING:
29
+ from memgraph_context_graph.hooks.runtime_plugin import RuntimeCLIPlugin
30
+
31
+
32
+ @dataclass
33
+ class ImportReport:
34
+ """What an import brought across, and what it left alone."""
35
+
36
+ imported: list[str] = field(default_factory=list)
37
+ skipped: list[str] = field(default_factory=list)
38
+
39
+
40
+ class MemoryFiles(Protocol):
41
+ """The part of sessions-graph's ``MemoryStore`` an import writes through."""
42
+
43
+ def view(self, path: str, view_range: tuple[int, int] | None = None) -> str: ...
44
+
45
+ def create(self, path: str, file_text: str) -> str: ...
46
+
47
+
48
+ class NativeMemory(Protocol):
49
+ """A harness's built-in memory, as far as switching away from it needs."""
50
+
51
+ def enabled(self, home: Path) -> bool:
52
+ """Whether the harness's own memory is on for this user."""
53
+ ...
54
+
55
+ def disable(self, home: Path) -> list[str]:
56
+ """Turn the harness's memory off in its user-level config; returns what changed."""
57
+ ...
58
+
59
+ def enable(self, home: Path) -> list[str]:
60
+ """Undo :meth:`disable`; returns what changed."""
61
+ ...
62
+
63
+ def import_into(self, files: MemoryFiles, home: Path) -> ImportReport:
64
+ """Copy the harness's existing memories into *files*, never overwriting a path."""
65
+ ...
66
+
67
+
68
+ class MemoryBackendError(Exception):
69
+ """The switch can't be made; the message says why and what to do."""
70
+
71
+
72
+ def native_memory_of(plugin: RuntimeCLIPlugin) -> NativeMemory | None:
73
+ """The runtime's built-in memory, or None for a runtime that can't switch."""
74
+ return getattr(plugin, "native_memory", None)
75
+
76
+
77
+ def use_graph_memory(plugin: RuntimeCLIPlugin, *, home: Path | None = None) -> list[str]:
78
+ """Make Context Graph *plugin*'s memory for this user: harness memory off, ours on, old memories imported.
79
+
80
+ The import runs first, so a failure (Memgraph down, sessions-graph
81
+ missing) leaves the user on their harness memory rather than on an
82
+ empty one. Re-running is safe: imported paths are skipped.
83
+
84
+ Raises:
85
+ MemoryBackendError: the runtime can't switch, no user is configured,
86
+ sessions-graph isn't installed, or Memgraph can't be reached.
87
+ """
88
+ native = _require_native(plugin)
89
+ home = home or Path.home()
90
+ config = load_config()
91
+ user_id = spaces_for(config).user_id()
92
+ if not user_id:
93
+ raise MemoryBackendError(
94
+ "No user is configured, so there is no memory to move into. "
95
+ "Set one first: memgraph-context-graph config set identity.user_id <name>"
96
+ )
97
+ report = native.import_into(_memory_files(config, user_id), home)
98
+ lines = [f"Imported {path}" for path in report.imported]
99
+ lines += [f"Kept existing {path}" for path in report.skipped]
100
+ lines += native.disable(home)
101
+ write_config(memory_backend=MEMORY_BACKEND_GRAPH)
102
+ lines.append(
103
+ f"Set memory.backend = {MEMORY_BACKEND_GRAPH}; the memory tool is on from the next {plugin.name} session."
104
+ )
105
+ return lines
106
+
107
+
108
+ def use_native_memory(plugin: RuntimeCLIPlugin, *, home: Path | None = None) -> list[str]:
109
+ """Hand memory back to *plugin*'s own: harness memory on, ours off. Graph memories stay in Memgraph.
110
+
111
+ Raises:
112
+ MemoryBackendError: the runtime can't switch.
113
+ """
114
+ native = _require_native(plugin)
115
+ lines = native.enable(home or Path.home())
116
+ write_config(memory_backend=MEMORY_BACKEND_NATIVE)
117
+ lines.append(
118
+ f"Set memory.backend = {MEMORY_BACKEND_NATIVE}; {plugin.name}'s own memory is back from its next session."
119
+ )
120
+ return lines
121
+
122
+
123
+ def _require_native(plugin: RuntimeCLIPlugin) -> NativeMemory:
124
+ native = native_memory_of(plugin)
125
+ if native is None:
126
+ raise MemoryBackendError(f"{plugin.name} has no memory Context Graph can replace.")
127
+ return native
128
+
129
+
130
+ def _memory_files(config: Any, user_id: str) -> MemoryFiles:
131
+ try:
132
+ from sessions_graph import SessionsGraph
133
+ except ImportError as exc:
134
+ raise MemoryBackendError(
135
+ "Context Graph memory needs sessions-graph: "
136
+ "uv tool install 'memgraph-context-graph[mcp]' --with 'sessions-graph[memgraph-context-graph]'"
137
+ ) from exc
138
+ target = spaces_for(config).personal().target
139
+ try:
140
+ graph = SessionsGraph(**target.client_kwargs())
141
+ graph.setup()
142
+ except Exception as exc:
143
+ raise MemoryBackendError(f"Can't reach Memgraph at {target.url}: {exc}") from exc
144
+ return graph.memory_store(user_id)