@intentsolutionsio/dolt-mcp-vcs 0.1.0
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.
- package/.claude-plugin/marketplace.json +39 -0
- package/.claude-plugin/plugin.json +25 -0
- package/LICENSE +202 -0
- package/README.md +58 -0
- package/agents/bead-dependency-mapper.md +49 -0
- package/agents/bead-epic-auditor.md +49 -0
- package/agents/bead-recovery-specialist.md +50 -0
- package/agents/beads-guru.md +49 -0
- package/agents/dolt-sync-advisor.md +54 -0
- package/package.json +46 -0
- package/scripts/check-agent-safety.sh +65 -0
- package/scripts/dep-graph.sh +44 -0
- package/scripts/descriptor-to-mcp-args.py +121 -0
- package/scripts/dolt-idle-reaper.sh +82 -0
- package/scripts/dolt-mcp-client.py +188 -0
- package/scripts/dolt-push-dolthub.sh +98 -0
- package/scripts/epic-closure-audit.sh +49 -0
- package/scripts/profile_sql.py +97 -0
- package/scripts/resolve-creds-ref.py +136 -0
- package/scripts/server-health.sh +39 -0
- package/scripts/sql_classifier.py +259 -0
- package/skills/dolt-mcp-vcs/SKILL.md +141 -0
- package/skills/dolt-mcp-vcs/eval.yaml +106 -0
- package/skills/dolt-mcp-vcs/references/connection-descriptor-and-profiles.md +109 -0
- package/skills/dolt-mcp-vcs/references/dolt-internals.md +33 -0
- package/skills/dolt-mcp-vcs/safety-eval.yaml +118 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# check-agent-safety.sh — the §10 union safety gate (blueprint §3 / blocker B1-iii).
|
|
3
|
+
#
|
|
4
|
+
# Asserts the mutation-verb taxonomy is enforced at the GRANT layer, across BOTH
|
|
5
|
+
# the MCP and the Bash surfaces (the original gate inspected only `mcp__*` and was
|
|
6
|
+
# blind to the Bash door). For every agent (and the core skill) it checks the
|
|
7
|
+
# tool ALLOWLIST — never the denylist — for:
|
|
8
|
+
#
|
|
9
|
+
# 1. no `Bash(<cmd>:*)` wildcard that reaches a history-affecting op
|
|
10
|
+
# (bash/sh = arbitrary; dolt/bd/bd-sync/git = push/reset/branch -D/killall);
|
|
11
|
+
# 2. no granted MCP tool outside the read/safe set (so a future `…__exec`,
|
|
12
|
+
# `…__merge`, `…__push`, `…__reset` grant fails the build).
|
|
13
|
+
#
|
|
14
|
+
# `disallowedTools` (the kebab/camel denylists) are intentionally NOT scanned —
|
|
15
|
+
# a destructive pattern there is the mitigation, not a violation.
|
|
16
|
+
#
|
|
17
|
+
# Usage: scripts/check-agent-safety.sh (exit 0 = pass, 1 = violation)
|
|
18
|
+
set -uo pipefail
|
|
19
|
+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
20
|
+
|
|
21
|
+
FORBIDDEN_WILDCARD='Bash\((bash|sh|dolt|bd|bd-sync|git):\*\)'
|
|
22
|
+
ALLOWED_MCP='query|list_databases|list_dolt_commits|list_dolt_branches|show_tables'
|
|
23
|
+
fail=0
|
|
24
|
+
checked=0
|
|
25
|
+
|
|
26
|
+
check_allowlist() {
|
|
27
|
+
local f="$1" field="$2" line val
|
|
28
|
+
line=$(grep -m1 -E "^${field}:" "$f" 2>/dev/null || true)
|
|
29
|
+
[ -n "$line" ] || return 0
|
|
30
|
+
checked=$((checked + 1))
|
|
31
|
+
val=${line#*:}
|
|
32
|
+
|
|
33
|
+
if echo "$val" | grep -qE "$FORBIDDEN_WILDCARD"; then
|
|
34
|
+
echo "FAIL: $(basename "$f") — allowlist holds a forbidden wildcard Bash grant:" \
|
|
35
|
+
"$(echo "$val" | grep -oE "$FORBIDDEN_WILDCARD" | tr '\n' ' ')"
|
|
36
|
+
fail=1
|
|
37
|
+
fi
|
|
38
|
+
|
|
39
|
+
local tok name
|
|
40
|
+
for tok in $(echo "$val" | grep -oE 'mcp__[A-Za-z0-9_-]+__[A-Za-z0-9_]+' || true); do
|
|
41
|
+
name=${tok##*__}
|
|
42
|
+
if ! echo "$name" | grep -qE "^(${ALLOWED_MCP})$"; then
|
|
43
|
+
echo "FAIL: $(basename "$f") — grants non-read MCP tool '$tok' (not in the read/safe set)"
|
|
44
|
+
fail=1
|
|
45
|
+
fi
|
|
46
|
+
done
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
shopt -s nullglob
|
|
50
|
+
for f in "$ROOT"/agents/*.md; do
|
|
51
|
+
check_allowlist "$f" "tools"
|
|
52
|
+
done
|
|
53
|
+
for f in "$ROOT"/skills/*/SKILL.md; do
|
|
54
|
+
check_allowlist "$f" "allowed-tools"
|
|
55
|
+
done
|
|
56
|
+
|
|
57
|
+
if [ "$fail" -eq 0 ]; then
|
|
58
|
+
echo "PASS: §10 safety gate — $checked allowlist(s) clean (no history-affecting Bash wildcard; no non-read MCP grant)."
|
|
59
|
+
else
|
|
60
|
+
echo "----"
|
|
61
|
+
echo "The mutation-verb taxonomy is violated: a read/safe-write agent or the core skill"
|
|
62
|
+
echo "holds a grant that reaches a history-affecting operation. Narrow it to explicit"
|
|
63
|
+
echo "read-only subcommands and move destructive forms to disallowedTools / recommend-only."
|
|
64
|
+
fi
|
|
65
|
+
exit "$fail"
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# dep-graph.sh — bead dependency analysis through the dolt-mcp server.
|
|
3
|
+
# Surfaces bottlenecks (open issues blocking the most other open work) and any
|
|
4
|
+
# direct dependency cycles. Runs SQL via dolt-mcp-client.py.
|
|
5
|
+
#
|
|
6
|
+
# Usage: dep-graph.sh # uses $DOLT_PORT / $DOLT_DATABASE
|
|
7
|
+
# dep-graph.sh --port 35579 --database beads [--top 10]
|
|
8
|
+
#
|
|
9
|
+
# Requires: python3, dolt-mcp-server on PATH, a running bd dolt sql-server.
|
|
10
|
+
set -euo pipefail
|
|
11
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
12
|
+
|
|
13
|
+
PORT="${DOLT_PORT:-}"; DB="${DOLT_DATABASE:-}"; TOP=10
|
|
14
|
+
while [ $# -gt 0 ]; do
|
|
15
|
+
case "$1" in
|
|
16
|
+
--port) PORT="$2"; shift 2 ;;
|
|
17
|
+
--database) DB="$2"; shift 2 ;;
|
|
18
|
+
--top) TOP="$2"; shift 2 ;;
|
|
19
|
+
*) echo "unknown arg: $1" >&2; exit 2 ;;
|
|
20
|
+
esac
|
|
21
|
+
done
|
|
22
|
+
[ -n "$PORT" ] || { echo "error: set --port or DOLT_PORT (see 'bd dolt show')" >&2; exit 2; }
|
|
23
|
+
[ -n "$DB" ] || { echo "error: set --database or DOLT_DATABASE (see 'bd dolt show')" >&2; exit 2; }
|
|
24
|
+
|
|
25
|
+
q() { python3 "$SCRIPT_DIR/dolt-mcp-client.py" --port "$PORT" --database "$DB" query "$1"; }
|
|
26
|
+
|
|
27
|
+
echo "# Dependency analysis — database '$DB' (port $PORT)"
|
|
28
|
+
echo
|
|
29
|
+
echo "## Bottlenecks — open issues blocking the most other OPEN issues (top $TOP)"
|
|
30
|
+
q "SELECT b.id AS blocker, b.status, COUNT(*) AS blocking_open, LEFT(b.title,55) AS title
|
|
31
|
+
FROM dependencies d
|
|
32
|
+
JOIN issues b ON b.id=d.depends_on_id
|
|
33
|
+
JOIN issues blocked ON blocked.id=d.issue_id
|
|
34
|
+
WHERE d.type='blocks' AND b.status<>'closed' AND blocked.status<>'closed'
|
|
35
|
+
GROUP BY b.id, b.status, b.title
|
|
36
|
+
ORDER BY blocking_open DESC
|
|
37
|
+
LIMIT ${TOP}"
|
|
38
|
+
|
|
39
|
+
echo
|
|
40
|
+
echo "## Direct cycles — A blocks B and B blocks A (should be none)"
|
|
41
|
+
q "SELECT d1.issue_id AS a, d1.depends_on_id AS b
|
|
42
|
+
FROM dependencies d1
|
|
43
|
+
JOIN dependencies d2 ON d2.issue_id=d1.depends_on_id AND d2.depends_on_id=d1.issue_id
|
|
44
|
+
WHERE d1.type='blocks' AND d2.type='blocks' AND d1.issue_id < d1.depends_on_id"
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""descriptor-to-mcp-args.py — the connection-descriptor -> .mcp.json transform,
|
|
3
|
+
plus the descriptor validator rule (blueprint §2 / the creds-ref MAJOR).
|
|
4
|
+
|
|
5
|
+
Reads a `connection.descriptor.json` (the committed per-workspace home), VALIDATES
|
|
6
|
+
it, and emits the `dolt-mcp-server` args + env the .mcp.json wires — turning
|
|
7
|
+
`flavor`/`endpoint`/`database`/`maturity` from frozen literals into data.
|
|
8
|
+
|
|
9
|
+
Validator rule (fail-closed): the run is rejected (exit 2) if
|
|
10
|
+
* a required field is missing (flavor, endpoint, database, creds-ref, maturity);
|
|
11
|
+
* `flavor` is not a known flavor;
|
|
12
|
+
* `maturity` is not ga|beta|alpha|experimental;
|
|
13
|
+
* `creds-ref` does not start with a known `scheme:` (env:/sops:/pass:) — a
|
|
14
|
+
creds-ref is a pointer, never a literal, so anything without a known scheme is
|
|
15
|
+
refused rather than silently treated as a password.
|
|
16
|
+
Alpha/experimental flavors are emitted with DOLT_MATURITY set so the client gate
|
|
17
|
+
(sql_classifier.gate_decision) holds them to read-only.
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
descriptor-to-mcp-args.py [--descriptor connection.descriptor.json] [--format json|args]
|
|
21
|
+
--format json (default) -> {"args": [...], "env": {...}} (machine-readable)
|
|
22
|
+
--format args -> the args, space-joined (for eyeballing)
|
|
23
|
+
Exit: 0 ok · 2 invalid descriptor / unknown scheme.
|
|
24
|
+
"""
|
|
25
|
+
import argparse
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import sys
|
|
29
|
+
|
|
30
|
+
REQUIRED = ("flavor", "endpoint", "database", "creds-ref", "maturity")
|
|
31
|
+
FLAVOR_CONNECT = {"dolt": "--dolt", "doltgres": "--doltgres"}
|
|
32
|
+
# alpha/experimental flavors have no wired connect flag yet (descriptor-stub only,
|
|
33
|
+
# decision 6) — they validate but cannot be transformed into a live connection.
|
|
34
|
+
FLAVOR_STUB = {"doltlite", "dumbo"}
|
|
35
|
+
MATURITIES = {"ga", "beta", "alpha", "experimental"}
|
|
36
|
+
KNOWN_CREDS_SCHEMES = ("env:", "sops:", "pass:")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def eprint(*a):
|
|
40
|
+
print(*a, file=sys.stderr)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def validate(d):
|
|
44
|
+
errs = []
|
|
45
|
+
for f in REQUIRED:
|
|
46
|
+
if not d.get(f):
|
|
47
|
+
errs.append(f"missing required field '{f}'")
|
|
48
|
+
flavor = d.get("flavor")
|
|
49
|
+
if flavor and flavor not in FLAVOR_CONNECT and flavor not in FLAVOR_STUB:
|
|
50
|
+
errs.append(f"unknown flavor '{flavor}' (known: "
|
|
51
|
+
f"{', '.join(sorted(set(FLAVOR_CONNECT) | FLAVOR_STUB))})")
|
|
52
|
+
maturity = (d.get("maturity") or "").lower()
|
|
53
|
+
if maturity and maturity not in MATURITIES:
|
|
54
|
+
errs.append(f"unknown maturity '{maturity}' (known: {', '.join(sorted(MATURITIES))})")
|
|
55
|
+
cref = d.get("creds-ref")
|
|
56
|
+
if cref and not str(cref).startswith(KNOWN_CREDS_SCHEMES):
|
|
57
|
+
errs.append(f"creds-ref '{cref}' has no known scheme prefix "
|
|
58
|
+
f"({', '.join(KNOWN_CREDS_SCHEMES)}); a creds-ref must be a pointer, "
|
|
59
|
+
"never a literal secret")
|
|
60
|
+
return errs
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def split_endpoint(endpoint):
|
|
64
|
+
host, _, port = endpoint.partition(":")
|
|
65
|
+
return host or "127.0.0.1", port or "3308"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def transform(d):
|
|
69
|
+
flavor = d["flavor"]
|
|
70
|
+
if flavor in FLAVOR_STUB:
|
|
71
|
+
raise ValueError(f"flavor '{flavor}' is a descriptor-stub (pre-beta) — no live "
|
|
72
|
+
"connection flag is wired yet (decision 6). It validates but "
|
|
73
|
+
"cannot be transformed until dolt-watch reports it has reached beta.")
|
|
74
|
+
host, port = split_endpoint(d["endpoint"])
|
|
75
|
+
user = os.environ.get("DOLT_USER", "root")
|
|
76
|
+
args = ["--stdio", FLAVOR_CONNECT[flavor],
|
|
77
|
+
"--host", host, "--port", port,
|
|
78
|
+
"--user", user, "--database", d["database"]]
|
|
79
|
+
env = {
|
|
80
|
+
# the secret is resolved at runtime from the creds-ref pointer (never inlined)
|
|
81
|
+
"DOLT_PASSWORD": "${DOLT_PASSWORD:-}",
|
|
82
|
+
"DOLT_MATURITY": d["maturity"].lower(),
|
|
83
|
+
}
|
|
84
|
+
return {"args": args, "env": env, "creds-ref": d["creds-ref"]}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def main():
|
|
88
|
+
ap = argparse.ArgumentParser(description="connection-descriptor -> .mcp.json args (validated)")
|
|
89
|
+
ap.add_argument("--descriptor", default="connection.descriptor.json")
|
|
90
|
+
ap.add_argument("--format", choices=["json", "args"], default="json")
|
|
91
|
+
args = ap.parse_args()
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
with open(args.descriptor) as fh:
|
|
95
|
+
d = json.load(fh)
|
|
96
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
97
|
+
eprint(f"error: cannot read descriptor '{args.descriptor}': {e}")
|
|
98
|
+
return 2
|
|
99
|
+
|
|
100
|
+
errs = validate(d)
|
|
101
|
+
if errs:
|
|
102
|
+
eprint(f"invalid descriptor '{args.descriptor}':")
|
|
103
|
+
for e in errs:
|
|
104
|
+
eprint(f" - {e}")
|
|
105
|
+
return 2
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
out = transform(d)
|
|
109
|
+
except ValueError as e:
|
|
110
|
+
eprint(f"error: {e}")
|
|
111
|
+
return 2
|
|
112
|
+
|
|
113
|
+
if args.format == "args":
|
|
114
|
+
print(" ".join(out["args"]))
|
|
115
|
+
else:
|
|
116
|
+
print(json.dumps(out, indent=2))
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
if __name__ == "__main__":
|
|
121
|
+
sys.exit(main())
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# dolt-idle-reaper.sh — gracefully stop idle bd-managed `dolt sql-server`
|
|
3
|
+
# processes so they don't accumulate (the "18 sprawled servers" problem).
|
|
4
|
+
#
|
|
5
|
+
# Safe by design: bd auto-restarts a workspace's dolt server on the next bd
|
|
6
|
+
# command, so stopping an idle one is non-destructive — worst case is a ~2s
|
|
7
|
+
# respawn latency next time that workspace is used. Data lives in Dolt (durable)
|
|
8
|
+
# + the .beads JSONL; stopping a server never loses anything.
|
|
9
|
+
#
|
|
10
|
+
# "Idle" = the workspace's .beads/ activity files (last-touched, issues.jsonl,
|
|
11
|
+
# dolt-server.log — the log updates on every query, so reads count too) have not
|
|
12
|
+
# changed in $IDLE_MIN minutes.
|
|
13
|
+
#
|
|
14
|
+
# Usage:
|
|
15
|
+
# dolt-idle-reaper.sh # reap servers idle > IDLE_MIN (default 90)
|
|
16
|
+
# dolt-idle-reaper.sh --dry-run # show what WOULD be reaped, change nothing
|
|
17
|
+
# IDLE_MIN=120 dolt-idle-reaper.sh # custom idle threshold
|
|
18
|
+
#
|
|
19
|
+
# Cron (every 30 min):
|
|
20
|
+
# 3,33 * * * * /path/to/dolt-idle-reaper.sh >> ~/.local/state/dolt-reaper/reap.log 2>&1
|
|
21
|
+
set -uo pipefail
|
|
22
|
+
|
|
23
|
+
IDLE_MIN="${IDLE_MIN:-90}"
|
|
24
|
+
DRY=0
|
|
25
|
+
[ "${1:-}" = "--dry-run" ] && DRY=1
|
|
26
|
+
|
|
27
|
+
LOGDIR="$HOME/.local/state/dolt-reaper"
|
|
28
|
+
mkdir -p "$LOGDIR"
|
|
29
|
+
LOCK="$LOGDIR/.lock"
|
|
30
|
+
exec 9>"$LOCK"
|
|
31
|
+
flock -n 9 || { echo "[$(date '+%F %T')] another reaper run is active; skip."; exit 0; }
|
|
32
|
+
|
|
33
|
+
now=$(date +%s)
|
|
34
|
+
idle_secs=$(( IDLE_MIN * 60 ))
|
|
35
|
+
reaped=0 kept=0 total=0
|
|
36
|
+
|
|
37
|
+
# Resolve a server process's workspace root from its cwd (cwd is either the
|
|
38
|
+
# workspace root or its .beads/dolt data-dir).
|
|
39
|
+
ws_root() {
|
|
40
|
+
local cwd="$1"
|
|
41
|
+
case "$cwd" in
|
|
42
|
+
*/.beads/dolt) echo "${cwd%/.beads/dolt}" ;;
|
|
43
|
+
*/.beads) echo "${cwd%/.beads}" ;;
|
|
44
|
+
*) echo "$cwd" ;;
|
|
45
|
+
esac
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# Most-recent activity timestamp (epoch) across a workspace's .beads activity files.
|
|
49
|
+
latest_activity() {
|
|
50
|
+
local bd="$1/.beads" newest=0 m
|
|
51
|
+
for f in last-touched issues.jsonl dolt-server.log interactions.jsonl; do
|
|
52
|
+
[ -f "$bd/$f" ] || continue
|
|
53
|
+
m=$(stat -c %Y "$bd/$f" 2>/dev/null || echo 0)
|
|
54
|
+
[ "$m" -gt "$newest" ] && newest=$m
|
|
55
|
+
done
|
|
56
|
+
echo "$newest"
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
echo "[$(date '+%F %T')] reaper start (IDLE_MIN=$IDLE_MIN, dry-run=$DRY)"
|
|
60
|
+
for pid in $(pgrep -f 'dolt sql-server' 2>/dev/null); do
|
|
61
|
+
total=$((total+1))
|
|
62
|
+
cwd=$(readlink "/proc/$pid/cwd" 2>/dev/null) || { continue; }
|
|
63
|
+
ws=$(ws_root "$cwd")
|
|
64
|
+
short=$(echo "$ws" | sed "s#$HOME/##")
|
|
65
|
+
act=$(latest_activity "$ws")
|
|
66
|
+
if [ "$act" -eq 0 ]; then
|
|
67
|
+
# No activity files found — fall back to the process start time via /proc.
|
|
68
|
+
act=$(stat -c %Y "/proc/$pid" 2>/dev/null || echo "$now")
|
|
69
|
+
fi
|
|
70
|
+
age_min=$(( (now - act) / 60 ))
|
|
71
|
+
if [ "$(( now - act ))" -ge "$idle_secs" ]; then
|
|
72
|
+
if [ "$DRY" -eq 1 ]; then
|
|
73
|
+
echo " WOULD REAP pid=$pid idle=${age_min}m $short"
|
|
74
|
+
else
|
|
75
|
+
kill -TERM "$pid" 2>/dev/null && echo " reaped pid=$pid idle=${age_min}m $short" || echo " reap-failed pid=$pid $short"
|
|
76
|
+
fi
|
|
77
|
+
reaped=$((reaped+1))
|
|
78
|
+
else
|
|
79
|
+
kept=$((kept+1))
|
|
80
|
+
fi
|
|
81
|
+
done
|
|
82
|
+
echo "[$(date '+%F %T')] reaper done — $total servers, $reaped $([ $DRY -eq 1 ] && echo would-reap || echo reaped), $kept kept (active within ${IDLE_MIN}m). bd respawns on next use."
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
dolt-mcp-client.py — minimal, robust stdio client for the dolthub/dolt-mcp server.
|
|
4
|
+
|
|
5
|
+
The reusable foundation the dolt-mcp-vcs agents/scripts use to run SQL against a
|
|
6
|
+
bd Dolt server *through the MCP* (not by shelling `dolt` directly), so the path
|
|
7
|
+
exercised in production is the same one the plugin ships.
|
|
8
|
+
|
|
9
|
+
Spawns `dolt-mcp-server --stdio`, performs the JSON-RPC handshake, calls one tool,
|
|
10
|
+
prints the tool's text result, and exits. Reads until the matching response id
|
|
11
|
+
arrives (no sleep-based timing).
|
|
12
|
+
|
|
13
|
+
This client is the plugin's mutation chokepoint (blueprint §3 / blocker B1): every
|
|
14
|
+
`query`/`exec` SQL string is run through the verb-class statement classifier
|
|
15
|
+
(`sql_classifier.py`) and gated BEFORE it reaches the server. Reads execute freely;
|
|
16
|
+
safe-writes require `--allow-mutation` on a non-`main` agent branch and a GA flavor;
|
|
17
|
+
history-affecting statements (push / merge / reset --hard / branch-delete / DROP
|
|
18
|
+
DATABASE / unknown CALL) are always refused (recommend-only).
|
|
19
|
+
|
|
20
|
+
Requires: python3 (stdlib only) + a PINNED `dolt-mcp-server` on PATH. The plugin
|
|
21
|
+
pins the binary (blocker B4) — do NOT install `@latest`:
|
|
22
|
+
go install github.com/dolthub/dolt-mcp/mcp/cmd/dolt-mcp-server@v0.3.6
|
|
23
|
+
Pinned: github.com/dolthub/dolt-mcp v0.3.6
|
|
24
|
+
Module checksum (verified by `go install @v0.3.6` against sum.golang.org):
|
|
25
|
+
h1:uwjh1zf0er51VBT6uY3tI7JLj5pYxWyk9uB6CYQOhfU=
|
|
26
|
+
A bump is proposed only by the dolt-watch routine (never auto-trusted).
|
|
27
|
+
|
|
28
|
+
Usage:
|
|
29
|
+
dolt-mcp-client.py --port 35579 --database beads query "SELECT COUNT(*) FROM issues"
|
|
30
|
+
dolt-mcp-client.py --port 35579 list_databases
|
|
31
|
+
echo "SELECT ..." | dolt-mcp-client.py --port 35579 --database beads query -
|
|
32
|
+
# a safe-write must opt in AND target an agent branch (never main):
|
|
33
|
+
dolt-mcp-client.py --port 35579 --database beads --allow-mutation \
|
|
34
|
+
--branch agent/my-task exec "INSERT INTO issues ..."
|
|
35
|
+
|
|
36
|
+
Connection defaults come from env when flags are omitted:
|
|
37
|
+
DOLT_HOST (127.0.0.1), DOLT_PORT, DOLT_USER (root), DOLT_DATABASE, DOLT_PASSWORD ('')
|
|
38
|
+
DOLT_MATURITY (ga) — alpha/experimental restrict the connection to read-only.
|
|
39
|
+
Exit codes: 0 ok · 2 bad usage · 3 binary missing · 4 connection/tool error ·
|
|
40
|
+
5 timeout · 6 mutation refused by the verb-class gate
|
|
41
|
+
"""
|
|
42
|
+
import argparse
|
|
43
|
+
import json
|
|
44
|
+
import os
|
|
45
|
+
import shutil
|
|
46
|
+
import subprocess
|
|
47
|
+
import sys
|
|
48
|
+
import threading
|
|
49
|
+
|
|
50
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
51
|
+
from sql_classifier import gate_decision # noqa: E402
|
|
52
|
+
|
|
53
|
+
BIN = "dolt-mcp-server"
|
|
54
|
+
PINNED_VERSION = "v0.3.6" # blocker B4 — see module docstring; bump only via dolt-watch
|
|
55
|
+
INSTALL_HINT = (f"go install github.com/dolthub/dolt-mcp/mcp/cmd/"
|
|
56
|
+
f"dolt-mcp-server@{PINNED_VERSION}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def eprint(*a):
|
|
60
|
+
print(*a, file=sys.stderr)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def main():
|
|
64
|
+
ap = argparse.ArgumentParser(description="stdio client for dolthub/dolt-mcp")
|
|
65
|
+
ap.add_argument("--host", default=os.environ.get("DOLT_HOST", "127.0.0.1"))
|
|
66
|
+
ap.add_argument("--port", default=os.environ.get("DOLT_PORT"))
|
|
67
|
+
ap.add_argument("--user", default=os.environ.get("DOLT_USER", "root"))
|
|
68
|
+
ap.add_argument("--database", default=os.environ.get("DOLT_DATABASE", "information_schema"))
|
|
69
|
+
ap.add_argument("--branch", default=os.environ.get("DOLT_BRANCH", "main"),
|
|
70
|
+
help="working branch (the dolt-mcp query/exec tools require it; default main)")
|
|
71
|
+
ap.add_argument("--password", default=os.environ.get("DOLT_PASSWORD", ""))
|
|
72
|
+
ap.add_argument("--maturity", default=os.environ.get("DOLT_MATURITY", "ga"),
|
|
73
|
+
help="flavor maturity (ga|beta|alpha|experimental); pre-GA is read-only")
|
|
74
|
+
ap.add_argument("--allow-mutation", action="store_true",
|
|
75
|
+
help="opt in to safe-write SQL (still refused on main / pre-GA / for "
|
|
76
|
+
"history-affecting statements)")
|
|
77
|
+
ap.add_argument("--timeout", type=float, default=25.0, help="seconds")
|
|
78
|
+
ap.add_argument("tool", help="MCP tool name, e.g. query, exec, list_databases, list_dolt_commits")
|
|
79
|
+
ap.add_argument("sql", nargs="?", help="SQL for query/exec ('-' to read from stdin)")
|
|
80
|
+
args = ap.parse_args()
|
|
81
|
+
|
|
82
|
+
if not args.port:
|
|
83
|
+
eprint("error: --port (or DOLT_PORT) is required")
|
|
84
|
+
return 2
|
|
85
|
+
if not shutil.which(BIN):
|
|
86
|
+
eprint(f"error: '{BIN}' not found on PATH. Install (pinned): {INSTALL_HINT}")
|
|
87
|
+
return 3
|
|
88
|
+
|
|
89
|
+
# Build tool arguments
|
|
90
|
+
tool_args = {}
|
|
91
|
+
if args.tool in ("query", "exec"):
|
|
92
|
+
sql = args.sql
|
|
93
|
+
if sql == "-" or (sql is None and not sys.stdin.isatty()):
|
|
94
|
+
sql = sys.stdin.read()
|
|
95
|
+
if not sql or not sql.strip():
|
|
96
|
+
eprint(f"error: tool '{args.tool}' requires a SQL string")
|
|
97
|
+
return 2
|
|
98
|
+
sql = sql.strip()
|
|
99
|
+
# The mutation chokepoint (blocker B1): classify + gate BEFORE the server call.
|
|
100
|
+
allowed, verb_class, reason = gate_decision(
|
|
101
|
+
sql, allow_mutation=args.allow_mutation,
|
|
102
|
+
branch=args.branch, maturity=args.maturity)
|
|
103
|
+
if not allowed:
|
|
104
|
+
eprint(f"refused [{verb_class}]: {reason}")
|
|
105
|
+
return 6
|
|
106
|
+
tool_args["query"] = sql
|
|
107
|
+
tool_args["working_database"] = args.database
|
|
108
|
+
tool_args["working_branch"] = args.branch # server enforces this for query/exec
|
|
109
|
+
elif args.tool in ("list_dolt_commits", "list_dolt_branches", "show_tables"):
|
|
110
|
+
tool_args["working_database"] = args.database
|
|
111
|
+
if args.tool == "list_dolt_commits":
|
|
112
|
+
tool_args["working_branch"] = args.branch
|
|
113
|
+
|
|
114
|
+
cmd = [BIN, "--stdio", "--dolt",
|
|
115
|
+
"--host", args.host, "--port", str(args.port),
|
|
116
|
+
"--user", args.user, "--database", args.database]
|
|
117
|
+
env = dict(os.environ)
|
|
118
|
+
if args.password:
|
|
119
|
+
env["DOLT_PASSWORD"] = args.password
|
|
120
|
+
|
|
121
|
+
requests = [
|
|
122
|
+
{"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
|
123
|
+
"params": {"protocolVersion": "2024-11-05", "capabilities": {},
|
|
124
|
+
"clientInfo": {"name": "dolt-mcp-client", "version": "0.1"}}},
|
|
125
|
+
{"jsonrpc": "2.0", "method": "notifications/initialized"},
|
|
126
|
+
{"jsonrpc": "2.0", "id": 2, "method": "tools/call",
|
|
127
|
+
"params": {"name": args.tool, "arguments": tool_args}},
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
|
132
|
+
stderr=subprocess.PIPE, text=True, env=env)
|
|
133
|
+
except OSError as e:
|
|
134
|
+
eprint(f"error: failed to spawn {BIN}: {e}")
|
|
135
|
+
return 3
|
|
136
|
+
|
|
137
|
+
# Watchdog: kill the process if it runs past the timeout
|
|
138
|
+
timer = threading.Timer(args.timeout, proc.kill)
|
|
139
|
+
timer.start()
|
|
140
|
+
try:
|
|
141
|
+
for r in requests:
|
|
142
|
+
proc.stdin.write(json.dumps(r) + "\n")
|
|
143
|
+
proc.stdin.flush()
|
|
144
|
+
|
|
145
|
+
result_text, err = None, None
|
|
146
|
+
for line in proc.stdout:
|
|
147
|
+
line = line.strip()
|
|
148
|
+
if not line.startswith("{"):
|
|
149
|
+
continue
|
|
150
|
+
try:
|
|
151
|
+
o = json.loads(line)
|
|
152
|
+
except json.JSONDecodeError:
|
|
153
|
+
continue
|
|
154
|
+
if o.get("id") == 2:
|
|
155
|
+
if "error" in o:
|
|
156
|
+
err = o["error"].get("message", str(o["error"]))
|
|
157
|
+
else:
|
|
158
|
+
res = o.get("result", {})
|
|
159
|
+
parts = [c.get("text", "") for c in res.get("content", []) if c.get("type") == "text"]
|
|
160
|
+
result_text = "\n".join(parts)
|
|
161
|
+
if res.get("isError"):
|
|
162
|
+
err = result_text or "tool reported isError"
|
|
163
|
+
result_text = None
|
|
164
|
+
break
|
|
165
|
+
finally:
|
|
166
|
+
timer.cancel()
|
|
167
|
+
try:
|
|
168
|
+
proc.stdin.close()
|
|
169
|
+
except Exception:
|
|
170
|
+
pass
|
|
171
|
+
proc.terminate()
|
|
172
|
+
|
|
173
|
+
if not timer.is_alive() and result_text is None and err is None:
|
|
174
|
+
eprint(f"error: timed out after {args.timeout}s")
|
|
175
|
+
return 5
|
|
176
|
+
if err is not None:
|
|
177
|
+
eprint(f"MCP error: {err}")
|
|
178
|
+
return 4
|
|
179
|
+
if result_text is None:
|
|
180
|
+
eprint("error: no result returned (connection failed?). stderr:")
|
|
181
|
+
eprint((proc.stderr.read() or "").strip()[:500])
|
|
182
|
+
return 4
|
|
183
|
+
print(result_text)
|
|
184
|
+
return 0
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
if __name__ == "__main__":
|
|
188
|
+
sys.exit(main())
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# dolt-push-dolthub.sh — flush + push a bd workspace's Dolt database to its
|
|
3
|
+
# DoltHub remote. Built to run on a schedule (cron/systemd timer) so DoltHub
|
|
4
|
+
# stays current, instead of pushing per-command (too slow). Idempotent and safe
|
|
5
|
+
# to run when nothing changed.
|
|
6
|
+
#
|
|
7
|
+
# Safety (blocker B2):
|
|
8
|
+
# * A failed `bd export` flush ABORTS the push — we never push on an unverified
|
|
9
|
+
# flush (the `|| true` swallow that masked flush failures was removed).
|
|
10
|
+
# * A flock guard makes overlapping scheduled runs a no-op (no double-apply).
|
|
11
|
+
# * On an ambiguous push (non-zero exit), we poll the DoltHub SQL API to read
|
|
12
|
+
# the TERMINAL state before reporting — a push that timed out client-side may
|
|
13
|
+
# have actually landed; we surface the real state instead of blind-retrying.
|
|
14
|
+
# The script itself never auto-retries; a Dolt push is idempotent, so a human
|
|
15
|
+
# (or the next scheduled run) can safely re-run it.
|
|
16
|
+
#
|
|
17
|
+
# Usage: dolt-push-dolthub.sh [WORKSPACE_DIR] [--remote NAME]
|
|
18
|
+
# WORKSPACE_DIR bd workspace to push (default: current dir). Must contain .beads/.
|
|
19
|
+
# --remote NAME Dolt remote to push (default: origin).
|
|
20
|
+
#
|
|
21
|
+
# Cron example (every 20 min):
|
|
22
|
+
# */20 * * * * /path/to/dolt-push-dolthub.sh ~/my-workspace >> ~/.local/state/dolt-push.log 2>&1
|
|
23
|
+
#
|
|
24
|
+
# Requires: bd >= 1.0.4 with a Dolt remote already configured
|
|
25
|
+
# (bd dolt remote add origin https://doltremoteapi.dolthub.com/ORG/REPO).
|
|
26
|
+
# Optional: curl (enables the DoltHub SQL-API terminal-state poll).
|
|
27
|
+
# Exit: 0 pushed or nothing-to-do · 2 bad usage · 3 no remote configured ·
|
|
28
|
+
# 4 push failed (and the poll could not confirm it landed) ·
|
|
29
|
+
# 5 flush failed (did NOT push) · 0 also when a failed push is confirmed-landed by the poll.
|
|
30
|
+
set -uo pipefail
|
|
31
|
+
|
|
32
|
+
WORKSPACE="."; REMOTE="origin"
|
|
33
|
+
while [ $# -gt 0 ]; do
|
|
34
|
+
case "$1" in
|
|
35
|
+
--remote) REMOTE="$2"; shift 2 ;;
|
|
36
|
+
-*) echo "unknown arg: $1" >&2; exit 2 ;;
|
|
37
|
+
*) WORKSPACE="$1"; shift ;;
|
|
38
|
+
esac
|
|
39
|
+
done
|
|
40
|
+
command -v bd >/dev/null 2>&1 || { echo "error: bd not on PATH" >&2; exit 2; }
|
|
41
|
+
cd "$WORKSPACE" || { echo "error: cannot cd to $WORKSPACE" >&2; exit 2; }
|
|
42
|
+
[ -d .beads ] || { echo "error: $WORKSPACE is not a bd workspace (no .beads/)" >&2; exit 2; }
|
|
43
|
+
|
|
44
|
+
ts() { date '+%Y-%m-%dT%H:%M:%S'; }
|
|
45
|
+
|
|
46
|
+
# Idempotency guard: serialize concurrent runs for THIS workspace so two timers
|
|
47
|
+
# can't push the same workspace at once. Non-blocking — a second run just exits.
|
|
48
|
+
LOCKDIR="${XDG_STATE_HOME:-$HOME/.local/state}/dolt-push"
|
|
49
|
+
mkdir -p "$LOCKDIR"
|
|
50
|
+
LOCK="$LOCKDIR/$(echo "$PWD" | tr '/' '_').lock"
|
|
51
|
+
exec 9>"$LOCK"
|
|
52
|
+
flock -n 9 || { echo "[$(ts)] another push for $PWD is in progress — skip."; exit 0; }
|
|
53
|
+
|
|
54
|
+
# No remote => nothing to push; say so clearly (the #1 "beads not in DoltHub" cause).
|
|
55
|
+
if ! bd dolt remote list 2>/dev/null | grep -q "$REMOTE"; then
|
|
56
|
+
echo "[$(ts)] no Dolt remote '$REMOTE' configured in $WORKSPACE — nothing pushed." >&2
|
|
57
|
+
echo " fix: bd dolt remote add $REMOTE https://doltremoteapi.dolthub.com/ORG/REPO" >&2
|
|
58
|
+
exit 3
|
|
59
|
+
fi
|
|
60
|
+
|
|
61
|
+
# Poll the DoltHub SQL API for a terminal state (a cheap COUNT(*) read). Best-effort:
|
|
62
|
+
# resolves ORG/REPO from the remote URL; prints the count or nothing. Never fatal.
|
|
63
|
+
poll_remote_terminal() {
|
|
64
|
+
command -v curl >/dev/null 2>&1 || return 1
|
|
65
|
+
local url org_repo
|
|
66
|
+
url="$(bd dolt remote list 2>/dev/null | grep -oE 'https://doltremoteapi\.dolthub\.com/[^ ]+' | head -1)"
|
|
67
|
+
[ -n "$url" ] || return 1
|
|
68
|
+
org_repo="${url#https://doltremoteapi.dolthub.com/}"
|
|
69
|
+
org_repo="${org_repo%/}"
|
|
70
|
+
curl -sf --max-time 15 \
|
|
71
|
+
"https://www.dolthub.com/api/v1alpha1/${org_repo}/main?q=SELECT%20COUNT(*)%20AS%20n%20FROM%20issues" \
|
|
72
|
+
2>/dev/null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
# Flush the JSONL projection. A failed flush is a real signal — DO NOT push on it.
|
|
76
|
+
if ! bd export >/dev/null 2>&1; then
|
|
77
|
+
echo "[$(ts)] bd export (flush) FAILED — NOT pushing on an unverified flush." >&2
|
|
78
|
+
echo " The Dolt DB may be locked or the server down. Resolve, then re-run." >&2
|
|
79
|
+
exit 5
|
|
80
|
+
fi
|
|
81
|
+
|
|
82
|
+
echo "[$(ts)] pushing $WORKSPACE -> remote '$REMOTE' ..."
|
|
83
|
+
if bd dolt push --remote "$REMOTE" 2>&1; then
|
|
84
|
+
echo "[$(ts)] push complete."
|
|
85
|
+
exit 0
|
|
86
|
+
fi
|
|
87
|
+
|
|
88
|
+
# Ambiguous failure: poll the remote's terminal state before declaring defeat. The
|
|
89
|
+
# push may have landed despite a client-side error; report what actually happened.
|
|
90
|
+
echo "[$(ts)] push returned non-zero — polling DoltHub for terminal state ..." >&2
|
|
91
|
+
if poll_remote_terminal >/dev/null 2>&1; then
|
|
92
|
+
echo "[$(ts)] remote is reachable and reflects an 'issues' table — the push likely landed." >&2
|
|
93
|
+
echo " Verify: curl the DoltHub SQL API for COUNT(*); re-running this script is safe (idempotent)." >&2
|
|
94
|
+
exit 0
|
|
95
|
+
fi
|
|
96
|
+
echo "[$(ts)] push FAILED and could not be confirmed landed (remote unreachable, creds, or DoltHub repo missing)." >&2
|
|
97
|
+
echo " Not auto-retrying — a Dolt push is idempotent, so re-run after fixing the cause." >&2
|
|
98
|
+
exit 4
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# epic-closure-audit.sh — find OPEN epics whose entire child set is already closed
|
|
3
|
+
# (the "stale-open epic" drift: every parent-child child is closed but the epic
|
|
4
|
+
# itself is still open, so its GitHub/Plane cluster issue never gets the close
|
|
5
|
+
# fan-out). Runs the audit SQL through the dolt-mcp server via dolt-mcp-client.py.
|
|
6
|
+
#
|
|
7
|
+
# Usage: epic-closure-audit.sh # uses $DOLT_PORT / $DOLT_DATABASE
|
|
8
|
+
# DOLT_PORT=35579 DOLT_DATABASE=beads epic-closure-audit.sh
|
|
9
|
+
# epic-closure-audit.sh --port 35579 --database beads
|
|
10
|
+
#
|
|
11
|
+
# Requires: python3, dolt-mcp-server on PATH, a running bd dolt sql-server.
|
|
12
|
+
# Exit: 0 ok (whether or not drift was found) · non-zero on connection/tool error.
|
|
13
|
+
set -euo pipefail
|
|
14
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
15
|
+
|
|
16
|
+
PORT="${DOLT_PORT:-}"; DB="${DOLT_DATABASE:-}"
|
|
17
|
+
while [ $# -gt 0 ]; do
|
|
18
|
+
case "$1" in
|
|
19
|
+
--port) PORT="$2"; shift 2 ;;
|
|
20
|
+
--database) DB="$2"; shift 2 ;;
|
|
21
|
+
*) echo "unknown arg: $1" >&2; exit 2 ;;
|
|
22
|
+
esac
|
|
23
|
+
done
|
|
24
|
+
[ -n "$PORT" ] || { echo "error: set --port or DOLT_PORT (see 'bd dolt show')" >&2; exit 2; }
|
|
25
|
+
[ -n "$DB" ] || { echo "error: set --database or DOLT_DATABASE (see 'bd dolt show')" >&2; exit 2; }
|
|
26
|
+
|
|
27
|
+
read -r -d '' SQL <<'EOSQL' || true
|
|
28
|
+
SELECT e.id AS epic, COUNT(d.issue_id) AS children,
|
|
29
|
+
SUM(CASE WHEN c.status='closed' THEN 1 ELSE 0 END) AS closed,
|
|
30
|
+
LEFT(e.title,60) AS title
|
|
31
|
+
FROM issues e
|
|
32
|
+
JOIN dependencies d ON d.depends_on_id=e.id AND d.type='parent-child'
|
|
33
|
+
JOIN issues c ON c.id=d.issue_id
|
|
34
|
+
WHERE e.issue_type='epic' AND e.status<>'closed'
|
|
35
|
+
GROUP BY e.id, e.title
|
|
36
|
+
HAVING children>0 AND closed=children
|
|
37
|
+
ORDER BY children DESC
|
|
38
|
+
EOSQL
|
|
39
|
+
|
|
40
|
+
echo "# Epic-closure drift audit — database '$DB' (port $PORT)"
|
|
41
|
+
echo "# OPEN epics whose every parent-child child is already closed (candidates to close)."
|
|
42
|
+
OUT="$(python3 "$SCRIPT_DIR/dolt-mcp-client.py" --port "$PORT" --database "$DB" query "$SQL")"
|
|
43
|
+
echo "$OUT"
|
|
44
|
+
# Rows beyond the header/separator => drift found
|
|
45
|
+
if [ "$(printf '%s\n' "$OUT" | sed '1,2d' | grep -c .)" -eq 0 ]; then
|
|
46
|
+
echo "# ✓ No stale-open-epic drift found."
|
|
47
|
+
else
|
|
48
|
+
echo "# ⚠ Above epics have all children closed — close each via: bd-sync close <epic> --also-close-gh"
|
|
49
|
+
fi
|