mcp-server-evolutiondb 1.1.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.
@@ -0,0 +1,229 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-server-evolutiondb
3
+ Version: 1.1.0
4
+ Summary: MCP (Model Context Protocol) server backed by EvolutionDB — gives Claude Desktop / Claude Code persistent long-term memory.
5
+ Author-email: alptekin topal <topal.alptekin@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/alptekin/evolutiondb
8
+ Project-URL: Documentation, https://alptekin.github.io/evolutiondb/
9
+ Project-URL: Repository, https://github.com/alptekin/evolutiondb
10
+ Project-URL: Issues, https://github.com/alptekin/evolutiondb/issues
11
+ Project-URL: Source, https://github.com/alptekin/evolutiondb/tree/main/client/mcp-server-evosql
12
+ Keywords: mcp,model-context-protocol,evolutiondb,claude,claude-desktop,claude-code,long-term-memory,agent-memory
13
+ Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.9
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Topic :: Database
25
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
26
+ Requires-Python: >=3.9
27
+ Description-Content-Type: text/markdown
28
+ Requires-Dist: psycopg[binary]>=3.1
29
+
30
+ # mcp-server-evolutiondb
31
+
32
+ <!-- mcp-name: io.github.alptekin/evolutiondb-memory -->
33
+
34
+ A [Model Context Protocol](https://modelcontextprotocol.io) server
35
+ that gives Claude Desktop / Claude Code persistent **long-term
36
+ memory** backed by EvolutionDB. Anything Claude decides to remember
37
+ during a conversation is written to a real database; in any future
38
+ session — same window or weeks later — Claude can search it back
39
+ without you having to repaste context.
40
+
41
+ ## Install
42
+
43
+ ```bash
44
+ pipx install mcp-server-evolutiondb
45
+ # or: pip install --user mcp-server-evolutiondb
46
+ ```
47
+
48
+ The package installs the `mcp-server-evolutiondb` console entry-point
49
+ (also aliased as `mcp-server-evosql`). It speaks the PostgreSQL wire
50
+ protocol over `psycopg`, so installation is **pure-Python** — no C
51
+ toolchain, no `libevosql-memory.so` to build. EvolutionDB still has
52
+ to be running somewhere reachable; `docker compose up -d` in the
53
+ [main repo](https://github.com/alptekin/evolutiondb) is the easiest
54
+ way.
55
+
56
+ ```
57
+ ┌──────────────────┐ ┌─────────────────────┐ ┌────────────────┐
58
+ │ Claude Desktop │ stdio │ mcp-server-evosql │ TCP │ EvolutionDB │
59
+ │ (or Claude │ ◀──────▶│ (this package) │ ◀──────▶│ (port 9967) │
60
+ │ Code) │ JSON- │ │ │ │
61
+ │ │ RPC │ save_memory │ │ MEMORY STORE │
62
+ │ │ 2.0 │ search_memory │ │ ENTITY STORE │
63
+ │ │ │ recent_memories │ │ │
64
+ │ │ │ forget │ │ │
65
+ │ │ │ list_tags │ │ │
66
+ └──────────────────┘ └─────────────────────┘ └────────────────┘
67
+ ```
68
+
69
+ ## Why
70
+
71
+ The default Claude experience is **stateless** — every new chat starts
72
+ from scratch, so you waste tokens re-explaining who you are, what
73
+ project you're on, what your preferences are. Plug this server in
74
+ and the model:
75
+
76
+ - saves preferences / decisions / facts during natural conversation,
77
+ - searches them back the next time you ask something related,
78
+ - forgets entries on demand,
79
+ - never sees the user_id that pins the namespace (we override it
80
+ server-side, so the model can't accidentally fragment the
81
+ namespace by inventing IDs across sessions).
82
+
83
+ Token math: 100 chats × 3,000 tokens of pre-loaded context (~$0.90
84
+ on Sonnet) → 100 chats × ~250 tokens of just-relevant facts pulled
85
+ on demand (~$0.26). Roughly **3.5× cheaper inputs** without losing
86
+ context fidelity.
87
+
88
+ ## What's exposed to Claude
89
+
90
+ Five tools, all under one `evolutiondb-memory` MCP server:
91
+
92
+ | Tool | Purpose |
93
+ |---------------------|--------------------------------------------------|
94
+ | `save_memory` | Persist a fact + optional tags |
95
+ | `search_memory` | Substring + tag search (use before answering) |
96
+ | `recent_memories` | Last N saved facts (most-recent-first) |
97
+ | `forget` | Delete by key |
98
+ | `list_tags` | All distinct tags in use, with counts |
99
+
100
+ Each call's `user_id` is overridden server-side from the
101
+ `MCP_USER_ID` env var — stops the model from drifting the namespace
102
+ across "user" / "default_user" / your name etc.
103
+
104
+ ## Install + run
105
+
106
+ **1. Bring up EvolutionDB**
107
+
108
+ ```bash
109
+ cd /path/to/evolutiondb
110
+ docker compose up -d
111
+ ```
112
+
113
+ **2. Build the SDK once**
114
+
115
+ ```bash
116
+ make -C client/libevosql-memory
117
+ ```
118
+
119
+ **3. Configure Claude Desktop**
120
+
121
+ Open the config file:
122
+
123
+ - macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
124
+ - Windows: `%APPDATA%\Claude\claude_desktop_config.json`
125
+
126
+ Drop in the entry from
127
+ [`examples/claude_desktop_config.json`](examples/claude_desktop_config.json),
128
+ substituting the absolute paths for your machine. Quit + restart
129
+ Claude Desktop.
130
+
131
+ You'll see a small 🔌 / hammer icon in the bottom-right of the
132
+ chat composer once `evolutiondb-memory` is connected.
133
+
134
+ **4. Talk normally**
135
+
136
+ Say "remember that I take my espresso single-shot, no sugar"; Claude
137
+ will run `save_memory(...)`. Days later open a new chat, ask "what
138
+ do I drink?" — Claude runs `search_memory(...)` and recalls.
139
+
140
+ ## Same setup for Claude Code
141
+
142
+ If you use Claude Code (the CLI), drop the same `mcpServers` entry
143
+ into `~/.claude/mcp.json`:
144
+
145
+ ```bash
146
+ mkdir -p ~/.claude
147
+ cp client/mcp-server-evosql/examples/claude_desktop_config.json ~/.claude/mcp.json
148
+ # edit the absolute paths
149
+ ```
150
+
151
+ Claude Code picks it up automatically on the next `claude` invocation.
152
+
153
+ ## Configuration
154
+
155
+ | Env var | Default | Purpose |
156
+ |-----------------------|--------------------|---------|
157
+ | `EVOSQL_HOST` | `127.0.0.1` | DB host |
158
+ | `EVOSQL_PORT` | `9967` | EVO port |
159
+ | `EVOSQL_USER` | `admin` | DB user |
160
+ | `EVOSQL_PASSWORD` | `admin` | DB password |
161
+ | `MCP_USER_ID` | `default_user` | Sticky namespace for every tool call |
162
+ | `MCP_STORE_PREFIX` | `mcp` | Catalog object prefix |
163
+ | `EVOSQL_PYTHON_SDK` | (auto-discovered) | Override path to the Python ctypes binding |
164
+ | `EVOSQL_MEMORY_LIB` | (auto-discovered) | Override path to libevosql-memory.dylib/so |
165
+
166
+ ## Tests
167
+
168
+ ```bash
169
+ cd client/mcp-server-evosql
170
+ python3 tests/test_mcp.py
171
+ ```
172
+
173
+ Eight cases — initialize handshake, tools/list discovery, save+search
174
+ round-trip, tag-filtered search, recent ordering, forget, list_tags
175
+ aggregation, and the "user_id can't be hijacked from the LLM side"
176
+ isolation case. Each test spawns the server as a real subprocess and
177
+ talks JSON-RPC, so framing bugs that an in-process unit test would
178
+ hide get caught.
179
+
180
+ ## Inspect the database directly
181
+
182
+ While Claude is using the server, open another terminal and:
183
+
184
+ ```bash
185
+ docker compose exec evosql evosql-cli -W admin
186
+ ```
187
+
188
+ Then:
189
+
190
+ ```sql
191
+ SELECT mem_namespace, mem_key, mem_value FROM __mem_mcp_mem;
192
+ ENTITY RANK FROM mcp_ents;
193
+ ```
194
+
195
+ Everything Claude has decided to remember is right there as
196
+ queryable rows — no opaque blob storage.
197
+
198
+ ## Wire format
199
+
200
+ Newline-delimited JSON-RPC 2.0 over stdio (no Content-Length
201
+ headers — that's the LSP variant; MCP uses plain `\n`-delimited).
202
+ The server speaks protocol version `2024-11-05`.
203
+
204
+ ```
205
+ {"jsonrpc":"2.0","id":1,"method":"initialize",
206
+ "params":{"protocolVersion":"2024-11-05","capabilities":{}}}
207
+
208
+ {"jsonrpc":"2.0","id":2,"method":"tools/list"}
209
+
210
+ {"jsonrpc":"2.0","id":3,"method":"tools/call",
211
+ "params":{"name":"save_memory",
212
+ "arguments":{"fact":"loves jazz","tags":["preference"]}}}
213
+ ```
214
+
215
+ Errors come back as `{"jsonrpc":"2.0","id":N,"error":{"code":..,"message":..}}`
216
+ or as a `tools/call` result with `isError: true`.
217
+
218
+ ## Known limitations
219
+
220
+ - **Single-process EvolutionDB connection.** The server holds one
221
+ Connection — the SDK contract is one-per-thread, and MCP stdio is
222
+ inherently single-threaded so this is fine.
223
+ - **No streaming responses.** Tool results return as single JSON
224
+ blobs. Larger memories (>100 facts) take ~50 ms to serialise; the
225
+ protocol can stream but Claude's tool-use UI doesn't render
226
+ partial responses anyway.
227
+ - **Authentication via env-vars only.** If you expose the server to
228
+ another machine (which you shouldn't — it's stdio), set
229
+ `EVOSQL_PASSWORD` accordingly. The server doesn't rotate secrets.
@@ -0,0 +1,8 @@
1
+ mcp_server_evosql/__init__.py,sha256=LCJ7j9SPR2PfVAGVkxe5u2KyBORREfe4QBlT2Z0n4rA,84
2
+ mcp_server_evosql/__main__.py,sha256=DKoUu5zWvBGoul6cWpjMXZ6pdXXUuBgjLQRpFsw0S-0,110
3
+ mcp_server_evosql/server.py,sha256=KCcVE5BRyoFgivl-5aUmSEqtRjKSlqBlyJ2NLqrElNA,16145
4
+ mcp_server_evolutiondb-1.1.0.dist-info/METADATA,sha256=3K0KaoVl1alOMK8TdDHxLrlv2fNMqTXouX3qjQ1WaAY,9274
5
+ mcp_server_evolutiondb-1.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
6
+ mcp_server_evolutiondb-1.1.0.dist-info/entry_points.txt,sha256=AT_7a8l_YHfO73yfns9iKqHBHD4fUObn0WRMPySHrbI,127
7
+ mcp_server_evolutiondb-1.1.0.dist-info/top_level.txt,sha256=VYvE5FXJRj7fDLw1ZQ--e0oW6_3xXF7-sWviQctI2xg,18
8
+ mcp_server_evolutiondb-1.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ mcp-server-evolutiondb = mcp_server_evosql.__main__:main
3
+ mcp-server-evosql = mcp_server_evosql.__main__:main
@@ -0,0 +1 @@
1
+ mcp_server_evosql
@@ -0,0 +1,2 @@
1
+ """mcp-server-evosql — MCP server backed by EvolutionDB."""
2
+ __version__ = "1.0.0"
@@ -0,0 +1,5 @@
1
+ """python -m mcp_server_evosql entrypoint."""
2
+ from .server import main
3
+
4
+ if __name__ == "__main__":
5
+ main()
@@ -0,0 +1,430 @@
1
+ """
2
+ mcp_server_evosql.server — JSON-RPC 2.0 stdio loop that exposes
3
+ EvolutionDB-backed long-term memory to Claude Desktop / Claude Code
4
+ through the MCP (Model Context Protocol) standard.
5
+
6
+ Protocol surface implemented:
7
+ - initialize — handshake, advertises tools capability
8
+ - tools/list — describes the five memory tools
9
+ - tools/call — dispatches to save / search / recent /
10
+ forget / list_tags
11
+ - notifications/initialized (incoming, no response needed)
12
+ - shutdown / exit — clean teardown
13
+
14
+ Threading: MCP stdio is single-threaded. Every request is processed
15
+ in order, so the underlying psycopg connection stays inside one
16
+ event loop.
17
+
18
+ User isolation: every tool call has its `user_id` server-side
19
+ overridden to MCP_USER_ID env (default "default_user"). This is the
20
+ sticky-id trick — keeps the LLM from fragmenting the namespace
21
+ across "user" / "default_user" / the user's actual name etc.
22
+
23
+ Backend: speaks the PostgreSQL wire protocol (port 5433) over psycopg.
24
+ This avoids having to ship a compiled C library on PyPI; psycopg has
25
+ pre-built binary wheels on every platform. EvolutionDB's PG adaptor
26
+ parses the same MEMORY DDL/DML the EVO native protocol does.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import os
32
+ import sys
33
+ import time
34
+ import traceback
35
+ import uuid
36
+ from typing import Any, Dict, List, Optional
37
+
38
+
39
+ PROTOCOL_VERSION = "2024-11-05" # MCP version we speak
40
+ SERVER_NAME = "evolutiondb-memory"
41
+ SERVER_VERSION = "1.1.0"
42
+
43
+
44
+ # ---------------------------------------------------------------- #
45
+ # Memory backend — speaks EvolutionDB over psycopg / PG wire. #
46
+ # ---------------------------------------------------------------- #
47
+ def _e(s: str) -> str:
48
+ """Escape a value for inline SQL.
49
+
50
+ The MEMORY DDL/DML doesn't take parameters in the EVO grammar,
51
+ so we have to inline. We strip control bytes and double up
52
+ apostrophes — same defensive shape as the upstream Python SDK.
53
+ """
54
+ if not isinstance(s, str):
55
+ s = str(s)
56
+ s = s.replace("\r", " ").replace("\n", " ").replace("\t", " ")
57
+ return s.replace("'", "''")
58
+
59
+
60
+ class MemoryBackend:
61
+ def __init__(self, host: str, port: int, user: str, password: str,
62
+ database: str, prefix: str):
63
+ try:
64
+ import psycopg
65
+ except ImportError as exc:
66
+ raise RuntimeError(
67
+ "mcp-server-evosql requires psycopg. "
68
+ "Install with `pip install 'mcp-server-evosql'` "
69
+ "or `pip install psycopg[binary]>=3.1`."
70
+ ) from exc
71
+
72
+ self.psycopg = psycopg
73
+ self.conn = psycopg.connect(
74
+ host=host, port=port, user=user, password=password,
75
+ dbname=database, autocommit=True,
76
+ )
77
+ self.memory = f"{prefix}_mem"
78
+ self.entities = f"{prefix}_ents"
79
+ # Idempotent CREATE — the server must not lose data across
80
+ # restarts. Both objects already exist on second start, the
81
+ # error is silently swallowed.
82
+ for kind, name in [
83
+ ("MEMORY STORE", self.memory),
84
+ ("ENTITY STORE", self.entities),
85
+ ]:
86
+ try:
87
+ with self.conn.cursor() as cur:
88
+ cur.execute(f"CREATE {kind} {name}")
89
+ except Exception:
90
+ pass
91
+
92
+ # -- helpers ------------------------------------------------------
93
+ def _exec(self, sql: str) -> None:
94
+ with self.conn.cursor() as cur:
95
+ cur.execute(sql)
96
+
97
+ def _query(self, sql: str) -> List[List[str]]:
98
+ with self.conn.cursor() as cur:
99
+ cur.execute(sql)
100
+ try:
101
+ return [list(map(_to_str, row)) for row in cur.fetchall()]
102
+ except self.psycopg.ProgrammingError:
103
+ return []
104
+
105
+ # -- DML wrappers -------------------------------------------------
106
+ def save(self, user_id: str, fact: str,
107
+ tags: Optional[List[str]] = None) -> str:
108
+ created = time.time()
109
+ key = f"mem_{int(created*1000)}_{uuid.uuid4().hex[:6]}"
110
+ value = json.dumps({
111
+ "fact": fact,
112
+ "tags": tags or [],
113
+ "created": created,
114
+ })
115
+ self._exec(
116
+ f"MEMORY PUT INTO {self.memory} VALUES "
117
+ f"('{_e(user_id)}','{_e(key)}','{_e(value)}')"
118
+ )
119
+ return key
120
+
121
+ def search(self, user_id: str, query: str,
122
+ limit: int = 5,
123
+ tag: Optional[str] = None) -> List[Dict[str, Any]]:
124
+ rows = self._query(
125
+ f"SELECT mem_namespace, mem_key, mem_value FROM "
126
+ f"__mem_{self.memory} WHERE mem_namespace = "
127
+ f"'{_e(user_id)}' LIMIT 512"
128
+ )
129
+ q_terms = [w for w in query.lower().split() if len(w) > 1]
130
+ out: List[Dict[str, Any]] = []
131
+ for r in rows:
132
+ try:
133
+ rec = json.loads(r[2]) if r[2] else None
134
+ except Exception:
135
+ rec = {"fact": r[2]}
136
+ if not rec or not rec.get("fact"):
137
+ continue
138
+ haystack = (rec.get("fact", "").lower() + " " +
139
+ " ".join(rec.get("tags") or []).lower())
140
+ score = sum(1 for w in q_terms if w in haystack)
141
+ if tag and tag.lower() not in [t.lower() for t in (rec.get("tags") or [])]:
142
+ continue
143
+ if score == 0 and not tag:
144
+ continue
145
+ out.append({"key": r[1], "score": score, **rec})
146
+ out.sort(key=lambda x: -x["score"])
147
+ return out[:limit]
148
+
149
+ def recent(self, user_id: str, limit: int = 10) -> List[Dict[str, Any]]:
150
+ rows = self._query(
151
+ f"SELECT mem_namespace, mem_key, mem_value FROM "
152
+ f"__mem_{self.memory} WHERE mem_namespace = "
153
+ f"'{_e(user_id)}' LIMIT 512"
154
+ )
155
+ out: List[Dict[str, Any]] = []
156
+ for r in rows:
157
+ try:
158
+ rec = json.loads(r[2]) if r[2] else {}
159
+ except Exception:
160
+ rec = {"fact": r[2]}
161
+ out.append({"key": r[1], **rec})
162
+ out.sort(key=lambda x: -x.get("created", 0))
163
+ return out[:limit]
164
+
165
+ def forget(self, user_id: str, key: str) -> bool:
166
+ try:
167
+ self._exec(
168
+ f"MEMORY DELETE FROM {self.memory} "
169
+ f"WHERE NS='{_e(user_id)}' AND KEY='{_e(key)}'"
170
+ )
171
+ return True
172
+ except Exception:
173
+ return False
174
+
175
+ def list_tags(self, user_id: str) -> List[Dict[str, Any]]:
176
+ rows = self._query(
177
+ f"SELECT mem_namespace, mem_value FROM "
178
+ f"__mem_{self.memory} WHERE mem_namespace = "
179
+ f"'{_e(user_id)}' LIMIT 512"
180
+ )
181
+ counts: Dict[str, int] = {}
182
+ for r in rows:
183
+ try:
184
+ rec = json.loads(r[1]) if r[1] else {}
185
+ except Exception:
186
+ continue
187
+ for tag in (rec.get("tags") or []):
188
+ counts[tag] = counts.get(tag, 0) + 1
189
+ out = [{"tag": t, "count": c} for t, c in counts.items()]
190
+ out.sort(key=lambda x: -x["count"])
191
+ return out
192
+
193
+
194
+ def _to_str(v: Any) -> str:
195
+ if v is None:
196
+ return ""
197
+ if isinstance(v, (bytes, bytearray)):
198
+ try:
199
+ return v.decode("utf-8")
200
+ except UnicodeDecodeError:
201
+ return v.decode("latin-1", "replace")
202
+ if isinstance(v, (dict, list)):
203
+ return json.dumps(v, ensure_ascii=False)
204
+ return str(v)
205
+
206
+
207
+ # ---------------------------------------------------------------- #
208
+ # Tool catalog — what Claude Desktop sees on tools/list. #
209
+ # ---------------------------------------------------------------- #
210
+ TOOLS = [
211
+ {
212
+ "name": "save_memory",
213
+ "description": (
214
+ "Persist a long-term fact about the user. Call this whenever "
215
+ "the user shares a preference, decision, biographical detail, "
216
+ "or anything you'd want to remember across future "
217
+ "conversations. The fact will be available to all future "
218
+ "Claude sessions through search_memory."
219
+ ),
220
+ "inputSchema": {
221
+ "type": "object",
222
+ "properties": {
223
+ "fact": {
224
+ "type": "string",
225
+ "description": "Concise statement of what to remember."
226
+ },
227
+ "tags": {
228
+ "type": "array",
229
+ "items": {"type": "string"},
230
+ "description": "Categorisation labels (e.g. work, "
231
+ "preference, family). Optional."
232
+ },
233
+ },
234
+ "required": ["fact"],
235
+ },
236
+ },
237
+ {
238
+ "name": "search_memory",
239
+ "description": (
240
+ "Search remembered facts. Call this BEFORE answering any "
241
+ "question that depends on prior knowledge of the user. "
242
+ "Substring + tag matching; supply both `query` and "
243
+ "(optionally) `tag` to narrow."
244
+ ),
245
+ "inputSchema": {
246
+ "type": "object",
247
+ "properties": {
248
+ "query": {"type": "string"},
249
+ "tag": {"type": "string",
250
+ "description": "Optional tag filter."},
251
+ "limit": {"type": "integer", "default": 5},
252
+ },
253
+ "required": ["query"],
254
+ },
255
+ },
256
+ {
257
+ "name": "recent_memories",
258
+ "description": "List the most recently saved facts.",
259
+ "inputSchema": {
260
+ "type": "object",
261
+ "properties": {
262
+ "limit": {"type": "integer", "default": 10},
263
+ },
264
+ },
265
+ },
266
+ {
267
+ "name": "forget",
268
+ "description": "Delete a stored fact by its `key` (returned by "
269
+ "save_memory or surfaced by search_memory).",
270
+ "inputSchema": {
271
+ "type": "object",
272
+ "properties": {"key": {"type": "string"}},
273
+ "required": ["key"],
274
+ },
275
+ },
276
+ {
277
+ "name": "list_tags",
278
+ "description": "List all distinct tags used so far, with counts.",
279
+ "inputSchema": {"type": "object", "properties": {}},
280
+ },
281
+ ]
282
+
283
+
284
+ # ---------------------------------------------------------------- #
285
+ # Server — newline-delimited JSON-RPC 2.0 over stdio. #
286
+ # ---------------------------------------------------------------- #
287
+ class MCPServer:
288
+ def __init__(self) -> None:
289
+ self.user_id = os.environ.get("MCP_USER_ID", "default_user")
290
+ self.host = os.environ.get("EVOSQL_HOST", "127.0.0.1")
291
+ # Default to PostgreSQL wire (5433). Older deployments using EVO
292
+ # native (9967) won't work over psycopg — point them at 5433.
293
+ self.port = int(os.environ.get("EVOSQL_PORT", "5433"))
294
+ self.user = os.environ.get("EVOSQL_USER", "admin")
295
+ self.pw = os.environ.get("EVOSQL_PASSWORD", "admin")
296
+ self.db = os.environ.get("EVOSQL_DATABASE", "testdb")
297
+ self.prefix = os.environ.get("MCP_STORE_PREFIX", "mcp")
298
+ self.backend: Optional[MemoryBackend] = None
299
+
300
+ def _connect(self) -> MemoryBackend:
301
+ if self.backend is None:
302
+ self.backend = MemoryBackend(self.host, self.port,
303
+ self.user, self.pw,
304
+ self.db, self.prefix)
305
+ return self.backend
306
+
307
+ # -- tool dispatch ------------------------------------------------
308
+ def _call_tool(self, name: str, args: Dict[str, Any]) -> Dict[str, Any]:
309
+ b = self._connect()
310
+ if name == "save_memory":
311
+ fact = args.get("fact") or ""
312
+ if not fact.strip():
313
+ return {"error": "save_memory requires non-empty `fact`"}
314
+ tags = args.get("tags") or []
315
+ if isinstance(tags, str):
316
+ tags = [tags]
317
+ key = b.save(self.user_id, fact, tags)
318
+ return {"ok": True, "key": key, "user_id": self.user_id}
319
+
320
+ if name == "search_memory":
321
+ q = args.get("query") or ""
322
+ if not q.strip():
323
+ return {"error": "search_memory requires non-empty `query`"}
324
+ tag = args.get("tag")
325
+ limit = int(args.get("limit") or 5)
326
+ return {"ok": True,
327
+ "user_id": self.user_id,
328
+ "results": b.search(self.user_id, q, limit=limit, tag=tag)}
329
+
330
+ if name == "recent_memories":
331
+ limit = int(args.get("limit") or 10)
332
+ return {"ok": True, "user_id": self.user_id,
333
+ "results": b.recent(self.user_id, limit)}
334
+
335
+ if name == "forget":
336
+ key = args.get("key") or ""
337
+ if not key:
338
+ return {"error": "forget requires `key`"}
339
+ ok = b.forget(self.user_id, key)
340
+ return {"ok": ok, "key": key}
341
+
342
+ if name == "list_tags":
343
+ return {"ok": True, "user_id": self.user_id,
344
+ "tags": b.list_tags(self.user_id)}
345
+
346
+ return {"error": f"unknown tool: {name}"}
347
+
348
+ # -- JSON-RPC dispatch -------------------------------------------
349
+ def handle(self, msg: Dict[str, Any]) -> Optional[Dict[str, Any]]:
350
+ method = msg.get("method")
351
+ params = msg.get("params") or {}
352
+ msg_id = msg.get("id")
353
+
354
+ # Notifications (no id) get no response.
355
+ if msg_id is None:
356
+ return None
357
+
358
+ if method == "initialize":
359
+ return self._ok(msg_id, {
360
+ "protocolVersion": PROTOCOL_VERSION,
361
+ "capabilities": {"tools": {"listChanged": False}},
362
+ "serverInfo": {"name": SERVER_NAME,
363
+ "version": SERVER_VERSION},
364
+ })
365
+
366
+ if method == "tools/list":
367
+ return self._ok(msg_id, {"tools": TOOLS})
368
+
369
+ if method == "tools/call":
370
+ name = params.get("name") or ""
371
+ args = params.get("arguments") or {}
372
+ try:
373
+ result = self._call_tool(name, args)
374
+ except Exception as e:
375
+ traceback.print_exc(file=sys.stderr)
376
+ return self._ok(msg_id, {
377
+ "content": [{"type": "text",
378
+ "text": f"tool {name} failed: {e}"}],
379
+ "isError": True,
380
+ })
381
+ text = json.dumps(result, ensure_ascii=False)
382
+ return self._ok(msg_id, {
383
+ "content": [{"type": "text", "text": text}],
384
+ "isError": bool(result.get("error")),
385
+ })
386
+
387
+ if method in ("ping",):
388
+ return self._ok(msg_id, {})
389
+
390
+ return self._err(msg_id, -32601, f"method not found: {method}")
391
+
392
+ @staticmethod
393
+ def _ok(id_, result) -> Dict[str, Any]:
394
+ return {"jsonrpc": "2.0", "id": id_, "result": result}
395
+
396
+ @staticmethod
397
+ def _err(id_, code, message) -> Dict[str, Any]:
398
+ return {"jsonrpc": "2.0", "id": id_,
399
+ "error": {"code": code, "message": message}}
400
+
401
+
402
+ # ---------------------------------------------------------------- #
403
+ # stdio loop #
404
+ # ---------------------------------------------------------------- #
405
+ def main() -> int:
406
+ server = MCPServer()
407
+ print(
408
+ f"[mcp-evosql] listening on stdio "
409
+ f"(evosql={server.host}:{server.port}, user_id={server.user_id!r})",
410
+ file=sys.stderr, flush=True)
411
+
412
+ for raw_line in sys.stdin:
413
+ raw_line = raw_line.strip()
414
+ if not raw_line:
415
+ continue
416
+ try:
417
+ msg = json.loads(raw_line)
418
+ except json.JSONDecodeError as e:
419
+ print(f"[mcp-evosql] bad JSON line: {e}",
420
+ file=sys.stderr, flush=True)
421
+ continue
422
+ try:
423
+ resp = server.handle(msg)
424
+ except Exception as e:
425
+ traceback.print_exc(file=sys.stderr)
426
+ resp = server._err(msg.get("id"), -32603, str(e))
427
+ if resp is not None:
428
+ sys.stdout.write(json.dumps(resp, ensure_ascii=False) + "\n")
429
+ sys.stdout.flush()
430
+ return 0