diff --git a/mcp-server/CHANGELOG.md b/mcp-server/CHANGELOG.md index 79d1a6bf..faa7b4a4 100644 --- a/mcp-server/CHANGELOG.md +++ b/mcp-server/CHANGELOG.md @@ -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 diff --git a/mcp-server/index.js b/mcp-server/index.js index 38ce1f69..98370ceb 100755 --- a/mcp-server/index.js +++ b/mcp-server/index.js @@ -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); @@ -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.", diff --git a/mcp-server/package.json b/mcp-server/package.json index 92bb59fb..77391cd5 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -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", diff --git a/mcp-server/test/search_format_param.test.js b/mcp-server/test/search_format_param.test.js new file mode 100644 index 00000000..47ff82f4 --- /dev/null +++ b/mcp-server/test/search_format_param.test.js @@ -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", + ); + }); +});