Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions mcp-server/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions mcp-server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -848,6 +848,7 @@ async function knowledgeCreate({
tags,
project_id,
draft,
force,
source_type,
source_id,
idempotency_key,
Expand All @@ -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",
Expand Down Expand Up @@ -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 " +
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion mcp-server/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
27 changes: 26 additions & 1 deletion mcp-server/test/knowledge_tools.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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",
Expand Down Expand Up @@ -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);
});
});
Loading