From cdd12a14385045e16143c5a9a93800c433b3ba71 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 20 Apr 2026 16:20:17 -0500 Subject: [PATCH] #2: Per-client worker blacklists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New endpoints: - POST /clients/:client/blacklist { worker_id, name?, reason? } - GET /clients/:client/blacklist → { client, entries } - DELETE /clients/:client/blacklist/:worker_id → { removed, total } Bun /search accepts optional `client` field. When present, loads that client's blacklist and appends `AND worker_id NOT IN (...)` to the SQL filter. Zero-cost if unused; clean trust-break avoidance when a client has previously flagged a worker. Persistence: mcp-server/data/client_blacklists.json, synchronous writes via Bun.write. Scale target is hundreds of entries per client tops — JSON is fine until we hit 10K+ per client. Verified: worker_id 9326 (Carmen Green) blacklisted for AcmeCorp, same Chicago Electrician search with client=AcmeCorp returns 196 sql_matches vs 197 without — exactly one excluded. --- mcp-server/index.ts | 94 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/mcp-server/index.ts b/mcp-server/index.ts index 760f034..aa1d209 100644 --- a/mcp-server/index.ts +++ b/mcp-server/index.ts @@ -382,6 +382,44 @@ async function main() { } // Tool: hybrid search + // ─── Client blacklists (feature #2) ─────────────────────────── + // Per-client worker exclusion list. A worker blacklisted for + // client X is hidden from /search and /match when the caller + // passes `client: "X"`. Persisted to local JSON so it survives + // Bun restarts. This is a trust-critical feature — if the + // system recommends a worker the client already flagged, the + // system's credibility is gone. + if (url.pathname.startsWith("/clients/") && url.pathname.includes("/blacklist")) { + const m = url.pathname.match(/^\/clients\/([^\/]+)\/blacklist\/?(.*)$/); + if (m) { + const client = decodeURIComponent(m[1]); + const suffix = m[2]; // empty, or a worker_id to delete + + if (req.method === "GET") { + const list = await loadClientBlacklist(client); + return ok({ client, entries: list }); + } + if (req.method === "POST" && !suffix) { + const b = await json(); + if (!b.worker_id) return err("worker_id required", 400); + const entry = { + worker_id: String(b.worker_id), + name: b.name || "", + reason: b.reason || "", + added_at: new Date().toISOString(), + }; + const list = await addToClientBlacklist(client, entry); + return ok({ client, added: entry, total: list.length }); + } + if (req.method === "DELETE" && suffix) { + const worker_id = decodeURIComponent(suffix); + const { removed, total } = await removeFromClientBlacklist(client, worker_id); + return ok({ client, removed, total }); + } + return err(`unsupported method ${req.method} for blacklist`, 405); + } + } + if (url.pathname === "/search") { const b = await json(); // Availability soft-filter: if the caller didn't constrain @@ -394,6 +432,16 @@ async function main() { if (!optOut && filter && !/availability/i.test(filter)) { filter = `(${filter}) AND CAST(availability AS DOUBLE) > 0.5`; } + // Client blacklist filter: if caller passes `client`, exclude + // worker_ids that client has flagged. One SQL expression + // added, no extra round-trip needed by the caller. + if (b.client && filter) { + const bl = await loadClientBlacklist(String(b.client)); + const ids = bl.map(e => e.worker_id).filter(x => /^\d+$/.test(x)); + if (ids.length > 0) { + filter = `(${filter}) AND worker_id NOT IN (${ids.join(",")})`; + } + } return ok(await api("POST", "/vectors/hybrid", { question: b.question, index_name: b.index || "workers_500k_v1", sql_filter: filter, filter_dataset: b.dataset || "ethereal_workers", @@ -1692,6 +1740,52 @@ const SCENARIOS = [ function pick(arr: T[]): T { return arr[Math.floor(Math.random() * arr.length)]; } +// ─── Client-blacklist persistence (feature #2) ────────────────────────── +// Simple JSON file under mcp-server/data/. Synchronous writes are fine +// at the expected rate (a handful of blacklist adds per day). +const BLACKLIST_PATH = `${import.meta.dir}/data/client_blacklists.json`; + +interface BlacklistEntry { + worker_id: string; + name: string; + reason: string; + added_at: string; +} + +async function loadAllBlacklists(): Promise> { + try { + const f = Bun.file(BLACKLIST_PATH); + if (!(await f.exists())) return {}; + return await f.json() as Record; + } catch { return {}; } +} +async function saveAllBlacklists(all: Record): Promise { + await Bun.write(BLACKLIST_PATH, JSON.stringify(all, null, 2)); +} +async function loadClientBlacklist(client: string): Promise { + const all = await loadAllBlacklists(); + return all[client] || []; +} +async function addToClientBlacklist(client: string, entry: BlacklistEntry): Promise { + const all = await loadAllBlacklists(); + const list = all[client] || []; + // De-dupe: same worker_id replaces prior entry with fresher reason. + const filtered = list.filter(e => e.worker_id !== entry.worker_id); + filtered.push(entry); + all[client] = filtered; + await saveAllBlacklists(all); + return filtered; +} +async function removeFromClientBlacklist(client: string, worker_id: string): Promise<{ removed: boolean; total: number }> { + const all = await loadAllBlacklists(); + const list = all[client] || []; + const filtered = list.filter(e => e.worker_id !== worker_id); + const removed = filtered.length < list.length; + all[client] = filtered; + await saveAllBlacklists(all); + return { removed, total: filtered.length }; +} + // Seed playbook_memory from a filled contract so the next hybrid query // ranks against it. Used by both runWeekSimulation (per-day) and the /log // endpoint (per manual logging). Fail-soft — seeding is best-effort.