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.