mcp-kb-sqlite 0.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_kb_sqlite/__init__.py +0 -0
- mcp_kb_sqlite/db/__init__.py +38 -0
- mcp_kb_sqlite/db/migrations.py +76 -0
- mcp_kb_sqlite/db/queries.py +220 -0
- mcp_kb_sqlite/server.py +197 -0
- mcp_kb_sqlite-0.1.0.dist-info/METADATA +147 -0
- mcp_kb_sqlite-0.1.0.dist-info/RECORD +10 -0
- mcp_kb_sqlite-0.1.0.dist-info/WHEEL +4 -0
- mcp_kb_sqlite-0.1.0.dist-info/entry_points.txt +2 -0
- mcp_kb_sqlite-0.1.0.dist-info/licenses/LICENSE +21 -0
|
File without changes
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sqlite3
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from mcp_kb_sqlite.db.migrations import run_migrations
|
|
7
|
+
|
|
8
|
+
_db_path: Path | None = None
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_db_path() -> Path:
|
|
12
|
+
global _db_path
|
|
13
|
+
if _db_path is None:
|
|
14
|
+
raw = os.environ.get("DB_PATH")
|
|
15
|
+
if raw:
|
|
16
|
+
_db_path = Path(raw)
|
|
17
|
+
else:
|
|
18
|
+
_db_path = Path.home() / ".ai-memory" / "kb.db"
|
|
19
|
+
os.makedirs(_db_path.parent, exist_ok=True)
|
|
20
|
+
return _db_path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@contextmanager
|
|
24
|
+
def get_conn():
|
|
25
|
+
conn = sqlite3.connect(get_db_path(), timeout=30)
|
|
26
|
+
conn.row_factory = sqlite3.Row
|
|
27
|
+
conn.execute("PRAGMA journal_mode = WAL")
|
|
28
|
+
conn.execute("PRAGMA foreign_keys = ON")
|
|
29
|
+
try:
|
|
30
|
+
yield conn
|
|
31
|
+
conn.commit()
|
|
32
|
+
finally:
|
|
33
|
+
conn.close()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def init_db() -> None:
|
|
37
|
+
with get_conn() as conn:
|
|
38
|
+
run_migrations(conn)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
def _migrate_v0(conn) -> None:
|
|
2
|
+
"""Baseline schema — single entries table with FTS on title/description/tags."""
|
|
3
|
+
conn.executescript("""
|
|
4
|
+
CREATE TABLE IF NOT EXISTS db_meta (
|
|
5
|
+
key TEXT PRIMARY KEY,
|
|
6
|
+
value TEXT NOT NULL
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
CREATE TABLE IF NOT EXISTS entries (
|
|
10
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
11
|
+
ns TEXT NOT NULL,
|
|
12
|
+
key TEXT NOT NULL,
|
|
13
|
+
title TEXT NOT NULL,
|
|
14
|
+
description TEXT,
|
|
15
|
+
tags TEXT,
|
|
16
|
+
data TEXT,
|
|
17
|
+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
18
|
+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
19
|
+
UNIQUE(ns, key)
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS entries_fts
|
|
23
|
+
USING fts5(title, description, tags, content='entries', content_rowid='id');
|
|
24
|
+
|
|
25
|
+
CREATE TRIGGER IF NOT EXISTS entries_ai AFTER INSERT ON entries BEGIN
|
|
26
|
+
INSERT INTO entries_fts(rowid, title, description, tags)
|
|
27
|
+
VALUES (new.id, new.title, new.description, new.tags);
|
|
28
|
+
END;
|
|
29
|
+
|
|
30
|
+
CREATE TRIGGER IF NOT EXISTS entries_ad AFTER DELETE ON entries BEGIN
|
|
31
|
+
INSERT INTO entries_fts(entries_fts, rowid, title, description, tags)
|
|
32
|
+
VALUES ('delete', old.id, old.title, old.description, old.tags);
|
|
33
|
+
END;
|
|
34
|
+
|
|
35
|
+
CREATE TRIGGER IF NOT EXISTS entries_au AFTER UPDATE ON entries BEGIN
|
|
36
|
+
INSERT INTO entries_fts(entries_fts, rowid, title, description, tags)
|
|
37
|
+
VALUES ('delete', old.id, old.title, old.description, old.tags);
|
|
38
|
+
INSERT INTO entries_fts(rowid, title, description, tags)
|
|
39
|
+
VALUES (new.id, new.title, new.description, new.tags);
|
|
40
|
+
UPDATE entries SET updated_at = CURRENT_TIMESTAMP WHERE id = new.id;
|
|
41
|
+
END;
|
|
42
|
+
|
|
43
|
+
CREATE TABLE IF NOT EXISTS relations (
|
|
44
|
+
from_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
|
45
|
+
to_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
|
46
|
+
rel TEXT NOT NULL,
|
|
47
|
+
PRIMARY KEY (from_id, to_id, rel)
|
|
48
|
+
);
|
|
49
|
+
""")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
MIGRATIONS = [_migrate_v0]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _get_schema_version(conn) -> int:
|
|
56
|
+
import sqlite3
|
|
57
|
+
try:
|
|
58
|
+
row = conn.execute("SELECT value FROM db_meta WHERE key='schema_version'").fetchone()
|
|
59
|
+
return int(row["value"]) if row else 0
|
|
60
|
+
except sqlite3.OperationalError:
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _set_schema_version(conn, version: int) -> None:
|
|
65
|
+
conn.execute(
|
|
66
|
+
"INSERT OR REPLACE INTO db_meta(key, value) VALUES ('schema_version', ?)",
|
|
67
|
+
(str(version),),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def run_migrations(conn) -> None:
|
|
72
|
+
version = _get_schema_version(conn)
|
|
73
|
+
for i, fn in enumerate(MIGRATIONS, start=1):
|
|
74
|
+
if i > version:
|
|
75
|
+
fn(conn)
|
|
76
|
+
_set_schema_version(conn, i)
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sqlite3
|
|
3
|
+
|
|
4
|
+
from mcp_kb_sqlite.db import get_conn
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class EntryNotFound(Exception):
|
|
8
|
+
def __init__(self, id: int):
|
|
9
|
+
self.id = id
|
|
10
|
+
super().__init__(f"id={id}")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EntryClash(Exception):
|
|
14
|
+
def __init__(self, ns: str, key: str, id: int):
|
|
15
|
+
self.ns = ns
|
|
16
|
+
self.key = key
|
|
17
|
+
self.id = id
|
|
18
|
+
super().__init__(f"{ns}/{key} already exists (id={id})")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _ns_params(ns: str) -> list:
|
|
22
|
+
return [ns, ns + "/%"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _ns_filter_sql(ns: str | None) -> tuple[str, list]:
|
|
26
|
+
"""SQL condition (without leading AND/WHERE) plus its params for an ns-prefix filter.
|
|
27
|
+
Empty string/params when ns is None — callers splice it into their own WHERE clause."""
|
|
28
|
+
if not ns:
|
|
29
|
+
return "", []
|
|
30
|
+
return "(e.ns = ? OR e.ns LIKE ?)", _ns_params(ns)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _clash(conn, ns: str, key: str):
|
|
34
|
+
return conn.execute(
|
|
35
|
+
"SELECT id FROM entries WHERE ns = ? AND key = ?", (ns, key)
|
|
36
|
+
).fetchone()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def create_entry(ns, key, title, description, tags, data) -> dict:
|
|
40
|
+
"""Insert a new entry. Raises ValueError if ns/key/title missing, EntryClash if the
|
|
41
|
+
(ns, key) pair is taken. Returns {'id', 'ns', 'key'}."""
|
|
42
|
+
if not ns or not key or not title:
|
|
43
|
+
raise ValueError("create requires ns, key, and title (or pass id to update)")
|
|
44
|
+
with get_conn() as conn:
|
|
45
|
+
clash = _clash(conn, ns, key)
|
|
46
|
+
if clash:
|
|
47
|
+
raise EntryClash(ns, key, clash["id"])
|
|
48
|
+
cur = conn.execute(
|
|
49
|
+
"""
|
|
50
|
+
INSERT INTO entries(ns, key, title, description, tags, data)
|
|
51
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
52
|
+
RETURNING id
|
|
53
|
+
""",
|
|
54
|
+
(ns, key, title, description, json.dumps(tags) if tags else None, data),
|
|
55
|
+
)
|
|
56
|
+
id_ = cur.fetchone()["id"]
|
|
57
|
+
return {"id": id_, "ns": ns, "key": key}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _update_sets(ns, key, title, description, tags, data) -> tuple[list[str], list]:
|
|
61
|
+
"""Column assignments for the fields that were actually passed.
|
|
62
|
+
|
|
63
|
+
A field left as None is omitted entirely; "" (or [] for tags) clears it.
|
|
64
|
+
Column names come from these literals only — never from caller input.
|
|
65
|
+
"""
|
|
66
|
+
sets: list[str] = []
|
|
67
|
+
params: list = []
|
|
68
|
+
for col, provided, val in (
|
|
69
|
+
("ns", ns is not None, ns),
|
|
70
|
+
("key", key is not None, key),
|
|
71
|
+
("title", title is not None, title),
|
|
72
|
+
("description", description is not None, description or None),
|
|
73
|
+
("tags", tags is not None, json.dumps(tags) if tags else None),
|
|
74
|
+
("data", data is not None, data or None),
|
|
75
|
+
):
|
|
76
|
+
if provided:
|
|
77
|
+
sets.append(f"{col} = ?")
|
|
78
|
+
params.append(val)
|
|
79
|
+
return sets, params
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def update_entry(id, ns, key, title, description, tags, data) -> dict:
|
|
83
|
+
"""Patch-update an entry. Raises ValueError for invalid input, EntryNotFound if id
|
|
84
|
+
doesn't exist, EntryClash if renaming (ns, key) collides with another entry.
|
|
85
|
+
Returns {'id', 'ns', 'key', 'changed': [col, ...]}."""
|
|
86
|
+
if "" in (ns, key, title):
|
|
87
|
+
raise ValueError("ns, key, and title cannot be cleared")
|
|
88
|
+
sets, params = _update_sets(ns, key, title, description, tags, data)
|
|
89
|
+
if not sets:
|
|
90
|
+
raise ValueError("nothing to update — pass at least one field alongside id")
|
|
91
|
+
with get_conn() as conn:
|
|
92
|
+
row = conn.execute(
|
|
93
|
+
"SELECT id, ns, key FROM entries WHERE id = ?", (id,)
|
|
94
|
+
).fetchone()
|
|
95
|
+
if not row:
|
|
96
|
+
raise EntryNotFound(id)
|
|
97
|
+
new_ns = ns if ns is not None else row["ns"]
|
|
98
|
+
new_key = key if key is not None else row["key"]
|
|
99
|
+
if (new_ns, new_key) != (row["ns"], row["key"]):
|
|
100
|
+
clash = _clash(conn, new_ns, new_key)
|
|
101
|
+
if clash:
|
|
102
|
+
raise EntryClash(new_ns, new_key, clash["id"])
|
|
103
|
+
# SET clause interpolates only the literal column names from _update_sets(); every
|
|
104
|
+
# caller-supplied value is bound as a parameter. updated_at is set by the
|
|
105
|
+
# entries_au trigger, not here.
|
|
106
|
+
sql = f"UPDATE entries SET {', '.join(sets)} WHERE id = ?"
|
|
107
|
+
conn.execute(sql, [*params, id])
|
|
108
|
+
changed = [s.split(" = ")[0] for s in sets]
|
|
109
|
+
return {"id": id, "ns": new_ns, "key": new_key, "changed": changed}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def get_entry(id: int) -> sqlite3.Row | None:
|
|
113
|
+
with get_conn() as conn:
|
|
114
|
+
return conn.execute(
|
|
115
|
+
"SELECT id, ns, key, title, description, tags, data, created_at, updated_at "
|
|
116
|
+
"FROM entries WHERE id = ?",
|
|
117
|
+
(id,),
|
|
118
|
+
).fetchone()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def search_entries(query: str, ns: str | None, limit: int, offset: int) -> list[sqlite3.Row]:
|
|
122
|
+
ns_sql, ns_params = _ns_filter_sql(ns)
|
|
123
|
+
with get_conn() as conn:
|
|
124
|
+
# ns_sql is a fixed literal from _ns_filter_sql(), never caller input; the ns
|
|
125
|
+
# value itself is bound as a parameter via ns_params.
|
|
126
|
+
return conn.execute(
|
|
127
|
+
f"""
|
|
128
|
+
SELECT e.id, e.ns, e.key, e.title, e.updated_at,
|
|
129
|
+
snippet(entries_fts, 1, '**', '**', '...', 20) AS snip
|
|
130
|
+
FROM entries_fts
|
|
131
|
+
JOIN entries e ON e.id = entries_fts.rowid
|
|
132
|
+
WHERE entries_fts MATCH ?
|
|
133
|
+
{"AND " + ns_sql if ns_sql else ""}
|
|
134
|
+
ORDER BY bm25(entries_fts) ASC
|
|
135
|
+
LIMIT ? OFFSET ?
|
|
136
|
+
""",
|
|
137
|
+
[query, *ns_params, limit, offset],
|
|
138
|
+
).fetchall()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def list_entries(ns: str | None, limit: int, offset: int) -> tuple[list[sqlite3.Row], int]:
|
|
142
|
+
ns_sql, ns_params = _ns_filter_sql(ns)
|
|
143
|
+
where = f"WHERE {ns_sql}" if ns_sql else ""
|
|
144
|
+
with get_conn() as conn:
|
|
145
|
+
total = conn.execute(
|
|
146
|
+
f"SELECT COUNT(*) FROM entries e {where}", ns_params
|
|
147
|
+
).fetchone()[0]
|
|
148
|
+
rows = conn.execute(
|
|
149
|
+
f"""
|
|
150
|
+
SELECT e.id, e.ns, e.key, e.title, e.updated_at
|
|
151
|
+
FROM entries e
|
|
152
|
+
{where}
|
|
153
|
+
ORDER BY e.updated_at DESC
|
|
154
|
+
LIMIT ? OFFSET ?
|
|
155
|
+
""",
|
|
156
|
+
[*ns_params, limit, offset],
|
|
157
|
+
).fetchall()
|
|
158
|
+
return rows, total
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def list_namespaces() -> list[sqlite3.Row]:
|
|
162
|
+
with get_conn() as conn:
|
|
163
|
+
return conn.execute(
|
|
164
|
+
"""
|
|
165
|
+
SELECT ns, COUNT(*) AS cnt, MAX(updated_at) AS last_updated
|
|
166
|
+
FROM entries
|
|
167
|
+
GROUP BY ns
|
|
168
|
+
ORDER BY ns
|
|
169
|
+
"""
|
|
170
|
+
).fetchall()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def add_relation(from_id: int, to_id: int, rel: str) -> None:
|
|
174
|
+
"""Raises EntryNotFound (with the first missing id) if either endpoint doesn't exist."""
|
|
175
|
+
with get_conn() as conn:
|
|
176
|
+
ids = {
|
|
177
|
+
r["id"]
|
|
178
|
+
for r in conn.execute(
|
|
179
|
+
"SELECT id FROM entries WHERE id IN (?, ?)", (from_id, to_id)
|
|
180
|
+
).fetchall()
|
|
181
|
+
}
|
|
182
|
+
missing = {from_id, to_id} - ids
|
|
183
|
+
if missing:
|
|
184
|
+
raise EntryNotFound(min(missing))
|
|
185
|
+
conn.execute(
|
|
186
|
+
"INSERT OR IGNORE INTO relations(from_id, to_id, rel) VALUES (?, ?, ?)",
|
|
187
|
+
(from_id, to_id, rel),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def remove_relation(from_id: int, to_id: int) -> int:
|
|
192
|
+
"""Returns the number of relation rows removed (both directions)."""
|
|
193
|
+
with get_conn() as conn:
|
|
194
|
+
cur = conn.execute(
|
|
195
|
+
"DELETE FROM relations WHERE (from_id=? AND to_id=?) OR (from_id=? AND to_id=?)",
|
|
196
|
+
(from_id, to_id, to_id, from_id),
|
|
197
|
+
)
|
|
198
|
+
return cur.rowcount
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def get_relations(id: int) -> list[sqlite3.Row]:
|
|
202
|
+
with get_conn() as conn:
|
|
203
|
+
return conn.execute(
|
|
204
|
+
"""
|
|
205
|
+
SELECT r.rel, r.from_id, r.to_id,
|
|
206
|
+
e.ns, e.key, e.title, e.updated_at,
|
|
207
|
+
CASE WHEN r.from_id = ? THEN 'outgoing' ELSE 'incoming' END AS direction
|
|
208
|
+
FROM relations r
|
|
209
|
+
JOIN entries e ON e.id = CASE WHEN r.from_id = ? THEN r.to_id ELSE r.from_id END
|
|
210
|
+
WHERE r.from_id = ? OR r.to_id = ?
|
|
211
|
+
ORDER BY r.rel, e.ns, e.key
|
|
212
|
+
""",
|
|
213
|
+
(id, id, id, id),
|
|
214
|
+
).fetchall()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def delete_entry(id: int) -> bool:
|
|
218
|
+
with get_conn() as conn:
|
|
219
|
+
cur = conn.execute("DELETE FROM entries WHERE id = ?", (id,))
|
|
220
|
+
return bool(cur.rowcount)
|
mcp_kb_sqlite/server.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from mcp.server.mcpserver import MCPServer
|
|
4
|
+
from mcp.server.mcpserver.utilities.func_metadata import ArgModelBase
|
|
5
|
+
from pydantic import ConfigDict
|
|
6
|
+
|
|
7
|
+
from mcp_kb_sqlite.db import init_db, queries
|
|
8
|
+
|
|
9
|
+
# Tool argument models otherwise silently drop unknown fields (pydantic's default
|
|
10
|
+
# extra="ignore"), so a misnamed param (e.g. "payload" instead of "data") looks
|
|
11
|
+
# like a successful call instead of erroring. Forbid extras for every @mcp.tool()
|
|
12
|
+
# defined below — must run before those decorators execute.
|
|
13
|
+
ArgModelBase.model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid")
|
|
14
|
+
|
|
15
|
+
mcp = MCPServer("mcp-kb-sqlite", instructions="Project knowledge base — search here before querying external sources; store cross-service architecture facts, investigation findings, and patterns worth preserving across sessions. Tools: search, save, get, list, relate, delete.")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _fmt_date(ts: str | None) -> str:
|
|
19
|
+
return ts[:10] if ts else "?"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@mcp.tool()
|
|
23
|
+
def save(
|
|
24
|
+
id: int | None = None,
|
|
25
|
+
ns: str | None = None,
|
|
26
|
+
key: str | None = None,
|
|
27
|
+
title: str | None = None,
|
|
28
|
+
description: str | None = None,
|
|
29
|
+
tags: list[str] | None = None,
|
|
30
|
+
data: str | None = None,
|
|
31
|
+
) -> str:
|
|
32
|
+
"""Create or update a KB entry. Two modes:
|
|
33
|
+
UPDATE — pass id plus only the fields you want to change. Omitted fields keep their current
|
|
34
|
+
value; pass "" (or [] for tags) to clear description/tags/data. ns/key/title can be changed
|
|
35
|
+
but not cleared. Errors if no entry has that id.
|
|
36
|
+
CREATE — omit id and pass ns, key, and title (all three required). Errors if ns+key is taken;
|
|
37
|
+
the error reports the existing id so you can re-issue it as an update.
|
|
38
|
+
ns = namespace like 'project/subsystem', key = unique slug within ns.
|
|
39
|
+
title: short label (FTS-indexed). description: search-hint field — write the keywords/synonyms
|
|
40
|
+
a user would actually query here, not just a prose summary (FTS-indexed).
|
|
41
|
+
tags: 3-5 keywords (FTS-indexed). data: large payload, NOT FTS-indexed — anything you want
|
|
42
|
+
findable must appear in title, description, or tags instead. Retrieved via get()."""
|
|
43
|
+
try:
|
|
44
|
+
if id is None:
|
|
45
|
+
result = queries.create_entry(ns, key, title, description, tags, data)
|
|
46
|
+
return f"Created: {result['ns']}/{result['key']} (id={result['id']})"
|
|
47
|
+
result = queries.update_entry(id, ns, key, title, description, tags, data)
|
|
48
|
+
changed = ", ".join(result["changed"])
|
|
49
|
+
return f"Updated: {result['ns']}/{result['key']} (id={result['id']}) — fields: {changed}"
|
|
50
|
+
except ValueError as e:
|
|
51
|
+
return f"Error: {e}"
|
|
52
|
+
except queries.EntryNotFound as e:
|
|
53
|
+
return f"Not found: id={e.id}"
|
|
54
|
+
except queries.EntryClash as e:
|
|
55
|
+
return f"Error: {e.ns}/{e.key} already exists (id={e.id}) — pass id={e.id} to update it"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@mcp.tool()
|
|
59
|
+
def get(id: int, include_data: bool = False) -> str:
|
|
60
|
+
"""Fetch a KB entry by id. Returns title, description, tags, and dates by default.
|
|
61
|
+
Pass include_data=True to also retrieve the data payload."""
|
|
62
|
+
row = queries.get_entry(id)
|
|
63
|
+
if not row:
|
|
64
|
+
return f"Not found: id={id}"
|
|
65
|
+
tags = json.loads(row["tags"]) if row["tags"] else []
|
|
66
|
+
tags_str = f" tags={tags}" if tags else ""
|
|
67
|
+
desc_str = f"\n{row['description']}" if row["description"] else ""
|
|
68
|
+
header = (
|
|
69
|
+
f"id={row['id']} | {row['ns']}/{row['key']}{tags_str}"
|
|
70
|
+
f" created: {_fmt_date(row['created_at'])} updated: {_fmt_date(row['updated_at'])}\n"
|
|
71
|
+
f"{row['title']}{desc_str}"
|
|
72
|
+
)
|
|
73
|
+
if include_data and row["data"]:
|
|
74
|
+
return f"{header}\n\n{row['data']}"
|
|
75
|
+
return header
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@mcp.tool()
|
|
79
|
+
def search(
|
|
80
|
+
query: str,
|
|
81
|
+
ns: str | None = None,
|
|
82
|
+
limit: int = 10,
|
|
83
|
+
offset: int = 0,
|
|
84
|
+
) -> str:
|
|
85
|
+
"""Full-text search over entries using FTS5/BM25. Searches title, description, and tags.
|
|
86
|
+
Optionally filter by ns prefix. FTS5 operators AND, OR, NOT work as-is. Wrap query in double quotes for strict phrase search — special characters (dots, hyphens, etc.) become literals inside quotes, e.g. "127.0.0.1"."""
|
|
87
|
+
limit = min(limit, 100)
|
|
88
|
+
rows = queries.search_entries(query, ns, limit, offset)
|
|
89
|
+
|
|
90
|
+
if not rows:
|
|
91
|
+
return "No results."
|
|
92
|
+
|
|
93
|
+
lines = []
|
|
94
|
+
for r in rows:
|
|
95
|
+
lines.append(f"id={r['id']} | {r['ns']}/{r['key']} updated: {_fmt_date(r['updated_at'])}")
|
|
96
|
+
lines.append(f" {r['title']}")
|
|
97
|
+
if r["snip"]:
|
|
98
|
+
lines.append(f" {r['snip']}")
|
|
99
|
+
return "\n".join(lines)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@mcp.tool()
|
|
103
|
+
def list(
|
|
104
|
+
ns: str | None = None,
|
|
105
|
+
limit: int = 20,
|
|
106
|
+
offset: int = 0,
|
|
107
|
+
) -> str:
|
|
108
|
+
"""List entries (id, ns/key, title, updated_at). Optionally filter by ns prefix."""
|
|
109
|
+
limit = min(limit, 100)
|
|
110
|
+
rows, total = queries.list_entries(ns, limit, offset)
|
|
111
|
+
|
|
112
|
+
if not rows:
|
|
113
|
+
return "No entries found."
|
|
114
|
+
|
|
115
|
+
lines = [
|
|
116
|
+
f"id={r['id']} | {r['ns']}/{r['key']} | {r['title']} | {_fmt_date(r['updated_at'])}"
|
|
117
|
+
for r in rows
|
|
118
|
+
]
|
|
119
|
+
shown_end = offset + len(rows)
|
|
120
|
+
if total > shown_end:
|
|
121
|
+
lines.append(f"({shown_end} of {total} — use offset={shown_end} for more)")
|
|
122
|
+
else:
|
|
123
|
+
lines.append(f"({total} total)")
|
|
124
|
+
return "\n".join(lines)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@mcp.tool()
|
|
128
|
+
def list_namespaces() -> str:
|
|
129
|
+
"""List all namespaces with entry counts and last-updated date.
|
|
130
|
+
Use this first to discover available projects/topics before calling search or list."""
|
|
131
|
+
rows = queries.list_namespaces()
|
|
132
|
+
|
|
133
|
+
if not rows:
|
|
134
|
+
return "No namespaces found."
|
|
135
|
+
|
|
136
|
+
lines = []
|
|
137
|
+
prev_top = None
|
|
138
|
+
for r in rows:
|
|
139
|
+
top = r["ns"].split("/")[0]
|
|
140
|
+
if top != prev_top:
|
|
141
|
+
if prev_top is not None:
|
|
142
|
+
lines.append("")
|
|
143
|
+
prev_top = top
|
|
144
|
+
lines.append(f"{r['ns']} ({r['cnt']}) {_fmt_date(r['last_updated'])}")
|
|
145
|
+
return "\n".join(lines)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@mcp.tool()
|
|
149
|
+
def relate(from_id: int, to_id: int, rel: str | None = "see_also") -> str:
|
|
150
|
+
"""Create or remove a typed relation between two entries.
|
|
151
|
+
rel given or omitted (default: see_also) → add relation (INSERT OR IGNORE). Not validated —
|
|
152
|
+
pick freely, but prefer these for consistency: see_also, part_of, caused_by, example_of.
|
|
153
|
+
rel explicitly null → delete all relations between the pair (both directions)."""
|
|
154
|
+
if rel is not None:
|
|
155
|
+
try:
|
|
156
|
+
queries.add_relation(from_id, to_id, rel)
|
|
157
|
+
except queries.EntryNotFound as e:
|
|
158
|
+
return f"Not found: id={e.id}"
|
|
159
|
+
return f"Related {from_id} --[{rel}]--> {to_id}"
|
|
160
|
+
removed = queries.remove_relation(from_id, to_id)
|
|
161
|
+
return f"Unrelated {from_id} <--> {to_id} ({removed} removed)"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@mcp.tool()
|
|
165
|
+
def get_relations(id: int) -> str:
|
|
166
|
+
"""Get all entries related to a given entry id (both directions)."""
|
|
167
|
+
rows = queries.get_relations(id)
|
|
168
|
+
|
|
169
|
+
if not rows:
|
|
170
|
+
return f"No relations found for id={id}"
|
|
171
|
+
|
|
172
|
+
lines = [f"Relations for id={id}:"]
|
|
173
|
+
for r in rows:
|
|
174
|
+
arrow = "-->" if r["direction"] == "outgoing" else "<--"
|
|
175
|
+
other_id = r["to_id"] if r["direction"] == "outgoing" else r["from_id"]
|
|
176
|
+
lines.append(
|
|
177
|
+
f" [{r['rel']}] {arrow} id={other_id} | {r['ns']}/{r['key']} — {r['title']}"
|
|
178
|
+
f" (updated: {_fmt_date(r['updated_at'])})"
|
|
179
|
+
)
|
|
180
|
+
return "\n".join(lines)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@mcp.tool()
|
|
184
|
+
def delete(id: int) -> str:
|
|
185
|
+
"""Delete a KB entry by id."""
|
|
186
|
+
if queries.delete_entry(id):
|
|
187
|
+
return f"Deleted id={id}"
|
|
188
|
+
return f"Not found: id={id}"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def main() -> None:
|
|
192
|
+
init_db()
|
|
193
|
+
mcp.run(transport="stdio")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
if __name__ == "__main__":
|
|
197
|
+
main()
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mcp-kb-sqlite
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local long-term memory MCP server backed by SQLite FTS5
|
|
5
|
+
Project-URL: Homepage, https://github.com/eukos/mcp-kb-sqlite
|
|
6
|
+
Author-email: Eugene Kosyakov <eukos@yandex.by>
|
|
7
|
+
License: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: ai,claude,fts5,knowledge-base,llm,mcp,memory,opencode,sqlite
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Requires-Dist: mcp[cli]<3,>=2
|
|
13
|
+
Requires-Dist: pydantic>=2
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# mcp-kb-sqlite
|
|
17
|
+
|
|
18
|
+
Simple, fast persistent memory for your coding agent — cross-session, cross-project. One knowledge
|
|
19
|
+
base shared across every repo you work in, so what you learn in one project is there the next time
|
|
20
|
+
you open another. Entries can be linked to each other (`relate`), so related facts stay connected
|
|
21
|
+
instead of scattered.
|
|
22
|
+
|
|
23
|
+
Start a conversation with `/kb-use` to load relevant context before you begin. When you learn
|
|
24
|
+
something worth keeping — an architecture decision, a gotcha, a debugging finding — save it with
|
|
25
|
+
`/kb-update`.
|
|
26
|
+
|
|
27
|
+
Under the hood: a plain SQLite database with an FTS5 full-text index, and a small tool surface for
|
|
28
|
+
agents to search, save, and link entries. No server to run, no external service, no schema to
|
|
29
|
+
manage by hand.
|
|
30
|
+
|
|
31
|
+
## Install — MCP server
|
|
32
|
+
|
|
33
|
+
The database lives at `~/.ai-memory/kb.db` unless you override it with `DB_PATH` (shown below,
|
|
34
|
+
optional). The schema is created and migrated automatically on first connection.
|
|
35
|
+
|
|
36
|
+
### Claude Code
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
claude mcp add kb -- uv run --directory /path/to/mcp-kb-sqlite mcp-kb-sqlite
|
|
40
|
+
|
|
41
|
+
# with a custom DB_PATH:
|
|
42
|
+
claude mcp add kb --env DB_PATH=/path/to/kb.db -- uv run --directory /path/to/mcp-kb-sqlite mcp-kb-sqlite
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Or add it directly to `.mcp.json` (project-local) or `~/.claude.json` (user-scoped, under the
|
|
46
|
+
top-level `mcpServers` key):
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"mcpServers": {
|
|
51
|
+
"kb": {
|
|
52
|
+
"type": "stdio",
|
|
53
|
+
"command": "uv",
|
|
54
|
+
"args": ["run", "--directory", "/path/to/mcp-kb-sqlite", "mcp-kb-sqlite"],
|
|
55
|
+
"env": { "DB_PATH": "/path/to/kb.db" }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`env` is optional — drop it to use the default `~/.ai-memory/kb.db`.
|
|
62
|
+
|
|
63
|
+
### OpenCode
|
|
64
|
+
|
|
65
|
+
Add to `opencode.json` (global at `~/.config/opencode/opencode.json`, or project-local):
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
{
|
|
69
|
+
"mcp": {
|
|
70
|
+
"kb": {
|
|
71
|
+
"type": "local",
|
|
72
|
+
"command": [
|
|
73
|
+
"uv",
|
|
74
|
+
"run",
|
|
75
|
+
"--directory",
|
|
76
|
+
"/path/to/mcp-kb-sqlite",
|
|
77
|
+
"mcp-kb-sqlite"
|
|
78
|
+
],
|
|
79
|
+
"enabled": true,
|
|
80
|
+
"environment": { "DB_PATH": "/path/to/kb.db" }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`environment` is optional — drop it to use the default `~/.ai-memory/kb.db`.
|
|
87
|
+
|
|
88
|
+
## Install — skills
|
|
89
|
+
|
|
90
|
+
The `/kb-use` and `/kb-update` workflows ship as skills for both Claude Code and OpenCode. The two
|
|
91
|
+
flavors are hand-kept in sync — there's no generator, so edit both when instructions change.
|
|
92
|
+
|
|
93
|
+
### Claude Code
|
|
94
|
+
|
|
95
|
+
Copy or symlink `skills/claude/commands/*.md` into `~/.claude/commands/`.
|
|
96
|
+
|
|
97
|
+
### OpenCode
|
|
98
|
+
|
|
99
|
+
Copy or symlink each `skills/opencode/<name>/` directory into `~/.config/opencode/skills/<name>/`
|
|
100
|
+
(or a project-local `.opencode/skills/`). OpenCode skills are auto-invoked by description match via
|
|
101
|
+
the agent's `skill` tool, but can also be triggered directly as `/<name>` in chat — this prepends
|
|
102
|
+
the SKILL.md text and appends whatever you typed after the command.
|
|
103
|
+
|
|
104
|
+
## Tools
|
|
105
|
+
|
|
106
|
+
| Tool | Purpose |
|
|
107
|
+
| ---------------------------------------------------------- | ------------------------------------------------------------- |
|
|
108
|
+
| `save(id?, ns?, key?, title?, description?, tags?, data?)` | Create or update an entry — see below |
|
|
109
|
+
| `get(id, include_data=False)` | Fetch one entry; `include_data=True` returns the payload |
|
|
110
|
+
| `search(query, ns?, limit=10, offset=0)` | FTS5/BM25 over title + description + tags |
|
|
111
|
+
| `list(ns?, limit=20, offset=0)` | Entries by recency, metadata only |
|
|
112
|
+
| `list_namespaces()` | Namespaces with entry counts and last-updated date |
|
|
113
|
+
| `relate(from_id, to_id, rel?)` | Link two entries; omit `rel` to remove all links between them |
|
|
114
|
+
| `get_relations(id)` | Links in both directions |
|
|
115
|
+
| `delete(id)` | Remove an entry (cascades to its relations) |
|
|
116
|
+
|
|
117
|
+
Entries are addressed by `ns` (namespace, e.g. `project/subsystem`) plus `key` (a slug unique within the
|
|
118
|
+
namespace). `ns` filters are prefix matches, so `ns="project"` covers every subsystem under it.
|
|
119
|
+
|
|
120
|
+
Only `title`, `description`, and `tags` are FTS-indexed. `data` is the payload — put schemas, code, configs
|
|
121
|
+
and traces there, and make sure anything you need to _find_ also appears in one of the indexed fields.
|
|
122
|
+
|
|
123
|
+
### `save`: create vs. update
|
|
124
|
+
|
|
125
|
+
Every parameter is optional; the presence of `id` picks the mode.
|
|
126
|
+
|
|
127
|
+
**Update** — pass `id` plus only the fields you want to change:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
save(id=42, description="new search hints") # title, tags, data untouched
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Omitted fields keep their current value. Pass `""` (or `[]` for `tags`) to clear `description`, `tags`, or
|
|
134
|
+
`data`. `ns`, `key`, and `title` can be changed — that's how you rename or move an entry — but not cleared.
|
|
135
|
+
|
|
136
|
+
**Create** — omit `id`; `ns`, `key`, and `title` are all required:
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
save(ns="project/db", key="schema", title="Schema layout", tags=["db","schema"], data="…")
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
There is no upsert. Creating over an existing `(ns, key)` is an error that reports the existing id, so an
|
|
143
|
+
accidental full overwrite isn't possible:
|
|
144
|
+
|
|
145
|
+
```
|
|
146
|
+
Error: project/db/schema already exists (id=42) — pass id=42 to update it
|
|
147
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
mcp_kb_sqlite/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
mcp_kb_sqlite/server.py,sha256=U14GROUj-HegsGpdX0NIOBQFQ7eHlPjubvnNg3-Xhz4,7465
|
|
3
|
+
mcp_kb_sqlite/db/__init__.py,sha256=dyIoPv00gvuZ8MFDwj3L9ozl1-ZMW37Ydy0NnX9Ii9A,864
|
|
4
|
+
mcp_kb_sqlite/db/migrations.py,sha256=c-TnF3s3IPR-D-ix0WjjF9eMEYTNtdOFf7eZZcoL9Vk,2832
|
|
5
|
+
mcp_kb_sqlite/db/queries.py,sha256=Xjcjn50IpLQ21g18GGz7DoLuwXmn312vFKg49zAu_Pc,7967
|
|
6
|
+
mcp_kb_sqlite-0.1.0.dist-info/METADATA,sha256=98tlG-GVdp_DI2iBZsXFx_WI5NfkQ_3uesXfKcgy6oA,5875
|
|
7
|
+
mcp_kb_sqlite-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
8
|
+
mcp_kb_sqlite-0.1.0.dist-info/entry_points.txt,sha256=kXw3Qh3KT2MAHyxhrHK-NqNJ0jUlHupqacrtOv97WvM,60
|
|
9
|
+
mcp_kb_sqlite-0.1.0.dist-info/licenses/LICENSE,sha256=tdNgBtlRWF1KbLtPlYVZuomItZsT1Xrbab__zrlJpbY,1072
|
|
10
|
+
mcp_kb_sqlite-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Eugene Kosyakov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|