sqlite-kb-mcp 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.
@@ -0,0 +1,3 @@
1
+ """mcp-knowledge-base: a Model Context Protocol server backed by SQLite full-text search."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,164 @@
1
+ """Data layer: SQLite + FTS5. Knows nothing about MCP."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sqlite3
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+
10
+
11
+ def _default_db_path() -> Path:
12
+ """User-writable DB path. Override with the KB_DB_PATH env var.
13
+
14
+ Never stored inside the installed package (site-packages may be read-only).
15
+ """
16
+ override = os.environ.get("KB_DB_PATH")
17
+ if override:
18
+ return Path(override)
19
+ if os.name == "nt":
20
+ base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
21
+ else:
22
+ base = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
23
+ data_dir = base / "sqlite-kb-mcp"
24
+ data_dir.mkdir(parents=True, exist_ok=True)
25
+ return data_dir / "knowledge.db"
26
+
27
+
28
+ DB_PATH = _default_db_path()
29
+
30
+
31
+ def _now() -> str:
32
+ """Current UTC time as ISO-8601 text."""
33
+ return datetime.now(timezone.utc).isoformat()
34
+
35
+
36
+ def get_connection(db_path: Path | str = DB_PATH) -> sqlite3.Connection:
37
+ """Open a connection with rows accessible by column name."""
38
+ conn = sqlite3.connect(db_path)
39
+ conn.row_factory = sqlite3.Row # rows behave like dicts
40
+ conn.execute("PRAGMA foreign_keys = ON")
41
+ return conn
42
+
43
+
44
+ def init_db(conn: sqlite3.Connection) -> None:
45
+ """Create the documents table and its FTS5 search index."""
46
+ conn.executescript(
47
+ """
48
+ CREATE TABLE IF NOT EXISTS documents (
49
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
50
+ title TEXT NOT NULL,
51
+ content TEXT NOT NULL,
52
+ tags TEXT NOT NULL DEFAULT '',
53
+ created_at TEXT NOT NULL,
54
+ updated_at TEXT NOT NULL
55
+ );
56
+
57
+ -- FTS5 index mirrors the searchable columns of `documents`.
58
+ CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts USING fts5(
59
+ title, content, tags,
60
+ content='documents',
61
+ content_rowid='id'
62
+ );
63
+
64
+ -- Triggers keep the FTS index in sync with the table.
65
+ CREATE TRIGGER IF NOT EXISTS documents_ai AFTER INSERT ON documents BEGIN
66
+ INSERT INTO documents_fts(rowid, title, content, tags)
67
+ VALUES (new.id, new.title, new.content, new.tags);
68
+ END;
69
+
70
+ CREATE TRIGGER IF NOT EXISTS documents_ad AFTER DELETE ON documents BEGIN
71
+ INSERT INTO documents_fts(documents_fts, rowid, title, content, tags)
72
+ VALUES ('delete', old.id, old.title, old.content, old.tags);
73
+ END;
74
+
75
+ CREATE TRIGGER IF NOT EXISTS documents_au AFTER UPDATE ON documents BEGIN
76
+ INSERT INTO documents_fts(documents_fts, rowid, title, content, tags)
77
+ VALUES ('delete', old.id, old.title, old.content, old.tags);
78
+ INSERT INTO documents_fts(rowid, title, content, tags)
79
+ VALUES (new.id, new.title, new.content, new.tags);
80
+ END;
81
+ """
82
+ )
83
+ conn.commit()
84
+
85
+
86
+ def add_document(
87
+ conn: sqlite3.Connection, title: str, content: str, tags: str = ""
88
+ ) -> int:
89
+ """Insert a document and return its new id."""
90
+ now = _now()
91
+ cur = conn.execute(
92
+ "INSERT INTO documents (title, content, tags, created_at, updated_at) "
93
+ "VALUES (?, ?, ?, ?, ?)",
94
+ (title, content, tags, now, now),
95
+ )
96
+ conn.commit()
97
+ return int(cur.lastrowid)
98
+
99
+
100
+ def get_document(conn: sqlite3.Connection, doc_id: int) -> dict | None:
101
+ """Fetch one document by id, or None if missing."""
102
+ row = conn.execute(
103
+ "SELECT * FROM documents WHERE id = ?", (doc_id,)
104
+ ).fetchone()
105
+ return dict(row) if row else None
106
+
107
+
108
+ def list_documents(conn: sqlite3.Connection) -> list[dict]:
109
+ """Return all documents, newest first."""
110
+ rows = conn.execute(
111
+ "SELECT * FROM documents ORDER BY created_at DESC, id DESC"
112
+ ).fetchall()
113
+ return [dict(r) for r in rows]
114
+
115
+
116
+ def search_documents(
117
+ conn: sqlite3.Connection, query: str, limit: int = 10
118
+ ) -> list[dict]:
119
+ """Full-text search, ranked by relevance (BM25)."""
120
+ rows = conn.execute(
121
+ """
122
+ SELECT d.*, bm25(documents_fts) AS score
123
+ FROM documents_fts
124
+ JOIN documents d ON d.id = documents_fts.rowid
125
+ WHERE documents_fts MATCH ?
126
+ ORDER BY score
127
+ LIMIT ?
128
+ """,
129
+ (query, limit),
130
+ ).fetchall()
131
+ return [dict(r) for r in rows]
132
+
133
+
134
+ def update_document(
135
+ conn: sqlite3.Connection,
136
+ doc_id: int,
137
+ title: str | None = None,
138
+ content: str | None = None,
139
+ tags: str | None = None,
140
+ ) -> bool:
141
+ """Update provided fields of a document. Returns True if it existed."""
142
+ existing = get_document(conn, doc_id)
143
+ if existing is None:
144
+ return False
145
+ conn.execute(
146
+ "UPDATE documents SET title = ?, content = ?, tags = ?, updated_at = ? "
147
+ "WHERE id = ?",
148
+ (
149
+ title if title is not None else existing["title"],
150
+ content if content is not None else existing["content"],
151
+ tags if tags is not None else existing["tags"],
152
+ _now(),
153
+ doc_id,
154
+ ),
155
+ )
156
+ conn.commit()
157
+ return True
158
+
159
+
160
+ def delete_document(conn: sqlite3.Connection, doc_id: int) -> bool:
161
+ """Delete a document. Returns True if a row was removed."""
162
+ cur = conn.execute("DELETE FROM documents WHERE id = ?", (doc_id,))
163
+ conn.commit()
164
+ return cur.rowcount > 0
@@ -0,0 +1,144 @@
1
+ """MCP server: thin tool layer over the db.py knowledge base."""
2
+
3
+ import json
4
+ from typing import Annotated, Optional
5
+
6
+ from mcp.server.fastmcp import FastMCP
7
+ from pydantic import Field
8
+
9
+ from mcp_knowledge_base import db
10
+
11
+ mcp = FastMCP("knowledge-base")
12
+
13
+ # create tables once on load (idempotent)
14
+ with db.get_connection() as _conn:
15
+ db.init_db(_conn)
16
+
17
+
18
+ @mcp.tool()
19
+ def add_document(
20
+ title: Annotated[str, Field(min_length=1, description="Document title")],
21
+ content: Annotated[str, Field(min_length=1, description="Document body")],
22
+ tags: Annotated[str, Field(description="Comma-separated tags")] = "",
23
+ ) -> dict:
24
+ """Add a document to the knowledge base. Returns the new document."""
25
+ with db.get_connection() as conn:
26
+ doc_id = db.add_document(conn, title, content, tags)
27
+ return db.get_document(conn, doc_id)
28
+
29
+
30
+ @mcp.tool()
31
+ def search_documents(
32
+ query: Annotated[str, Field(min_length=1, description="Full-text search query")],
33
+ limit: Annotated[int, Field(ge=1, le=50, description="Max results")] = 10,
34
+ ) -> list[dict] | str:
35
+ """Full-text search the knowledge base, ranked by relevance."""
36
+ with db.get_connection() as conn:
37
+ results = db.search_documents(conn, query, limit)
38
+ if not results:
39
+ return f"No documents matched '{query}'."
40
+ return results
41
+
42
+
43
+ @mcp.tool()
44
+ def get_document(
45
+ doc_id: Annotated[int, Field(ge=1, description="Document id")],
46
+ ) -> dict | str:
47
+ """Fetch a single document by its id."""
48
+ with db.get_connection() as conn:
49
+ doc = db.get_document(conn, doc_id)
50
+ return doc if doc else f"No document found with id {doc_id}."
51
+
52
+
53
+ @mcp.tool()
54
+ def list_documents() -> list[dict] | str:
55
+ """List all documents, newest first."""
56
+ with db.get_connection() as conn:
57
+ docs = db.list_documents(conn)
58
+ return docs if docs else "The knowledge base is empty."
59
+
60
+
61
+ @mcp.tool()
62
+ def update_document(
63
+ doc_id: Annotated[int, Field(ge=1, description="Document id to update")],
64
+ title: Annotated[str | None, Field(min_length=1)] = None,
65
+ content: Annotated[str | None, Field(min_length=1)] = None,
66
+ tags: str | None = None,
67
+ ) -> dict | str:
68
+ """Update a document's fields. Only provided fields change."""
69
+ with db.get_connection() as conn:
70
+ ok = db.update_document(conn, doc_id, title, content, tags)
71
+ if not ok:
72
+ return f"No document found with id {doc_id}."
73
+ return db.get_document(conn, doc_id)
74
+
75
+
76
+ @mcp.tool()
77
+ def delete_document(
78
+ doc_id: Annotated[int, Field(ge=1, description="Document id to delete")],
79
+ ) -> str:
80
+ """Delete a document by its id."""
81
+ with db.get_connection() as conn:
82
+ ok = db.delete_document(conn, doc_id)
83
+ return f"Deleted document {doc_id}." if ok else f"No document found with id {doc_id}."
84
+
85
+
86
+ # --- Resources: read-only data addressed by URI (app/user-controlled) ---
87
+
88
+
89
+ @mcp.resource("kb://documents")
90
+ def all_documents() -> str:
91
+ """All documents in the knowledge base, as JSON."""
92
+ with db.get_connection() as conn:
93
+ docs = db.list_documents(conn)
94
+ return json.dumps(docs, indent=2)
95
+
96
+
97
+ @mcp.resource("kb://documents/{doc_id}")
98
+ def one_document(doc_id: str) -> str:
99
+ """A single document by id, as JSON. (URI params arrive as strings.)"""
100
+ with db.get_connection() as conn:
101
+ doc = db.get_document(conn, int(doc_id))
102
+ if doc is None:
103
+ return json.dumps({"error": f"No document found with id {doc_id}"})
104
+ return json.dumps(doc, indent=2)
105
+
106
+
107
+ # --- Prompts: reusable instruction templates (user-controlled) ---
108
+
109
+
110
+ @mcp.prompt()
111
+ def summarize_search(
112
+ query: Annotated[str, Field(min_length=1, description="Topic to search for")],
113
+ limit: Annotated[int, Field(ge=1, le=20, description="How many results")] = 5,
114
+ ) -> str:
115
+ """Search the KB and ask the model to summarize the results (data embedded)."""
116
+ with db.get_connection() as conn:
117
+ results = db.search_documents(conn, query, limit)
118
+ if not results:
119
+ return f"No documents matched '{query}'. Tell the user nothing was found."
120
+ body = json.dumps(results, indent=2)
121
+ return (
122
+ f"Summarize these knowledge base results for '{query}'. "
123
+ f"Group related points and note any gaps.\n\n{body}"
124
+ )
125
+
126
+
127
+ @mcp.prompt()
128
+ def find_related(
129
+ topic: Annotated[str, Field(min_length=1, description="Subject to explore")],
130
+ ) -> str:
131
+ """Instruct the model to find related documents using the search tool."""
132
+ return (
133
+ f"Use the search_documents tool to find documents related to '{topic}'. "
134
+ "Then explain how they connect and suggest what to read first."
135
+ )
136
+
137
+
138
+ def main() -> None:
139
+ """Console entry point. Runs the server over stdio."""
140
+ mcp.run()
141
+
142
+
143
+ if __name__ == "__main__":
144
+ main()
@@ -0,0 +1,134 @@
1
+ Metadata-Version: 2.4
2
+ Name: sqlite-kb-mcp
3
+ Version: 0.1.0
4
+ Summary: A Model Context Protocol server for a SQLite-backed knowledge base with full-text search.
5
+ Keywords: mcp,model-context-protocol,sqlite,fts5,knowledge-base,llm
6
+ Author: Hammad
7
+ Author-email: Hammad <mhd-Hammad@users.noreply.github.com>
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Software Development :: Libraries
16
+ Requires-Dist: mcp[cli]>=1.0,<2
17
+ Requires-Python: >=3.10
18
+ Project-URL: Homepage, https://github.com/mhd-Hammad/mcp-knowledge-base
19
+ Project-URL: Repository, https://github.com/mhd-Hammad/mcp-knowledge-base
20
+ Project-URL: Issues, https://github.com/mhd-Hammad/mcp-knowledge-base/issues
21
+ Description-Content-Type: text/markdown
22
+
23
+ # mcp-knowledge-base
24
+
25
+ A [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server that gives an
26
+ AI assistant a searchable, persistent knowledge base. Documents are stored in SQLite and
27
+ searched with FTS5 full-text search ranked by relevance.
28
+
29
+ It is a **self-contained** MCP server: it owns its own data and logic rather than wrapping
30
+ an external API. The SQLite database *is* the application; MCP is the interface an AI host
31
+ (Kiro, Claude Desktop, any MCP client) talks to it through.
32
+
33
+ ## Features
34
+
35
+ - **Tools** — add, search, get, list, update, and delete documents.
36
+ - **Resources** — read documents by URI (`kb://documents`, `kb://documents/{id}`).
37
+ - **Prompts** — reusable templates (`summarize_search`, `find_related`).
38
+ - **Full-text search** — SQLite FTS5 with BM25 relevance ranking, not naive `LIKE`.
39
+ - **Input validation** — Pydantic field constraints surfaced in the generated tool schemas.
40
+
41
+ ## Installation
42
+
43
+ Once published, add the server to your MCP host's config. No clone, no paths:
44
+
45
+ ```json
46
+ {
47
+ "mcpServers": {
48
+ "knowledge-base": {
49
+ "command": "uvx",
50
+ "args": ["sqlite-kb-mcp"]
51
+ }
52
+ }
53
+ }
54
+ ```
55
+
56
+ - **Kiro** — put this in `.kiro/settings/mcp.json` (workspace) or `~/.kiro/settings/mcp.json` (user).
57
+ - **Claude Desktop** — put this in `claude_desktop_config.json`.
58
+
59
+ `uvx` downloads and runs the published package on demand, so users never touch the source.
60
+
61
+ > `autoApprove` is intentionally omitted here: auto-approving tool calls is a personal trust
62
+ > decision. Add it in your own config if you want read-only tools to run without a prompt.
63
+
64
+ ## What it exposes
65
+
66
+ ### Tools
67
+
68
+ | Tool | Description |
69
+ |------|-------------|
70
+ | `add_document(title, content, tags="")` | Add a document. |
71
+ | `search_documents(query, limit=10)` | Full-text search, ranked by relevance. |
72
+ | `get_document(doc_id)` | Fetch one document by id. |
73
+ | `list_documents()` | List all documents, newest first. |
74
+ | `update_document(doc_id, ...)` | Update provided fields. |
75
+ | `delete_document(doc_id)` | Delete a document. |
76
+
77
+ ### Resources
78
+
79
+ | URI | Description |
80
+ |-----|-------------|
81
+ | `kb://documents` | All documents, as JSON. |
82
+ | `kb://documents/{doc_id}` | One document, as JSON. |
83
+
84
+ ### Prompts
85
+
86
+ | Prompt | Description |
87
+ |--------|-------------|
88
+ | `summarize_search(query, limit=5)` | Searches the KB and asks the model to summarize (data embedded). |
89
+ | `find_related(topic)` | Instructs the model to find related documents using the search tool. |
90
+
91
+ ## Architecture
92
+
93
+ ```
94
+ MCP host ──stdio──► server.py ──► db.py ──► knowledge.db
95
+ (Kiro/Claude) (MCP layer) (logic) (SQLite + FTS5)
96
+ ```
97
+
98
+ - **`server.py`** — the MCP interface. Each tool/resource/prompt is a thin wrapper that
99
+ validates input and delegates to `db.py`. No business logic lives here.
100
+ - **`db.py`** — pure data-access logic. Knows nothing about MCP, so it is independently
101
+ testable. Opens a connection per call to avoid SQLite threading issues.
102
+ - **SQLite + FTS5** — a `documents` table plus a `documents_fts` external-content virtual
103
+ table. Triggers keep the search index in sync automatically, so CRUD touches only the
104
+ main table.
105
+
106
+ This separation of concerns means the whole data layer is tested without any MCP machinery.
107
+
108
+ ## Development
109
+
110
+ ```bash
111
+ # clone, then:
112
+ uv sync # install deps (incl. dev group)
113
+ uv run pytest # run the test suite
114
+ uv run mcp dev src/mcp_knowledge_base/server.py # open the MCP Inspector
115
+ ```
116
+
117
+ To run the server directly over stdio:
118
+
119
+ ```bash
120
+ uv run sqlite-kb-mcp
121
+ ```
122
+
123
+ ## Notes and future work
124
+
125
+ - **Transport** — currently stdio (local subprocess). Streamable HTTP would make it
126
+ deployable as a remote service; FastMCP supports it with a small change.
127
+ - **Search** — FTS5 matches whole tokens (`index` != `indexes`). Prefix queries (`index*`)
128
+ work today; a Porter stemmer could be enabled for automatic stem matching.
129
+ - **Not implemented (by design)** — client-side primitives (sampling, elicitation, roots)
130
+ and OAuth authorization, which are relevant mainly to remote/HTTP deployments.
131
+
132
+ ## License
133
+
134
+ MIT
@@ -0,0 +1,7 @@
1
+ mcp_knowledge_base/__init__.py,sha256=P9YXMrSkRUr76uLBoZZhV1MM7o-QdZOFF9togKRu-0s,116
2
+ mcp_knowledge_base/db.py,sha256=Sl9_cm-UUt0sS_xO_USvUKCg-rXzDfQj9LfR1M7NnNQ,5327
3
+ mcp_knowledge_base/server.py,sha256=kva3CFmTvlUIlqkkuajrIn2Gj6BzfZ1864J97bNF-ak,4738
4
+ sqlite_kb_mcp-0.1.0.dist-info/WHEEL,sha256=si2p8vW03RnhyBnhWl4tj-ObJa78SpE-c9HsEtblGR0,80
5
+ sqlite_kb_mcp-0.1.0.dist-info/entry_points.txt,sha256=r9o1fIoHPCsVgNF3SE7rC1dQeV5bT-PvR11o70y32Gk,66
6
+ sqlite_kb_mcp-0.1.0.dist-info/METADATA,sha256=_2xW5C_crYqZxt4WU-FijjwJ0rec9dsziqdsFwlubsM,5201
7
+ sqlite_kb_mcp-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.5
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ sqlite-kb-mcp = mcp_knowledge_base.server:main
3
+