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.
- willow_mcp-1.0.0/LICENSE +21 -0
- willow_mcp-1.0.0/PKG-INFO +85 -0
- willow_mcp-1.0.0/README.md +70 -0
- willow_mcp-1.0.0/pyproject.toml +30 -0
- willow_mcp-1.0.0/src/willow_mcp/__init__.py +10 -0
- willow_mcp-1.0.0/src/willow_mcp/__main__.py +3 -0
- willow_mcp-1.0.0/src/willow_mcp/db.py +131 -0
- willow_mcp-1.0.0/src/willow_mcp/server.py +316 -0
- willow_mcp-1.0.0/tests/test_store.py +72 -0
willow_mcp-1.0.0/LICENSE
ADDED
|
@@ -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,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
|