From 2d73d4456a204e1770ba7d4b8b1ca3669710fef1 Mon Sep 17 00:00:00 2001 From: mkreyman Date: Tue, 30 Jun 2026 15:32:18 -0600 Subject: [PATCH] feat(mcp): surface the novelty gate in knowledge_create + force bypass (v2.25.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The novelty-gated write-back (#222) is enforced server-side, so MCP clients already get gated behavior. This makes the agent AWARE of it: knowledge_create's description now explains the gate.verdict outcomes (duplicate → nothing created, read data.id; gated_to_draft → created as draft with metadata.proposal_novelty; created → novel), and adds a force:true param to bypass the gate. +2 tests for the force passthrough. Bumps to 2.25.0 (mcp-autopublish publishes on package.json version change). --- mcp-server/CHANGELOG.md | 15 ++++++++++++++ mcp-server/index.js | 19 +++++++++++++++++ mcp-server/package.json | 2 +- mcp-server/test/knowledge_tools.test.js | 27 ++++++++++++++++++++++++- 4 files changed, 61 insertions(+), 2 deletions(-) diff --git a/mcp-server/CHANGELOG.md b/mcp-server/CHANGELOG.md index 36342f7..092a199 100644 --- a/mcp-server/CHANGELOG.md +++ b/mcp-server/CHANGELOG.md @@ -5,6 +5,21 @@ All notable changes to `loopctl-mcp-server` are documented here. Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html) +## 2.25.0 — 2026-06-30 (novelty-gated write-back) + +### Added + +- **`knowledge_create`** now documents the server-side **novelty gate** and exposes + a `force` param. The gate semantically dedups a proposal against the published + corpus and returns a `gate.verdict`: + - `duplicate` — a near-identical article exists; **nothing is created** (HTTP 200, + `deduplicated: true`). Read/update the article at `data.id` instead. + - `gated_to_draft` — high overlap; created as a **draft** (not published) with the + near-neighbors in `metadata.proposal_novelty` to merge or publish. + - `created` — novel, went through normally. + - `force: true` bypasses the gate. The gate was already enforced server-side; this + release just makes the tool describe it and adds the bypass knob. + ## 2.24.0 — 2026-06-30 (pagination: analytics rankings offset) ### Added diff --git a/mcp-server/index.js b/mcp-server/index.js index ff944ca..0d78b0c 100755 --- a/mcp-server/index.js +++ b/mcp-server/index.js @@ -848,6 +848,7 @@ async function knowledgeCreate({ tags, project_id, draft, + force, source_type, source_id, idempotency_key, @@ -868,6 +869,10 @@ async function knowledgeCreate({ // knowledge_publish. if (draft) payload.draft = true; + // The server-side novelty gate dedups the proposal against the corpus (verdict in + // the response `gate`). force:true bypasses it. + if (force) payload.force = true; + const result = await apiCall( "POST", "/api/v1/articles", @@ -2565,6 +2570,13 @@ const TOOLS = [ "to agents (search/index/context) right away — no separate publish step is needed. Pass " + "draft: true to stage the article for later review instead; the response `note` says which " + "outcome occurred, and a draft can be published afterwards with knowledge_publish. " + + "NOVELTY GATE (default ON): the server semantically dedups your proposal against the published " + + "corpus and returns a `gate.verdict` — `duplicate` means a near-identical article already exists, " + + "so NOTHING was created (HTTP 200, `deduplicated: true`); read/update the article at `data.id` " + + "instead (its `gate.similarity` ~1.0). `gated_to_draft` means high overlap, so the article was " + + "created as a DRAFT (not published) with the near-neighbors in metadata.proposal_novelty for you " + + "to merge or publish. `created` means it was novel and went through normally. Pass force: true to " + + "bypass the gate when you intentionally want an article near an existing one. " + "Concurrency-safe: if a create races/retries against an " + "existing article with the same title AND an identical body (ignoring surrounding whitespace), the " + "server returns that existing article idempotently (HTTP 200) instead of a 422. A same-title create " + @@ -2600,6 +2612,13 @@ const TOOLS = [ "Optional: stage as a draft instead of publishing on create (default false → " + "published immediately). Publish later with knowledge_publish.", }, + force: { + type: "boolean", + description: + "Optional: bypass the novelty gate (default false). When true, the server skips " + + "semantic dedup and creates on the requested path even if a near-duplicate exists. " + + "Use only when you've already checked and intend an article close to an existing one.", + }, idempotency_key: { type: "string", description: diff --git a/mcp-server/package.json b/mcp-server/package.json index 0a0de44..cebfc74 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "loopctl-mcp-server", - "version": "2.24.0", + "version": "2.25.0", "description": "MCP server for loopctl — structural trust for AI development loops", "type": "module", "main": "index.js", diff --git a/mcp-server/test/knowledge_tools.test.js b/mcp-server/test/knowledge_tools.test.js index e7bf15c..ace3714 100644 --- a/mcp-server/test/knowledge_tools.test.js +++ b/mcp-server/test/knowledge_tools.test.js @@ -218,7 +218,7 @@ async function knowledgeContext({ query, project_id, story_id, limit, recency_we return toContent(result); } -async function knowledgeCreate({ title, body, category, tags, project_id, draft }) { +async function knowledgeCreate({ title, body, category, tags, project_id, draft, force }) { const payload = { title, body }; if (category) payload.category = category; if (tags) payload.tags = tags; @@ -229,6 +229,9 @@ async function knowledgeCreate({ title, body, category, tags, project_id, draft // draft:true to stage it for later review instead. if (draft) payload.draft = true; + // The server-side novelty gate dedups the proposal; force:true bypasses it. + if (force) payload.force = true; + const result = await apiCall( "POST", "/api/v1/articles", @@ -1096,3 +1099,25 @@ describe("knowledge_bulk_delete (#136)", () => { assert.doesNotMatch(result.content[0].text, /WARNING/); }); }); + +describe("knowledge_create novelty gate (force passthrough)", () => { + test("omits force by default so the gate stays on", async () => { + setupEnv(); + const calls = mockFetch({ data: { id: "x" } }); + + await knowledgeCreate({ title: "T", body: "B" }); + + const sent = JSON.parse(calls[0].options.body); + assert.equal(sent.force, undefined); + }); + + test("forwards force: true to bypass the gate", async () => { + setupEnv(); + const calls = mockFetch({ data: { id: "x" } }); + + await knowledgeCreate({ title: "T", body: "B", force: true }); + + const sent = JSON.parse(calls[0].options.body); + assert.equal(sent.force, true); + }); +});