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
23 changes: 23 additions & 0 deletions mcp-server/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,29 @@ 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.74.0 — 2026-08-12 (one search command, three response shapes)

### Added

- **`knowledge_search` takes `format`: `results` (default), `stubs`, or `bodies`.** These are
response SHAPES of one search, not different searches — the server dispatches `stubs` to the
same `progressive_index/3` and `bodies` to the same `get_context/3` that
`knowledge_progressive_index` and `knowledge_context` call.

**Nothing is retired.** Both sibling tools remain registered and work exactly as before;
they are now siblings on one path rather than separate doors to choose between.

Why it is worth having at all: an agent's choice of entrypoint is unobservable, so it
confounds any measurement of the ranking behind it — you cannot separate an algorithm's
effect from a choice you cannot see. A parameter is a variable the server controls; a tool
choice is a confounder it does not. This is the precondition for judging a ranking change
from observed behaviour.

Two refusals, deliberately not silent: `stubs` and `bodies` REQUIRE a query (they are
relevance shapes; there is no stub rendering of an enumeration page), and an unknown value
is a 400 rather than a quiet downgrade to `results`. A downgrade would answer a different
question than the one asked, and an agent could read the result as an empty corpus.

## 2.73.0 — 2026-08-12 (one spelling for the search parameter, plus a body window and prefix-tolerant ids)

### Changed
Expand Down
24 changes: 23 additions & 1 deletion mcp-server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -1497,9 +1497,16 @@ async function knowledgeFacets({
return toContent(result);
}

async function knowledgeSearch({ q, project_id, story_id, category, tags, match, mode, limit, offset }) {
async function knowledgeSearch({ q, project_id, story_id, category, tags, match, mode, format, limit, offset }) {
const params = new URLSearchParams();
if (q != null && q !== "") params.set("q", q);
// `format` is the SHAPE of the response, not a different search (#678). The server
// dispatches `stubs` to the same progressive_index/3 and `bodies` to the same
// get_context/3 that knowledge_progressive_index and knowledge_context call, so those
// tools remain and are not retired — they are now siblings on one path rather than
// separate doors an agent has to choose between. That choice was unobservable and
// therefore confounded any measurement of the ranking behind it.
if (format) params.set("format", format);
if (project_id) params.set("project_id", project_id);
if (story_id) params.set("story_id", story_id);
if (category) params.set("category", category);
Expand Down Expand Up @@ -4616,6 +4623,21 @@ const TOOLS = [
enum: ["keyword", "semantic", "combined"],
description: "Optional: search mode (keyword, semantic, or combined).",
},
format: {
type: "string",
enum: ["results", "stubs", "bodies"],
description:
"Optional: the SHAPE of the response, not a different search. 'results' " +
"(default) is ranked results plus snippets and is the only shape that " +
"supports cursor pagination. 'stubs' returns capped stubs with one hop of hub " +
"enrichment — use it to survey a broad topic without pulling bodies into " +
"context, then knowledge_progressive_drill into a chosen stub. 'bodies' " +
"returns full article bodies plus linked references for one deep read. " +
"'stubs' and 'bodies' REQUIRE a query; sending either without one is a 400, " +
"as is an unknown value (it is never silently downgraded to 'results'). These " +
"dispatch to exactly the same code knowledge_progressive_index and " +
"knowledge_context call, which both remain available.",
},
limit: {
type: "integer",
description: "Optional: maximum number of results to return.",
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.73.0",
"version": "2.74.0",
"description": "MCP server for loopctl \u2014 structural trust for AI development loops",
"type": "module",
"main": "index.js",
Expand Down
70 changes: 70 additions & 0 deletions mcp-server/test/search_format_param.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
/**
* `knowledge_search` gained a `format` parameter (loopctl#678): one command, three response
* shapes, dispatching to the same progressive_index/3 and get_context/3 the sibling tools
* call. The siblings are NOT retired.
*
* Asserted against the index.js SOURCE rather than through a re-implemented helper. The
* other suites here re-implement handler bodies by design (index.js has top-level await and
* exports nothing), but a re-implementation cannot catch the failure that matters for a
* pass-through parameter: the schema advertising a field the request builder never forwards.
* Reading the source is the only check that stays true when index.js changes.
*/

import { test, describe } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";

const here = path.dirname(fileURLToPath(import.meta.url));
const source = readFileSync(path.join(here, "..", "index.js"), "utf8");

describe("knowledge_search format parameter", () => {
test("the request builder actually forwards it", () => {
assert.match(
source,
/async function knowledgeSearch\(\{[^}]*\bformat\b[^}]*\}\)/,
"knowledgeSearch must destructure `format`",
);
assert.match(
source,
/if \(format\) params\.set\("format", format\);/,
"knowledgeSearch must forward `format` as a query param",
);
});

test("the schema advertises exactly the shapes the server accepts", () => {
const block = source.slice(source.indexOf('name: "knowledge_search"'));
const formatProp = block.slice(block.indexOf("format: {"), block.indexOf("limit: {"));

assert.ok(formatProp.includes("format: {"), "knowledge_search must declare `format`");
for (const shape of ["results", "stubs", "bodies"]) {
assert.ok(formatProp.includes(`"${shape}"`), `format enum must include ${shape}`);
}
});

test("the description tells an agent the two ways it is refused", () => {
const block = source.slice(source.indexOf('name: "knowledge_search"'));
const formatProp = block.slice(block.indexOf("format: {"), block.indexOf("limit: {"));

// Both are 400s on the server, and an agent that expects a silent downgrade to
// `results` would misread an error as an empty corpus.
assert.match(formatProp, /REQUIRE a query/, "must say stubs/bodies need a query");
assert.match(formatProp, /never silently downgraded/, "must say an unknown value 400s");
});

test("it does not claim the sibling tools were removed", () => {
const block = source.slice(source.indexOf('name: "knowledge_search"'));
const formatProp = block.slice(block.indexOf("format: {"), block.indexOf("limit: {"));

assert.match(formatProp, /both remain available/);
assert.ok(
source.includes('name: "knowledge_progressive_index"'),
"knowledge_progressive_index must still be registered",
);
assert.ok(
source.includes('name: "knowledge_context"'),
"knowledge_context must still be registered",
);
});
});
Loading