feat(search): A9 MCP — operators, match, pagination in the search tool (#529)
- client/read.ts search(): forward match + offset; surface the pagination envelope (total/hasMore/truncatedAtCap/offset) when the #529 server returns it (a stock upstream leaves them undefined). - index.ts search tool: document OR-default + RU/EN morphology, the "phrase"/+/- operators, match modes, limit+offset pagination and the relevance-CAP unreachable-tail caveat; add match + offset params. - server-instructions.ts: READ prose + inventory line updated to the new contract, enforced by a new tool-inventory.test.mjs assertion. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -78,7 +78,7 @@ export interface IReadMixin {
|
||||
getNode(pageId: string, nodeId: string, format?: "markdown" | "json"): any;
|
||||
searchInPage(pageId: string, query: string, opts?: SearchOptions): any;
|
||||
getTable(pageId: string, tableRef: string): any;
|
||||
search(query: string, spaceId?: string, limit?: number, opts?: { parentPageId?: string; titleOnly?: boolean }): any;
|
||||
search(query: string, spaceId?: string, limit?: number, opts?: { parentPageId?: string; titleOnly?: boolean; offset?: number; match?: "auto" | "word" | "prefix" | "substring" }): any;
|
||||
}
|
||||
|
||||
export function ReadMixin<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & IReadMixin> & TBase {
|
||||
@@ -681,13 +681,19 @@ export function ReadMixin<TBase extends GConstructor<DocmostClientContext>>(Base
|
||||
query: string,
|
||||
spaceId?: string,
|
||||
limit?: number,
|
||||
opts: { parentPageId?: string; titleOnly?: boolean } = {},
|
||||
opts: {
|
||||
parentPageId?: string;
|
||||
titleOnly?: boolean;
|
||||
offset?: number;
|
||||
match?: "auto" | "word" | "prefix" | "substring";
|
||||
} = {},
|
||||
) {
|
||||
await this.ensureAuthenticated();
|
||||
// Opt into the #443 agent-lookup mode: `substring: true` turns on the hybrid
|
||||
// substring + FTS branch that returns path + snippet + score. A stock
|
||||
// upstream server strips these unknown DTO fields (whitelist:true) and
|
||||
// silently degrades to plain FTS — see the tool-registration comment.
|
||||
// #529 unified engine: the query is parsed SERVER-SIDE (operators
|
||||
// "phrase"/+/-, OR default, RU+EN morphology, match=auto). We forward the RAW
|
||||
// query plus flags. `substring: true` is kept as a back-compat hint for a
|
||||
// stock upstream server (whitelist:true strips the unknown #529 fields and it
|
||||
// degrades to plain FTS — see the tool-registration comment).
|
||||
const payload: Record<string, any> = {
|
||||
query,
|
||||
spaceId,
|
||||
@@ -695,20 +701,31 @@ export function ReadMixin<TBase extends GConstructor<DocmostClientContext>>(Base
|
||||
};
|
||||
if (opts.parentPageId) payload.parentPageId = opts.parentPageId;
|
||||
if (opts.titleOnly) payload.titleOnly = true;
|
||||
if (opts.match) payload.match = opts.match;
|
||||
// Clamp an optional caller-supplied limit into the lookup range (1..50)
|
||||
// before forwarding; omit it when not provided so the server default applies.
|
||||
if (limit !== undefined) {
|
||||
payload.limit = Math.max(1, Math.min(50, limit));
|
||||
}
|
||||
if (opts.offset !== undefined) {
|
||||
payload.offset = Math.max(0, Math.floor(opts.offset));
|
||||
}
|
||||
const response = await this.client.post("/search", payload);
|
||||
|
||||
// Normalize both response shapes: bare array and paginated { items: [...] }
|
||||
// Normalize both response shapes: bare array and paginated { items: [...] }.
|
||||
const data = response.data?.data;
|
||||
const items = Array.isArray(data) ? data : data?.items || [];
|
||||
const filteredItems = items.map((item: any) => filterSearchResult(item));
|
||||
|
||||
// Surface the #529 pagination envelope when present (a stock upstream has
|
||||
// none — the fields are simply undefined and the caller sees just `items`).
|
||||
const envelope = Array.isArray(data) ? undefined : data;
|
||||
return {
|
||||
items: filteredItems,
|
||||
total: envelope?.total,
|
||||
hasMore: envelope?.hasMore,
|
||||
truncatedAtCap: envelope?.truncatedAtCap,
|
||||
offset: envelope?.offset,
|
||||
success: response.data?.success || false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -461,15 +461,22 @@ server.registerTool(
|
||||
"search",
|
||||
{
|
||||
description:
|
||||
"Find pages by a fragment of a technical string (hostnames, IPs, IDs " +
|
||||
"like `srv.local`, `10.0.12`, `WB-MGE-30D86B`) — one call returns each " +
|
||||
"hit's location (`path`: ancestor titles root→parent) and a `snippet` " +
|
||||
"around the first match, so you rarely need a follow-up get_page. " +
|
||||
"Matches substrings literally (dots/dashes/digits are not tokenized) as " +
|
||||
"well as full-text. Returns `{ pageId, title, path, snippet, score }` " +
|
||||
"sorted by `score` (a per-response relevance float).",
|
||||
"Search pages across the wiki. OR by default with relevance ranking " +
|
||||
"(RU+EN morphology): multi-word queries match ANY term, not all. " +
|
||||
"Operators: \"exact phrase\" (adjacent words), +term (require), -term " +
|
||||
"(exclude) — e.g. `+кофейня -архив`, `+\"воздушный шар\" кофе`. A leading " +
|
||||
"-/+ is the operator; -,.,: INSIDE a token are literal (`WB-MGE-30D86B`, " +
|
||||
"`10.0.12.5` stay one term). Technical fragments (hostnames, IPs, IDs) " +
|
||||
"auto-match as substrings; words use full-text. Each hit returns its " +
|
||||
"location (`path`: ancestor titles root→parent), a `snippet`, `score`, " +
|
||||
"`matchedTerms` and `matchedFields`, so you rarely need a follow-up " +
|
||||
"getPage. Paginate with limit + offset; the response carries " +
|
||||
"`total` (exact, permission-filtered), `hasMore` and `truncatedAtCap`. " +
|
||||
"NOTE: results past the relevance cap (~500) are unreachable by " +
|
||||
"pagination — narrow the query (add terms / +required / a spaceId) " +
|
||||
"instead when `truncatedAtCap` is true.",
|
||||
inputSchema: {
|
||||
query: z.string().min(1).describe("Search query"),
|
||||
query: z.string().min(1).describe("Search query (supports \"phrase\", +require, -exclude)"),
|
||||
spaceId: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -484,6 +491,13 @@ server.registerTool(
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Match page titles only; skip page text"),
|
||||
match: z
|
||||
.enum(["auto", "word", "prefix", "substring"])
|
||||
.optional()
|
||||
.describe(
|
||||
"Match mode (default auto: identifiers→substring, words→full-text). " +
|
||||
"Override with word/prefix/substring.",
|
||||
),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
@@ -491,12 +505,20 @@ server.registerTool(
|
||||
.max(50)
|
||||
.optional()
|
||||
.describe("Max results to return (1-50, default 10)"),
|
||||
offset: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.optional()
|
||||
.describe("Pagination offset (default 0); use with total/hasMore"),
|
||||
},
|
||||
},
|
||||
async ({ query, spaceId, parentPageId, titleOnly, limit }) => {
|
||||
async ({ query, spaceId, parentPageId, titleOnly, match, limit, offset }) => {
|
||||
const result = await docmostClient.search(query, spaceId, limit, {
|
||||
parentPageId,
|
||||
titleOnly,
|
||||
match,
|
||||
offset,
|
||||
});
|
||||
return jsonContent(result);
|
||||
},
|
||||
|
||||
@@ -40,7 +40,7 @@ import { SHARED_TOOL_SPECS, SharedToolSpec } from "./tool-specs.js";
|
||||
*/
|
||||
export const ROUTING_PROSE =
|
||||
"Docmost editing guide — choose the tool by intent. The <tool_inventory> at the end lists every tool with a one-line purpose; the notes below are the routing hints for WHEN to reach for each.\n" +
|
||||
"READ: find a page by a fragment of a technical string (hostname/IP/ID like srv.local, 10.0.12, WB-MGE-30D86B) -> search — hybrid substring + full-text, returns each hit's location (path: root->parent titles) and a snippet around the match, so you rarely need a follow-up getPage; scope with spaceId or parentPageId (a subtree), titleOnly to match titles only. A space's page HIERARCHY (or one subtree) -> getTree (one request, complete, `{pageId,title,children?}`; rootPageId for a subtree, maxDepth to trim depth — a trimmed node gets hasChildren:true); prefer it over listPages tree:true (deprecated). Have a pageId, need WHERE-AM-I / what's around it (its breadcrumbs + direct children, metadata only) -> getPageContext (one call; parent = last breadcrumb, [] for a root page). list -> listPages / listSpaces. Locate blocks and their ids CHEAPLY -> getOutline (compact top-level map; start here, not getPageJson). One block, for editing -> getNode (by attrs.id, or \"#<index>\" for tables, which carry no id) — returns MARKDOWN by default (comment anchors kept for safe write-back); pass format:\"json\" for the raw ProseMirror subtree. Find every occurrence of a string/regex ON a page (and where each is) -> searchInPage, NOT block-by-block getNode — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> getPage (Markdown, canonical for text; drops only block ids, resolved-comment anchors, and a fixed no-md-representation attr set: table spans/colwidth/bg, indent, callout.icon, orderedList.type, link internal/target/rel/class; inline <span data-comment-id> tags are comment anchors — markup, not text) or getPageJson (full ProseMirror with block ids, for those dropped attrs). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stashPage (returns a short-lived anonymous URL).\n" +
|
||||
"READ: find pages across the wiki -> search — OR by default with relevance ranking and RU+EN morphology (multi-word matches ANY term). Operators: \"exact phrase\", +require, -exclude (e.g. `+кофейня -архив`, `+\"воздушный шар\" кофе`); a leading -/+ is the operator, but -,.,: inside a token are literal (WB-MGE-30D86B, 10.0.12.5 stay one term, auto-matched as substrings). Each hit returns its location (path: root->parent titles), a snippet, score, matchedTerms/matchedFields, so you rarely need a follow-up getPage; scope with spaceId or parentPageId (a subtree), titleOnly to match titles only, match to override auto. Paginate with limit+offset; total is exact and permission-filtered, hasMore/truncatedAtCap flag more. Results past the relevance cap (~500) are UNREACHABLE by pagination — when truncatedAtCap is true, narrow the query (add terms / +required / a spaceId) rather than page deeper. A space's page HIERARCHY (or one subtree) -> getTree (one request, complete, `{pageId,title,children?}`; rootPageId for a subtree, maxDepth to trim depth — a trimmed node gets hasChildren:true); prefer it over listPages tree:true (deprecated). Have a pageId, need WHERE-AM-I / what's around it (its breadcrumbs + direct children, metadata only) -> getPageContext (one call; parent = last breadcrumb, [] for a root page). list -> listPages / listSpaces. Locate blocks and their ids CHEAPLY -> getOutline (compact top-level map; start here, not getPageJson). One block, for editing -> getNode (by attrs.id, or \"#<index>\" for tables, which carry no id) — returns MARKDOWN by default (comment anchors kept for safe write-back); pass format:\"json\" for the raw ProseMirror subtree. Find every occurrence of a string/regex ON a page (and where each is) -> searchInPage, NOT block-by-block getNode — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> getPage (Markdown, canonical for text; drops only block ids, resolved-comment anchors, and a fixed no-md-representation attr set: table spans/colwidth/bg, indent, callout.icon, orderedList.type, link internal/target/rel/class; inline <span data-comment-id> tags are comment anchors — markup, not text) or getPageJson (full ProseMirror with block ids, for those dropped attrs). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stashPage (returns a short-lived anonymous URL).\n" +
|
||||
"EDIT: fix wording/typos/numbers -> editPageText (find/replace inside blocks, no node id needed). Edit a block -> getNode(markdown) -> edit the markdown -> patchNode(markdown) (by attrs.id from getOutline; the markdown fragment may be several blocks — a 1->N section rewrite in one call, the first block keeps the id). Reach for patchNode's `node`-JSON only for fine attr/mark work; a table cell with spans/colors/fixed width -> the table tools (patchNode markdown refuses it). Add a block -> insertNode (markdown, before/after a block by attrs.id or by anchor text, or append; `node` for raw JSON or bare table structure). Remove a block -> deleteNode (by attrs.id). Tables -> tableGet / tableUpdateCell / tableInsertRow / tableDeleteRow (address by \"#<index>\" from getOutline; table nodes have no attrs.id). Images -> insertImage (add from a web URL) / replaceImage (swap an existing image). Draw.io diagrams -> PREFER the high-level semantic tools that hide coordinates/styles: drawioFromGraph (architecture/cloud/network diagrams — describe nodes/groups/edges by kind+icon, the server picks layout, colors and verified icons; hints layer/sameLayerAs/pinned and layout:full|incremental|none) and drawioFromMermaid (standard flowcharts — write Mermaid, get an editable diagram). For targeted tweaks of an existing diagram use drawioEditCells (id-based add/update/delete with cascade delete + baseHash lock). Raw mxGraph XML via drawioCreate/drawioUpdate is the escape-hatch for exotic/wireframe diagrams; drawioGet reads a diagram as mxGraph XML + a hash (pass it as baseHash to drawioUpdate/drawioEditCells for optimistic locking). Before authoring raw XML, drawioShapes (look up verified stencil style-strings so a shape name never renders as an empty box) and drawioGuide (on-demand authoring reference: skeleton/layout/containers/icons-aws/icons-azure), and pass layout:\"elk\" to drawioCreate/drawioUpdate to auto-place nodes. Footnotes -> insertFootnote. Bulk/structural rewrite -> updatePageJson (full ProseMirror replace) or updatePageMarkdown (full plain-Markdown body replace, re-imported — block ids regenerate); prefer the granular tools above to avoid resending the whole ~100KB+ document. Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmostTransform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes.\n" +
|
||||
"PAGES: new -> createPage (Markdown). Rename (title only) -> renamePage. Move -> movePage. Delete -> deletePage (SOFT delete — the page goes to trash and is restorable; nothing is permanent). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copyPageContent. Sharing -> sharePage / unsharePage / listShares; sharePage makes the page PUBLICLY accessible — do it only when explicitly asked.\n" +
|
||||
"COMMENTS: createComment is always inline and requires an EXACT selection — contiguous text from a single block, <=250 chars (fails rather than leaving an unanchored comment); reply to a thread via parentCommentId. Propose a concrete text fix for one-click human approval -> createComment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Manage -> listComments, updateComment, resolveComment (resolve/reopen, reversible — prefer over delete to close), deleteComment, checkNewComments.\n" +
|
||||
@@ -157,7 +157,7 @@ export const INLINE_MCP_INVENTORY: ToolInventoryLine[] = [
|
||||
{
|
||||
name: "search",
|
||||
purpose:
|
||||
"find pages by a fragment of a technical string (hybrid substring + full-text); returns each hit's path and a snippet.",
|
||||
"search pages across the wiki (OR default, RU+EN morphology, \"phrase\"/+/- operators, pagination); returns each hit's path, snippet, score and matched terms.",
|
||||
},
|
||||
{
|
||||
name: "docmostTransform",
|
||||
|
||||
@@ -133,3 +133,26 @@ test("SERVER_INSTRUCTIONS keeps the routing prose and the generated inventory",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// #529: the search routing prose must document the new engine contract — the
|
||||
// operators, OR/morphology default, pagination fields and the relevance-CAP
|
||||
// caveat — so an agent uses the operators and understands the unreachable tail.
|
||||
test("SERVER_INSTRUCTIONS documents the #529 search operators, pagination and CAP", () => {
|
||||
const read = ROUTING_PROSE.split("EDIT:")[0]; // the READ family section
|
||||
// Operators.
|
||||
assert.ok(/\+require/.test(read), "search prose missing +require operator");
|
||||
assert.ok(/-exclude/.test(read), "search prose missing -exclude operator");
|
||||
assert.ok(/phrase/i.test(read), "search prose missing phrase operator");
|
||||
// OR default + morphology.
|
||||
assert.ok(/\bOR\b/.test(read), "search prose missing OR-default note");
|
||||
assert.ok(/morpholog/i.test(read), "search prose missing morphology note");
|
||||
// Pagination + exact permission-filtered total.
|
||||
assert.ok(/offset/.test(read), "search prose missing offset/pagination");
|
||||
assert.ok(/total is exact/i.test(read), "search prose missing exact total");
|
||||
assert.ok(/hasMore|truncatedAtCap/.test(read), "search prose missing hasMore/cap flags");
|
||||
// The relevance CAP caveat (tail unreachable by pagination).
|
||||
assert.ok(
|
||||
/cap/i.test(read) && /unreachable/i.test(read),
|
||||
"search prose missing the relevance-CAP unreachable-tail caveat",
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user