willow-mcp 1.0.0__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sean Campbell
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.
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.4
2
+ Name: willow-mcp
3
+ Version: 1.0.0
4
+ Summary: Agent-neutral MCP server — SQLite store, Postgres knowledge base, Kart task queue
5
+ Project-URL: Homepage, https://github.com/rudi193-cmd/willow-mcp
6
+ Author: Sean Campbell
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Keywords: knowledge-base,mcp,openclaw,task-queue,willow
10
+ Requires-Python: >=3.11
11
+ Requires-Dist: mcp>=1.0.0
12
+ Requires-Dist: openclaw-sap-gate>=1.0.0
13
+ Requires-Dist: psycopg2-binary>=2.9
14
+ Description-Content-Type: text/markdown
15
+
16
+ # willow-mcp
17
+
18
+ Agent-neutral MCP server. SQLite key/value store, Postgres knowledge base, Kart task queue. SAP/1.0 authorization on every tool call.
19
+
20
+ ```bash
21
+ pip install willow-mcp
22
+ ```
23
+
24
+ ## Tools
25
+
26
+ | Tool | Description |
27
+ |------|-------------|
28
+ | `store_put` | Write to SQLite store |
29
+ | `store_get` | Read from SQLite store |
30
+ | `store_list` | List atoms in a collection |
31
+ | `store_search` | Full-text search in a collection |
32
+ | `store_delete` | Delete an atom |
33
+ | `store_search_all` | Search across all collections |
34
+ | `knowledge_ingest` | Add to Postgres knowledge base |
35
+ | `knowledge_search` | Search Postgres knowledge base |
36
+ | `task_submit` | Submit task to Kart queue |
37
+ | `task_status` | Check task status |
38
+ | `task_list` | List pending tasks |
39
+
40
+ Every tool requires an `app_id` param. Authorization is checked via [SAP/1.0](https://github.com/rudi193-cmd/sap-rfc).
41
+
42
+ ## OpenClaw config
43
+
44
+ ```json
45
+ {
46
+ "mcp": {
47
+ "servers": {
48
+ "willow": {
49
+ "command": "python3",
50
+ "args": ["-m", "willow_mcp"],
51
+ "env": {
52
+ "WILLOW_PG_DB": "willow",
53
+ "SAP_SAFE_ROOT": "~/.sap/Applications",
54
+ "SAP_PGP_FINGERPRINT": "YOUR_KEY_FINGERPRINT"
55
+ }
56
+ }
57
+ }
58
+ }
59
+ }
60
+ ```
61
+
62
+ ## Configuration
63
+
64
+ | Env var | Default | Description |
65
+ |---------|---------|-------------|
66
+ | `WILLOW_PG_DB` | `willow` | Postgres database name |
67
+ | `WILLOW_PG_USER` | `$USER` | Postgres user (Unix socket auth) |
68
+ | `WILLOW_STORE_ROOT` | `~/.willow/store` | SQLite store directory |
69
+ | `SAP_SAFE_ROOT` | `~/.sap/Applications` | SAFE folder root |
70
+ | `SAP_PGP_FINGERPRINT` | *(empty)* | Pinned GPG fingerprint |
71
+
72
+ ## Authorization
73
+
74
+ Uses [openclaw-sap-gate](https://github.com/rudi193-cmd/openclaw-sap-gate) (SAP/1.0). Set up a SAFE folder for each app_id:
75
+
76
+ ```bash
77
+ sap-gate init my-app
78
+ # edit ~/.sap/Applications/my-app/safe-app-manifest.json
79
+ # sign it, then:
80
+ sap-gate verify my-app
81
+ ```
82
+
83
+ ## License
84
+
85
+ MIT — Sean Campbell 2026
@@ -0,0 +1,70 @@
1
+ # willow-mcp
2
+
3
+ Agent-neutral MCP server. SQLite key/value store, Postgres knowledge base, Kart task queue. SAP/1.0 authorization on every tool call.
4
+
5
+ ```bash
6
+ pip install willow-mcp
7
+ ```
8
+
9
+ ## Tools
10
+
11
+ | Tool | Description |
12
+ |------|-------------|
13
+ | `store_put` | Write to SQLite store |
14
+ | `store_get` | Read from SQLite store |
15
+ | `store_list` | List atoms in a collection |
16
+ | `store_search` | Full-text search in a collection |
17
+ | `store_delete` | Delete an atom |
18
+ | `store_search_all` | Search across all collections |
19
+ | `knowledge_ingest` | Add to Postgres knowledge base |
20
+ | `knowledge_search` | Search Postgres knowledge base |
21
+ | `task_submit` | Submit task to Kart queue |
22
+ | `task_status` | Check task status |
23
+ | `task_list` | List pending tasks |
24
+
25
+ Every tool requires an `app_id` param. Authorization is checked via [SAP/1.0](https://github.com/rudi193-cmd/sap-rfc).
26
+
27
+ ## OpenClaw config
28
+
29
+ ```json
30
+ {
31
+ "mcp": {
32
+ "servers": {
33
+ "willow": {
34
+ "command": "python3",
35
+ "args": ["-m", "willow_mcp"],
36
+ "env": {
37
+ "WILLOW_PG_DB": "willow",
38
+ "SAP_SAFE_ROOT": "~/.sap/Applications",
39
+ "SAP_PGP_FINGERPRINT": "YOUR_KEY_FINGERPRINT"
40
+ }
41
+ }
42
+ }
43
+ }
44
+ }
45
+ ```
46
+
47
+ ## Configuration
48
+
49
+ | Env var | Default | Description |
50
+ |---------|---------|-------------|
51
+ | `WILLOW_PG_DB` | `willow` | Postgres database name |
52
+ | `WILLOW_PG_USER` | `$USER` | Postgres user (Unix socket auth) |
53
+ | `WILLOW_STORE_ROOT` | `~/.willow/store` | SQLite store directory |
54
+ | `SAP_SAFE_ROOT` | `~/.sap/Applications` | SAFE folder root |
55
+ | `SAP_PGP_FINGERPRINT` | *(empty)* | Pinned GPG fingerprint |
56
+
57
+ ## Authorization
58
+
59
+ Uses [openclaw-sap-gate](https://github.com/rudi193-cmd/openclaw-sap-gate) (SAP/1.0). Set up a SAFE folder for each app_id:
60
+
61
+ ```bash
62
+ sap-gate init my-app
63
+ # edit ~/.sap/Applications/my-app/safe-app-manifest.json
64
+ # sign it, then:
65
+ sap-gate verify my-app
66
+ ```
67
+
68
+ ## License
69
+
70
+ MIT — Sean Campbell 2026
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "willow-mcp"
7
+ version = "1.0.0"
8
+ description = "Agent-neutral MCP server — SQLite store, Postgres knowledge base, Kart task queue"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "Sean Campbell" }]
12
+ requires-python = ">=3.11"
13
+ keywords = ["mcp", "openclaw", "knowledge-base", "task-queue", "willow"]
14
+ dependencies = [
15
+ "mcp>=1.0.0",
16
+ "psycopg2-binary>=2.9",
17
+ "openclaw-sap-gate>=1.0.0",
18
+ ]
19
+
20
+ [project.scripts]
21
+ willow-mcp = "willow_mcp.server:main"
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/rudi193-cmd/willow-mcp"
25
+
26
+ [tool.hatch.build.targets.wheel]
27
+ packages = ["src/willow_mcp"]
28
+
29
+ [tool.pytest.ini_options]
30
+ testpaths = ["tests"]
@@ -0,0 +1,10 @@
1
+ """
2
+ willow-mcp — agent-neutral MCP server.
3
+
4
+ Provides: SQLite key/value store (SOIL), Postgres knowledge base, Kart task queue.
5
+ Auth: SAP/1.0 via openclaw-sap-gate (app_id required on every tool call).
6
+
7
+ Run: python3 -m willow_mcp
8
+ """
9
+
10
+ __version__ = "1.0.0"
@@ -0,0 +1,3 @@
1
+ """Entry point: python3 -m willow_mcp"""
2
+ from .server import main
3
+ main()
@@ -0,0 +1,131 @@
1
+ """Database connections — Postgres (Unix socket) and SQLite store."""
2
+
3
+ import json
4
+ import os
5
+ import sqlite3
6
+ import threading
7
+ import uuid
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ import psycopg2
13
+ import psycopg2.extras
14
+
15
+ _pg_conn = None
16
+ _pg_lock = threading.Lock()
17
+
18
+
19
+ def get_pg() -> Optional[psycopg2.extensions.connection]:
20
+ """Return a Postgres connection via Unix socket, or None."""
21
+ global _pg_conn
22
+ with _pg_lock:
23
+ try:
24
+ if _pg_conn is None or _pg_conn.closed:
25
+ _pg_conn = psycopg2.connect(
26
+ dbname=os.environ.get("WILLOW_PG_DB", "willow"),
27
+ user=os.environ.get("WILLOW_PG_USER", os.environ.get("USER", "")),
28
+ )
29
+ _pg_conn.autocommit = True
30
+ _pg_conn.cursor().execute("SELECT 1")
31
+ return _pg_conn
32
+ except Exception:
33
+ _pg_conn = None
34
+ return None
35
+
36
+
37
+ class Store:
38
+ """SQLite-backed key/value store. One database per collection."""
39
+
40
+ def __init__(self, store_root: Optional[str] = None):
41
+ self.root = Path(store_root or os.environ.get(
42
+ "WILLOW_STORE_ROOT",
43
+ Path.home() / ".willow" / "store"
44
+ ))
45
+ self.root.mkdir(parents=True, exist_ok=True)
46
+ self._conns: dict[str, sqlite3.Connection] = {}
47
+ self._lock = threading.Lock()
48
+
49
+ def _conn(self, collection: str) -> sqlite3.Connection:
50
+ with self._lock:
51
+ if collection not in self._conns:
52
+ db_path = self.root / f"{collection}.db"
53
+ conn = sqlite3.connect(str(db_path), check_same_thread=False)
54
+ conn.execute("""
55
+ CREATE TABLE IF NOT EXISTS atoms (
56
+ id TEXT PRIMARY KEY,
57
+ domain TEXT NOT NULL DEFAULT 'default',
58
+ content TEXT NOT NULL,
59
+ meta TEXT DEFAULT '{}',
60
+ created TEXT NOT NULL,
61
+ updated TEXT NOT NULL
62
+ )
63
+ """)
64
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_domain ON atoms(domain)")
65
+ conn.commit()
66
+ self._conns[collection] = conn
67
+ return self._conns[collection]
68
+
69
+ def put(self, collection: str, content: str, domain: str = "default",
70
+ atom_id: Optional[str] = None, meta: Optional[dict] = None) -> str:
71
+ atom_id = atom_id or str(uuid.uuid4())[:8].upper()
72
+ now = datetime.now(timezone.utc).isoformat()
73
+ conn = self._conn(collection)
74
+ conn.execute(
75
+ "INSERT OR REPLACE INTO atoms (id, domain, content, meta, created, updated) "
76
+ "VALUES (?, ?, ?, ?, ?, ?)",
77
+ (atom_id, domain, content, json.dumps(meta or {}), now, now)
78
+ )
79
+ conn.commit()
80
+ return atom_id
81
+
82
+ def get(self, collection: str, atom_id: str) -> Optional[dict]:
83
+ conn = self._conn(collection)
84
+ row = conn.execute(
85
+ "SELECT id, domain, content, meta, created, updated FROM atoms WHERE id = ?",
86
+ (atom_id,)
87
+ ).fetchone()
88
+ if not row:
89
+ return None
90
+ return {"id": row[0], "domain": row[1], "content": row[2],
91
+ "meta": json.loads(row[3]), "created": row[4], "updated": row[5]}
92
+
93
+ def list_atoms(self, collection: str, domain: Optional[str] = None, limit: int = 20) -> list[dict]:
94
+ conn = self._conn(collection)
95
+ if domain:
96
+ rows = conn.execute(
97
+ "SELECT id, domain, content, meta, created FROM atoms WHERE domain = ? "
98
+ "ORDER BY created DESC LIMIT ?", (domain, limit)
99
+ ).fetchall()
100
+ else:
101
+ rows = conn.execute(
102
+ "SELECT id, domain, content, meta, created FROM atoms "
103
+ "ORDER BY created DESC LIMIT ?", (limit,)
104
+ ).fetchall()
105
+ return [{"id": r[0], "domain": r[1], "content": r[2],
106
+ "meta": json.loads(r[3]), "created": r[4]} for r in rows]
107
+
108
+ def search(self, collection: str, query: str, limit: int = 10) -> list[dict]:
109
+ conn = self._conn(collection)
110
+ rows = conn.execute(
111
+ "SELECT id, domain, content, meta, created FROM atoms "
112
+ "WHERE content LIKE ? ORDER BY created DESC LIMIT ?",
113
+ (f"%{query}%", limit)
114
+ ).fetchall()
115
+ return [{"id": r[0], "domain": r[1], "content": r[2],
116
+ "meta": json.loads(r[3]), "created": r[4]} for r in rows]
117
+
118
+ def delete(self, collection: str, atom_id: str) -> bool:
119
+ conn = self._conn(collection)
120
+ cur = conn.execute("DELETE FROM atoms WHERE id = ?", (atom_id,))
121
+ conn.commit()
122
+ return cur.rowcount > 0
123
+
124
+ def search_all(self, query: str, limit: int = 10) -> list[dict]:
125
+ results = []
126
+ for db_path in sorted(self.root.glob("*.db")):
127
+ collection = db_path.stem
128
+ for item in self.search(collection, query, limit):
129
+ item["collection"] = collection
130
+ results.append(item)
131
+ return results[:limit]
@@ -0,0 +1,316 @@
1
+ """
2
+ willow-mcp MCP server — agent-neutral core tools.
3
+
4
+ Tools:
5
+ Store (SQLite): store_put, store_get, store_list, store_search, store_delete, store_search_all
6
+ Knowledge (PG): knowledge_search, knowledge_ingest
7
+ Tasks (PG): task_submit, task_status, task_list
8
+
9
+ Auth: SAP/1.0 — app_id required on every call. Set SAP_PGP_FINGERPRINT to pin your key.
10
+ """
11
+
12
+ import json
13
+ import os
14
+ import sys
15
+ import uuid
16
+ from typing import Any
17
+
18
+ import mcp.server.stdio
19
+ import mcp.types as types
20
+ from mcp.server import Server
21
+
22
+ from .db import Store, get_pg
23
+
24
+ try:
25
+ from openclaw_sap_gate import authorized as _sap_authorized
26
+ _SAP_AVAILABLE = True
27
+ except ImportError:
28
+ _SAP_AVAILABLE = False
29
+
30
+ _store = Store()
31
+ _server = Server("willow-mcp")
32
+ _DEFAULT_APP_ID = os.environ.get("WILLOW_APP_ID", "willow-mcp")
33
+
34
+
35
+ def _auth(app_id: str) -> tuple[bool, str]:
36
+ """Check SAP authorization. Returns (ok, error_message)."""
37
+ if not _SAP_AVAILABLE:
38
+ return True, ""
39
+ if not app_id:
40
+ return False, "app_id is required"
41
+ if not _sap_authorized(app_id):
42
+ return False, f"SAP gate denied: '{app_id}' not authorized"
43
+ return True, ""
44
+
45
+
46
+ @_server.list_tools()
47
+ async def list_tools() -> list[types.Tool]:
48
+ return [
49
+ types.Tool(
50
+ name="store_put",
51
+ description="Write a value to the local SQLite store.",
52
+ inputSchema={"type": "object", "required": ["app_id", "collection", "content"],
53
+ "properties": {
54
+ "app_id": {"type": "string"},
55
+ "collection": {"type": "string"},
56
+ "content": {"type": "string"},
57
+ "domain": {"type": "string", "default": "default"},
58
+ "id": {"type": "string"},
59
+ }},
60
+ ),
61
+ types.Tool(
62
+ name="store_get",
63
+ description="Read a value from the local SQLite store by ID.",
64
+ inputSchema={"type": "object", "required": ["app_id", "collection", "id"],
65
+ "properties": {
66
+ "app_id": {"type": "string"},
67
+ "collection": {"type": "string"},
68
+ "id": {"type": "string"},
69
+ }},
70
+ ),
71
+ types.Tool(
72
+ name="store_list",
73
+ description="List atoms in a collection.",
74
+ inputSchema={"type": "object", "required": ["app_id", "collection"],
75
+ "properties": {
76
+ "app_id": {"type": "string"},
77
+ "collection": {"type": "string"},
78
+ "domain": {"type": "string"},
79
+ "limit": {"type": "integer", "default": 20},
80
+ }},
81
+ ),
82
+ types.Tool(
83
+ name="store_search",
84
+ description="Full-text search within a collection.",
85
+ inputSchema={"type": "object", "required": ["app_id", "collection", "query"],
86
+ "properties": {
87
+ "app_id": {"type": "string"},
88
+ "collection": {"type": "string"},
89
+ "query": {"type": "string"},
90
+ "limit": {"type": "integer", "default": 10},
91
+ }},
92
+ ),
93
+ types.Tool(
94
+ name="store_delete",
95
+ description="Delete an atom from the store.",
96
+ inputSchema={"type": "object", "required": ["app_id", "collection", "id"],
97
+ "properties": {
98
+ "app_id": {"type": "string"},
99
+ "collection": {"type": "string"},
100
+ "id": {"type": "string"},
101
+ }},
102
+ ),
103
+ types.Tool(
104
+ name="store_search_all",
105
+ description="Search across all collections.",
106
+ inputSchema={"type": "object", "required": ["app_id", "query"],
107
+ "properties": {
108
+ "app_id": {"type": "string"},
109
+ "query": {"type": "string"},
110
+ "limit": {"type": "integer", "default": 10},
111
+ }},
112
+ ),
113
+ types.Tool(
114
+ name="knowledge_ingest",
115
+ description="Add a knowledge atom to the Postgres knowledge base.",
116
+ inputSchema={"type": "object", "required": ["app_id", "content"],
117
+ "properties": {
118
+ "app_id": {"type": "string"},
119
+ "content": {"type": "string"},
120
+ "domain": {"type": "string", "default": "general"},
121
+ "source": {"type": "string"},
122
+ "tags": {"type": "array", "items": {"type": "string"}},
123
+ }},
124
+ ),
125
+ types.Tool(
126
+ name="knowledge_search",
127
+ description="Search the Postgres knowledge base.",
128
+ inputSchema={"type": "object", "required": ["app_id", "query"],
129
+ "properties": {
130
+ "app_id": {"type": "string"},
131
+ "query": {"type": "string"},
132
+ "domain": {"type": "string"},
133
+ "limit": {"type": "integer", "default": 10},
134
+ }},
135
+ ),
136
+ types.Tool(
137
+ name="task_submit",
138
+ description="Submit a task to the Kart execution queue.",
139
+ inputSchema={"type": "object", "required": ["app_id", "task"],
140
+ "properties": {
141
+ "app_id": {"type": "string"},
142
+ "task": {"type": "string", "description": "Task text for Kart to execute"},
143
+ "agent": {"type": "string", "default": "kart"},
144
+ }},
145
+ ),
146
+ types.Tool(
147
+ name="task_status",
148
+ description="Check status of a submitted task.",
149
+ inputSchema={"type": "object", "required": ["app_id", "task_id"],
150
+ "properties": {
151
+ "app_id": {"type": "string"},
152
+ "task_id": {"type": "string"},
153
+ }},
154
+ ),
155
+ types.Tool(
156
+ name="task_list",
157
+ description="List pending tasks in the queue.",
158
+ inputSchema={"type": "object", "required": ["app_id"],
159
+ "properties": {
160
+ "app_id": {"type": "string"},
161
+ "agent": {"type": "string", "default": "kart"},
162
+ "limit": {"type": "integer", "default": 10},
163
+ }},
164
+ ),
165
+ ]
166
+
167
+
168
+ @_server.call_tool()
169
+ async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
170
+ app_id = arguments.get("app_id", _DEFAULT_APP_ID)
171
+ ok, err = _auth(app_id)
172
+ if not ok:
173
+ return [types.TextContent(type="text", text=json.dumps({"error": err}))]
174
+
175
+ try:
176
+ result = await _dispatch(name, arguments)
177
+ except Exception as e:
178
+ result = {"error": str(e)}
179
+
180
+ return [types.TextContent(type="text", text=json.dumps(result))]
181
+
182
+
183
+ async def _dispatch(name: str, args: dict) -> Any:
184
+ # ── Store ──────────────────────────────────────────────────────────
185
+ if name == "store_put":
186
+ atom_id = _store.put(
187
+ collection=args["collection"],
188
+ content=args["content"],
189
+ domain=args.get("domain", "default"),
190
+ atom_id=args.get("id"),
191
+ )
192
+ return {"id": atom_id, "collection": args["collection"]}
193
+
194
+ if name == "store_get":
195
+ item = _store.get(args["collection"], args["id"])
196
+ return item or {"error": "not_found"}
197
+
198
+ if name == "store_list":
199
+ return {"items": _store.list_atoms(
200
+ args["collection"],
201
+ domain=args.get("domain"),
202
+ limit=args.get("limit", 20),
203
+ )}
204
+
205
+ if name == "store_search":
206
+ return {"results": _store.search(
207
+ args["collection"], args["query"], limit=args.get("limit", 10)
208
+ )}
209
+
210
+ if name == "store_delete":
211
+ deleted = _store.delete(args["collection"], args["id"])
212
+ return {"deleted": deleted}
213
+
214
+ if name == "store_search_all":
215
+ return {"results": _store.search_all(args["query"], limit=args.get("limit", 10))}
216
+
217
+ # ── Knowledge ──────────────────────────────────────────────────────
218
+ if name == "knowledge_ingest":
219
+ pg = get_pg()
220
+ if not pg:
221
+ return {"error": "postgres_unavailable"}
222
+ cur = pg.cursor()
223
+ kid = str(uuid.uuid4())[:8].upper()
224
+ cur.execute(
225
+ "INSERT INTO knowledge (id, content, domain, source, tags) "
226
+ "VALUES (%s, %s, %s, %s, %s) ON CONFLICT DO NOTHING",
227
+ (kid, args["content"], args.get("domain", "general"),
228
+ args.get("source", ""), json.dumps(args.get("tags", [])))
229
+ )
230
+ cur.close()
231
+ return {"id": kid}
232
+
233
+ if name == "knowledge_search":
234
+ pg = get_pg()
235
+ if not pg:
236
+ return {"error": "postgres_unavailable"}
237
+ cur = pg.cursor()
238
+ query = f"%{args['query']}%"
239
+ sql = "SELECT id, content, domain, source FROM knowledge WHERE content ILIKE %s"
240
+ params = [query]
241
+ if args.get("domain"):
242
+ sql += " AND domain = %s"
243
+ params.append(args["domain"])
244
+ sql += " LIMIT %s"
245
+ params.append(args.get("limit", 10))
246
+ cur.execute(sql, params)
247
+ rows = cur.fetchall()
248
+ cur.close()
249
+ return {"results": [{"id": r[0], "content": r[1], "domain": r[2], "source": r[3]}
250
+ for r in rows]}
251
+
252
+ # ── Task Queue ─────────────────────────────────────────────────────
253
+ if name == "task_submit":
254
+ pg = get_pg()
255
+ if not pg:
256
+ return {"error": "postgres_unavailable"}
257
+ task_id = "".join(__import__("random").choices(
258
+ "ABCDEFGHJKLMNPQRSTUVWXYZ0123456789", k=8))
259
+ cur = pg.cursor()
260
+ cur.execute(
261
+ "INSERT INTO kart_task_queue (task_id, submitted_by, agent, task) "
262
+ "VALUES (%s, %s, %s, %s)",
263
+ (task_id, args.get("app_id", "willow-mcp"),
264
+ args.get("agent", "kart"), args["task"])
265
+ )
266
+ cur.close()
267
+ return {"task_id": task_id, "status": "pending"}
268
+
269
+ if name == "task_status":
270
+ pg = get_pg()
271
+ if not pg:
272
+ return {"error": "postgres_unavailable"}
273
+ cur = pg.cursor()
274
+ cur.execute(
275
+ "SELECT task_id, status, result, steps, created_at, completed_at "
276
+ "FROM kart_task_queue WHERE task_id = %s",
277
+ (args["task_id"],)
278
+ )
279
+ row = cur.fetchone()
280
+ cur.close()
281
+ if not row:
282
+ return {"error": "not_found"}
283
+ return {"task_id": row[0], "status": row[1], "result": row[2],
284
+ "steps": row[3], "created_at": str(row[4]), "completed_at": str(row[5])}
285
+
286
+ if name == "task_list":
287
+ pg = get_pg()
288
+ if not pg:
289
+ return {"error": "postgres_unavailable"}
290
+ cur = pg.cursor()
291
+ cur.execute(
292
+ "SELECT task_id, task, submitted_by, created_at FROM kart_task_queue "
293
+ "WHERE status = 'pending' AND agent = %s ORDER BY created_at LIMIT %s",
294
+ (args.get("agent", "kart"), args.get("limit", 10))
295
+ )
296
+ rows = cur.fetchall()
297
+ cur.close()
298
+ return {"pending": [{"task_id": r[0], "task": r[1][:80],
299
+ "submitted_by": r[2], "created_at": str(r[3])}
300
+ for r in rows]}
301
+
302
+ return {"error": f"unknown_tool: {name}"}
303
+
304
+
305
+ def main():
306
+ import asyncio
307
+ asyncio.run(_run())
308
+
309
+
310
+ async def _run():
311
+ async with mcp.server.stdio.stdio_server() as (r, w):
312
+ await _server.run(r, w, _server.create_initialization_options())
313
+
314
+
315
+ if __name__ == "__main__":
316
+ main()
@@ -0,0 +1,72 @@
1
+ """Tests for the SQLite Store."""
2
+
3
+ import pytest
4
+ from willow_mcp.db import Store
5
+
6
+
7
+ @pytest.fixture
8
+ def store(tmp_path):
9
+ return Store(store_root=str(tmp_path))
10
+
11
+
12
+ def test_put_and_get(store):
13
+ atom_id = store.put("test", "hello world")
14
+ result = store.get("test", atom_id)
15
+ assert result is not None
16
+ assert result["content"] == "hello world"
17
+ assert result["domain"] == "default"
18
+
19
+
20
+ def test_put_custom_id(store):
21
+ store.put("test", "content", atom_id="MYID")
22
+ result = store.get("test", "MYID")
23
+ assert result["content"] == "content"
24
+
25
+
26
+ def test_get_missing(store):
27
+ assert store.get("test", "NOPE") is None
28
+
29
+
30
+ def test_list(store):
31
+ store.put("col", "a")
32
+ store.put("col", "b")
33
+ store.put("col", "c")
34
+ items = store.list_atoms("col", limit=10)
35
+ assert len(items) == 3
36
+
37
+
38
+ def test_list_by_domain(store):
39
+ store.put("col", "x", domain="alpha")
40
+ store.put("col", "y", domain="beta")
41
+ items = store.list_atoms("col", domain="alpha")
42
+ assert len(items) == 1
43
+ assert items[0]["content"] == "x"
44
+
45
+
46
+ def test_search(store):
47
+ store.put("col", "the quick brown fox")
48
+ store.put("col", "lazy dog")
49
+ results = store.search("col", "quick")
50
+ assert len(results) == 1
51
+ assert "quick" in results[0]["content"]
52
+
53
+
54
+ def test_delete(store):
55
+ atom_id = store.put("col", "to delete")
56
+ assert store.delete("col", atom_id) is True
57
+ assert store.get("col", atom_id) is None
58
+
59
+
60
+ def test_delete_missing(store):
61
+ assert store.delete("col", "GHOST") is False
62
+
63
+
64
+ def test_search_all(store):
65
+ store.put("col_a", "willow is a system")
66
+ store.put("col_b", "willow runs on linux")
67
+ store.put("col_c", "something else")
68
+ results = store.search_all("willow", limit=10)
69
+ assert len(results) == 2
70
+ collections = {r["collection"] for r in results}
71
+ assert "col_a" in collections
72
+ assert "col_b" in collections