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.
- mcp_server_evolutiondb-1.1.0.dist-info/METADATA +229 -0
- mcp_server_evolutiondb-1.1.0.dist-info/RECORD +8 -0
- mcp_server_evolutiondb-1.1.0.dist-info/WHEEL +5 -0
- mcp_server_evolutiondb-1.1.0.dist-info/entry_points.txt +3 -0
- mcp_server_evolutiondb-1.1.0.dist-info/top_level.txt +1 -0
- mcp_server_evosql/__init__.py +2 -0
- mcp_server_evosql/__main__.py +5 -0
- mcp_server_evosql/server.py +430 -0
|
@@ -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 @@
|
|
|
1
|
+
mcp_server_evosql
|
|
@@ -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
|