From dbf7d3ebfc430f01e548a401726e41422a5aec9a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 02:09:36 +0000 Subject: [PATCH 1/6] feat: add browse_fpf_catalog and search_fpf MCP discovery tools Add a public discovery layer for Codex-facing workflows: - browse_fpf_catalog: browse compiled patterns, routes, and lexicon entries with optional part/status/kind filters - search_fpf: full-text search across all compiled nodes with ranked hits and contextual snippets Reorganize tool families per issue #15 spec: - Public: browse, search, ask, query, read - Expert: inspect node, inspect anchor, expand citations, trace - Admin: index status, refresh index Closes #15 Co-Authored-By: Stanislau --- src/mastra/mcp/server.ts | 2 +- src/mcp/tool-contracts.ts | 83 ++++++++++++++++++++++ src/mcp/tools.ts | 50 ++++++++++++-- src/runtime/runtime.ts | 141 ++++++++++++++++++++++++++++++++++++++ src/runtime/types.ts | 48 +++++++++++++ tests/mcp-server.test.ts | 14 ++-- 6 files changed, 327 insertions(+), 11 deletions(-) diff --git a/src/mastra/mcp/server.ts b/src/mastra/mcp/server.ts index 0a24847..4c4138e 100644 --- a/src/mastra/mcp/server.ts +++ b/src/mastra/mcp/server.ts @@ -12,6 +12,6 @@ export const fpfMemory = new MCPServer({ export const fpfMemoryPublic = new MCPServer({ name: 'fpf_memory', version: '1.0.0', - description: 'FPF-spec query runtime with public tool surface (ask, query, status).', + description: 'FPF-spec query runtime with public discovery surface (browse, search, ask, query, read).', tools: fpfPublicTools, }); diff --git a/src/mcp/tool-contracts.ts b/src/mcp/tool-contracts.ts index fecb267..683f1ac 100644 --- a/src/mcp/tool-contracts.ts +++ b/src/mcp/tool-contracts.ts @@ -485,3 +485,86 @@ export type ReadFpfDocInput = z.infer; export type InspectFpfAnchorInput = z.infer; export type ExpandFpfCitationsInput = z.infer; export type TraceFpfPathInput = z.infer; + +// --------------------------------------------------------------------------- +// Discovery layer schemas (browse / search) +// --------------------------------------------------------------------------- + +export const catalogEntrySchema = z + .object({ + id: z.string(), + kind: nodeKindSchema, + title: z.string(), + status: z.string().optional(), + part: z.string().optional(), + cluster: z.string().optional(), + description: z.string(), + }) + .strict(); + +export const browseFpfCatalogInputSchema = z + .object({ + part: z.string().optional(), + status: z.string().optional(), + kind: nodeKindSchema.optional(), + forceRefresh: z.boolean().optional(), + }) + .strict(); + +export const browseFpfCatalogResultSchema = z + .object({ + entries: z.array(catalogEntrySchema), + total: z.number(), + filters: z + .object({ + part: z.string().optional(), + status: z.string().optional(), + kind: nodeKindSchema.optional(), + }) + .strict(), + snapshot: z + .object({ + sourceHash: z.string(), + builtAt: z.string(), + }) + .strict(), + }) + .strict(); + +export const searchHitSchema = z + .object({ + id: z.string(), + kind: nodeKindSchema, + title: z.string(), + status: z.string().optional(), + part: z.string().optional(), + score: z.number(), + snippet: z.string(), + }) + .strict(); + +export const searchFpfInputSchema = z + .object({ + query: z.string().min(1), + kind: nodeKindSchema.optional(), + limit: z.number().int().min(1).max(100).optional(), + forceRefresh: z.boolean().optional(), + }) + .strict(); + +export const searchFpfResultSchema = z + .object({ + query: z.string(), + hits: z.array(searchHitSchema), + total: z.number(), + snapshot: z + .object({ + sourceHash: z.string(), + builtAt: z.string(), + }) + .strict(), + }) + .strict(); + +export type BrowseFpfCatalogInput = z.infer; +export type SearchFpfInput = z.infer; diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 9375b7b..16cad52 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -1,10 +1,12 @@ import { createTool } from '@mastra/core/tools'; import { FpfRuntime } from '../runtime/runtime.js'; -import type { AnswerMode, AskFpfResult, QueryResult } from '../runtime/types.js'; +import type { AnswerMode, AskFpfResult, NodeKind, QueryResult } from '../runtime/types.js'; import { askFpfInputSchema, askFpfResultSchema, + browseFpfCatalogInputSchema, + browseFpfCatalogResultSchema, expandCitationsResultSchema, expandFpfCitationsInputSchema, getFpfIndexStatusInputSchema, @@ -19,6 +21,8 @@ import { refreshFpfIndexInputSchema, runtimeStatusSchema, buildAuditSchema, + searchFpfInputSchema, + searchFpfResultSchema, traceFpfPathInputSchema, traceResultSchema, } from './tool-contracts.js'; @@ -121,27 +125,63 @@ export const traceFpfPathTool = createTool({ runtime.trace(question, mode ?? 'compact', forceRefresh ?? false, sessionId), }); +export const browseFpfCatalogTool = createTool({ + id: 'browse_fpf_catalog', + description: + 'Browse the FPF catalog of compiled patterns, routes, and lexicon entries. Filter by part, status, or kind to discover relevant material before drilling into individual nodes.', + inputSchema: browseFpfCatalogInputSchema, + outputSchema: browseFpfCatalogResultSchema, + execute: async ({ part, status, kind, forceRefresh }) => + runtime.browse({ + part, + status, + kind: kind as NodeKind | undefined, + forceRefresh: forceRefresh ?? false, + }), +}); + +export const searchFpfTool = createTool({ + id: 'search_fpf', + description: + 'Full-text search across all compiled FPF nodes. Returns ranked hits with contextual snippets. Use this to find patterns, routes, or lexicon entries by keyword or concept.', + inputSchema: searchFpfInputSchema, + outputSchema: searchFpfResultSchema, + execute: async ({ query, kind, limit, forceRefresh }) => + runtime.search(query, { + kind: kind as NodeKind | undefined, + limit, + forceRefresh: forceRefresh ?? false, + }), +}); + /** Public tools — safe for deployed MCP surface. */ export const fpfPublicTools = { + browse_fpf_catalog: browseFpfCatalogTool, + search_fpf: searchFpfTool, ask_fpf: askFpfTool, query_fpf_spec: queryFpfSpecTool, - get_fpf_index_status: getFpfIndexStatusTool, + read_fpf_doc: readFpfDocTool, } as const; /** Expert/debug tools — full-surface runtime only. */ export const fpfExpertTools = { - refresh_fpf_index: refreshFpfIndexTool, - trace_fpf_path: traceFpfPathTool, inspect_fpf_node: inspectFpfNodeTool, - read_fpf_doc: readFpfDocTool, inspect_fpf_anchor: inspectFpfAnchorTool, expand_fpf_citations: expandFpfCitationsTool, + trace_fpf_path: traceFpfPathTool, +} as const; + +/** Admin tools — index management. */ +export const fpfAdminTools = { + get_fpf_index_status: getFpfIndexStatusTool, + refresh_fpf_index: refreshFpfIndexTool, } as const; /** All tools — used by the full-surface MCP runtime. */ export const fpfMcpTools = { ...fpfPublicTools, ...fpfExpertTools, + ...fpfAdminTools, } as const; export function resolveDefaultQueryMode(env: NodeJS.ProcessEnv = process.env): AnswerMode { diff --git a/src/runtime/runtime.ts b/src/runtime/runtime.ts index 8673745..5b40860 100644 --- a/src/runtime/runtime.ts +++ b/src/runtime/runtime.ts @@ -19,18 +19,24 @@ import { } from './session-cache.js'; import type { AnswerMode, + BrowseCatalogResult, BuildAudit, + CatalogEntry, ExpandCitationsResult, IndexingView, InspectAnchorResult, InspectResult, LocalAnswerSynthesizer, + NodeKind, QueryResult, ReadDocResult, RuntimeStatus, + SearchHit, + SearchResult, Snapshot, TraceResult, } from './types.js'; +import { normalizeForLookup, tokenize, scoreOverlap } from './text.js'; import { getRuntimeObservabilitySummary } from '../observability/runtime-observability.js'; export interface FpfRuntimeOptions { @@ -258,6 +264,86 @@ export class FpfRuntime { }; } + async browse( + options: { part?: string; status?: string; kind?: NodeKind; forceRefresh?: boolean } = {}, + ): Promise { + await this.refresh(options.forceRefresh ?? false); + const snapshot = await this.requireSnapshot(); + + let entries: CatalogEntry[] = Object.values(snapshot.compiledNodes).map((node) => + nodeToCatalogEntry(node, snapshot), + ); + + if (options.kind) { + entries = entries.filter((e) => e.kind === options.kind); + } + if (options.part) { + const partLower = options.part.toLowerCase(); + entries = entries.filter((e) => e.part?.toLowerCase() === partLower); + } + if (options.status) { + const statusLower = options.status.toLowerCase(); + entries = entries.filter((e) => e.status?.toLowerCase() === statusLower); + } + + entries.sort((a, b) => a.id.localeCompare(b.id)); + + return { + entries, + total: entries.length, + filters: { + part: options.part, + status: options.status, + kind: options.kind, + }, + snapshot: { sourceHash: snapshot.sourceHash, builtAt: snapshot.builtAt }, + }; + } + + async search( + query: string, + options: { kind?: NodeKind; limit?: number; forceRefresh?: boolean } = {}, + ): Promise { + await this.refresh(options.forceRefresh ?? false); + const snapshot = await this.requireSnapshot(); + + const normalizedQuery = normalizeForLookup(query); + const queryTokens = tokenize(normalizedQuery); + const limit = Math.min(options.limit ?? 20, 100); + + const hits: SearchHit[] = []; + for (const node of Object.values(snapshot.compiledNodes)) { + if (options.kind && node.kind !== options.kind) { + continue; + } + + const score = scoreOverlap(queryTokens, node.searchableText); + if (score <= 0) { + continue; + } + + hits.push({ + id: node.id, + kind: node.kind, + title: node.title, + status: node.status, + part: node.part, + score, + snippet: extractSnippet(node.searchableText, queryTokens), + }); + } + + hits.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id)); + const trimmed = hits.slice(0, limit); + + return { + query, + hits: trimmed, + total: hits.length, + snapshot: { sourceHash: snapshot.sourceHash, builtAt: snapshot.builtAt }, + }; + } + private persistSession(sessionId: string | undefined, trace: TraceResult): void { if (!sessionId) { return; @@ -376,3 +462,58 @@ function snapshotNeedsRebuild(snapshot: Snapshot): boolean { typeof node.metadata.routeBearing !== 'boolean', ); } + +function nodeToCatalogEntry( + node: import('./types.js').CompiledNode, + snapshot: Snapshot, +): CatalogEntry { + let description = ''; + if (node.kind === 'pattern') { + const pattern = snapshot.patternGraph.nodes[node.id]; + description = pattern?.description ?? node.title; + } else if (node.kind === 'route') { + const route = snapshot.routeGraph.nodes[node.id]; + description = route?.description ?? node.title; + } else if (node.kind === 'lexeme') { + const entry = snapshot.lexicon[node.id]; + description = entry + ? `Lexicon: ${entry.canonical}${entry.aliases.length > 0 ? ` (${entry.aliases.join(', ')})` : ''}` + : node.title; + } + return { + id: node.id, + kind: node.kind, + title: node.title, + status: node.status, + part: node.part, + cluster: node.cluster, + description, + }; +} + +const SNIPPET_RADIUS = 80; + +function extractSnippet(searchableText: string, queryTokens: string[]): string { + const lower = searchableText.toLowerCase(); + let bestPos = 0; + let bestLen = 0; + + for (const token of queryTokens) { + const pos = lower.indexOf(token); + if (pos !== -1 && token.length > bestLen) { + bestPos = pos; + bestLen = token.length; + } + } + + const start = Math.max(0, bestPos - SNIPPET_RADIUS); + const end = Math.min(searchableText.length, bestPos + bestLen + SNIPPET_RADIUS); + let snippet = searchableText.slice(start, end).replace(/\s+/g, ' ').trim(); + if (start > 0) { + snippet = `…${snippet}`; + } + if (end < searchableText.length) { + snippet = `${snippet}…`; + } + return snippet; +} diff --git a/src/runtime/types.ts b/src/runtime/types.ts index 796480e..2985e32 100644 --- a/src/runtime/types.ts +++ b/src/runtime/types.ts @@ -545,3 +545,51 @@ export interface LocalAnswerSynthesizer { ): Promise | AnswerSynthesizerOutput; describe?(): LocalAnswerSynthesizerInfo; } + +// --------------------------------------------------------------------------- +// Discovery layer types (browse / search) +// --------------------------------------------------------------------------- + +export interface CatalogEntry { + id: string; + kind: NodeKind; + title: string; + status?: string; + part?: string; + cluster?: string; + description: string; +} + +export interface BrowseCatalogResult { + entries: CatalogEntry[]; + total: number; + filters: { + part?: string; + status?: string; + kind?: NodeKind; + }; + snapshot: { + sourceHash: string; + builtAt: string; + }; +} + +export interface SearchHit { + id: string; + kind: NodeKind; + title: string; + status?: string; + part?: string; + score: number; + snippet: string; +} + +export interface SearchResult { + query: string; + hits: SearchHit[]; + total: number; + snapshot: { + sourceHash: string; + builtAt: string; + }; +} diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts index ead399c..a4e7294 100644 --- a/tests/mcp-server.test.ts +++ b/tests/mcp-server.test.ts @@ -190,15 +190,17 @@ describe('Mastra MCP server', () => { }>; expect(tools.map((tool) => tool.name)).toEqual([ + 'browse_fpf_catalog', + 'search_fpf', 'ask_fpf', 'query_fpf_spec', - 'get_fpf_index_status', - 'refresh_fpf_index', - 'trace_fpf_path', - 'inspect_fpf_node', 'read_fpf_doc', + 'inspect_fpf_node', 'inspect_fpf_anchor', 'expand_fpf_citations', + 'trace_fpf_path', + 'get_fpf_index_status', + 'refresh_fpf_index', ]); for (const tool of tools) { @@ -277,9 +279,11 @@ describe('Mastra MCP server', () => { const toolsList = await harness.request('tools/list'); const tools = (toolsList.result?.tools ?? []) as Array<{ name: string }>; expect(tools.map((tool) => tool.name)).toEqual([ + 'browse_fpf_catalog', + 'search_fpf', 'ask_fpf', 'query_fpf_spec', - 'get_fpf_index_status', + 'read_fpf_doc', ]); }); From e3f053a849355cb820962e740506efbce3739069 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 02:21:10 +0000 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20limit,=20filter-before-map,=20snippets,=20AGENTS.md?= =?UTF-8?q?,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add limit param to browse_fpf_catalog (default 200, max 500) - Filter compiled nodes before mapping to catalog entries (perf) - Snap snippet boundaries to word boundaries - Fix snippet centering for collapsed/dotted-ID tokens (e.g. A.2.3) - Update AGENTS.md to reflect new public/expert/admin surface - Add 13 focused discovery-layer tests (browse + search) Co-Authored-By: Stanislau --- AGENTS.md | 11 ++- src/mcp/tool-contracts.ts | 1 + src/mcp/tools.ts | 3 +- src/runtime/runtime.ts | 84 +++++++++++++----- tests/discovery-layer.test.ts | 156 ++++++++++++++++++++++++++++++++++ 5 files changed, 229 insertions(+), 26 deletions(-) create mode 100644 tests/discovery-layer.test.ts diff --git a/AGENTS.md b/AGENTS.md index 1c89eb0..580db10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,13 +4,18 @@ Use the `fpf_memory` MCP server whenever the task requires grounded answers, exa Public tools (deployed MCP surface): +- `browse_fpf_catalog` for task-oriented discovery by part, status, or kind +- `search_fpf` for full-text search across compiled nodes - `ask_fpf` for markdown-first answers - `query_fpf_spec` for structured answer envelopes -- `get_fpf_index_status` for runtime freshness checks +- `read_fpf_doc` for exact generated markdown pages Expert tools (local full-surface runtime only, via `FPF_MCP_SURFACE=full bun run mcp`): -- `read_fpf_doc` for exact generated markdown pages -- `trace_fpf_path` for retrieval evidence and provenance - `inspect_fpf_node`, `inspect_fpf_anchor`, `expand_fpf_citations` for deep inspection +- `trace_fpf_path` for retrieval evidence and provenance + +Admin tools (index management): + +- `get_fpf_index_status` for runtime freshness checks - `refresh_fpf_index` to rebuild the local artifact set diff --git a/src/mcp/tool-contracts.ts b/src/mcp/tool-contracts.ts index 683f1ac..997c391 100644 --- a/src/mcp/tool-contracts.ts +++ b/src/mcp/tool-contracts.ts @@ -507,6 +507,7 @@ export const browseFpfCatalogInputSchema = z part: z.string().optional(), status: z.string().optional(), kind: nodeKindSchema.optional(), + limit: z.number().int().min(1).max(500).optional(), forceRefresh: z.boolean().optional(), }) .strict(); diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 16cad52..f1452ff 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -131,11 +131,12 @@ export const browseFpfCatalogTool = createTool({ 'Browse the FPF catalog of compiled patterns, routes, and lexicon entries. Filter by part, status, or kind to discover relevant material before drilling into individual nodes.', inputSchema: browseFpfCatalogInputSchema, outputSchema: browseFpfCatalogResultSchema, - execute: async ({ part, status, kind, forceRefresh }) => + execute: async ({ part, status, kind, limit, forceRefresh }) => runtime.browse({ part, status, kind: kind as NodeKind | undefined, + limit, forceRefresh: forceRefresh ?? false, }), }); diff --git a/src/runtime/runtime.ts b/src/runtime/runtime.ts index 5b40860..6c5db29 100644 --- a/src/runtime/runtime.ts +++ b/src/runtime/runtime.ts @@ -265,31 +265,29 @@ export class FpfRuntime { } async browse( - options: { part?: string; status?: string; kind?: NodeKind; forceRefresh?: boolean } = {}, + options: { part?: string; status?: string; kind?: NodeKind; limit?: number; forceRefresh?: boolean } = {}, ): Promise { await this.refresh(options.forceRefresh ?? false); const snapshot = await this.requireSnapshot(); - let entries: CatalogEntry[] = Object.values(snapshot.compiledNodes).map((node) => - nodeToCatalogEntry(node, snapshot), - ); + const partLower = options.part?.toLowerCase(); + const statusLower = options.status?.toLowerCase(); + const limit = Math.min(options.limit ?? 200, 500); - if (options.kind) { - entries = entries.filter((e) => e.kind === options.kind); - } - if (options.part) { - const partLower = options.part.toLowerCase(); - entries = entries.filter((e) => e.part?.toLowerCase() === partLower); - } - if (options.status) { - const statusLower = options.status.toLowerCase(); - entries = entries.filter((e) => e.status?.toLowerCase() === statusLower); - } + const entries = Object.values(snapshot.compiledNodes) + .filter((node) => { + if (options.kind && node.kind !== options.kind) return false; + if (partLower && node.part?.toLowerCase() !== partLower) return false; + if (statusLower && node.status?.toLowerCase() !== statusLower) return false; + return true; + }) + .map((node) => nodeToCatalogEntry(node, snapshot)); entries.sort((a, b) => a.id.localeCompare(b.id)); + const trimmed = entries.slice(0, limit); return { - entries, + entries: trimmed, total: entries.length, filters: { part: options.part, @@ -493,21 +491,63 @@ function nodeToCatalogEntry( const SNIPPET_RADIUS = 80; +function findTokenPosition( + searchableText: string, + lower: string, + token: string, +): { pos: number; len: number } | undefined { + // Try literal substring match first. + const literalPos = lower.indexOf(token); + if (literalPos !== -1) { + return { pos: literalPos, len: token.length }; + } + + // For collapsed tokens (e.g. "a23" from "A.2.3"), try matching with + // optional non-alphanumeric separators between each character. + if (token.length > 0) { + const escaped = Array.from(token).map((c) => + c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), + ); + const pattern = new RegExp(escaped.join('[^a-z0-9]*'), 'i'); + const match = pattern.exec(searchableText); + if (match && match.index !== undefined) { + return { pos: match.index, len: match[0].length }; + } + } + + return undefined; +} + function extractSnippet(searchableText: string, queryTokens: string[]): string { const lower = searchableText.toLowerCase(); let bestPos = 0; let bestLen = 0; for (const token of queryTokens) { - const pos = lower.indexOf(token); - if (pos !== -1 && token.length > bestLen) { - bestPos = pos; - bestLen = token.length; + const hit = findTokenPosition(searchableText, lower, token); + if (hit && token.length > bestLen) { + bestPos = hit.pos; + bestLen = hit.len; + } + } + + let start = Math.max(0, bestPos - SNIPPET_RADIUS); + let end = Math.min(searchableText.length, bestPos + bestLen + SNIPPET_RADIUS); + + // Snap to word boundaries to avoid cutting words in half. + if (start > 0) { + const nextSpace = searchableText.indexOf(' ', start); + if (nextSpace !== -1 && nextSpace < bestPos) { + start = nextSpace + 1; + } + } + if (end < searchableText.length) { + const prevSpace = searchableText.lastIndexOf(' ', end); + if (prevSpace > bestPos + bestLen) { + end = prevSpace; } } - const start = Math.max(0, bestPos - SNIPPET_RADIUS); - const end = Math.min(searchableText.length, bestPos + bestLen + SNIPPET_RADIUS); let snippet = searchableText.slice(start, end).replace(/\s+/g, ' ').trim(); if (start > 0) { snippet = `…${snippet}`; diff --git a/tests/discovery-layer.test.ts b/tests/discovery-layer.test.ts new file mode 100644 index 0000000..6f28949 --- /dev/null +++ b/tests/discovery-layer.test.ts @@ -0,0 +1,156 @@ +import { copyFile, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { resolve } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from '@rstest/core'; + +import { FpfRuntime } from '../src/runtime/runtime.js'; + +/** + * Tests for the browse / search discovery layer. + * + * Verifies filtering, ordering, limits, search scoring, snippet output, + * and snapshot metadata for the two new public tools. + */ + +describe('Discovery layer', () => { + const canonicalSourcePath = resolve(process.cwd(), 'FPF-spec.md'); + let tempRoot: string; + let sourcePath: string; + let artifactDir: string; + let runtime: FpfRuntime; + + beforeEach(async () => { + tempRoot = await mkdtemp(resolve(tmpdir(), 'fpf-discovery-')); + artifactDir = resolve(tempRoot, 'artifacts'); + sourcePath = resolve(tempRoot, 'FPF-spec.md'); + await copyFile(canonicalSourcePath, sourcePath); + runtime = new FpfRuntime({ sourcePath, artifactDir }); + }); + + afterEach(async () => { + await rm(tempRoot, { recursive: true, force: true }); + }); + + // ----------------------------------------------------------------------- + // browse() + // ----------------------------------------------------------------------- + + describe('browse()', () => { + it('returns entries when no filters are applied', async () => { + const result = await runtime.browse(); + expect(result.entries.length).toBeGreaterThan(0); + // total reflects all matching entries; entries may be capped by default limit + expect(result.total).toBeGreaterThanOrEqual(result.entries.length); + expect(result.snapshot.sourceHash).toBeTruthy(); + expect(result.snapshot.builtAt).toBeTruthy(); + }); + + it('returns entries sorted by id', async () => { + const result = await runtime.browse(); + const ids = result.entries.map((e) => e.id); + const sorted = [...ids].sort((a, b) => a.localeCompare(b)); + expect(ids).toEqual(sorted); + }); + + it('filters by kind', async () => { + const patterns = await runtime.browse({ kind: 'pattern' }); + const routes = await runtime.browse({ kind: 'route' }); + + expect(patterns.entries.length).toBeGreaterThan(0); + expect(routes.entries.length).toBeGreaterThan(0); + expect(patterns.entries.every((e) => e.kind === 'pattern')).toBe(true); + expect(routes.entries.every((e) => e.kind === 'route')).toBe(true); + expect(patterns.filters.kind).toBe('pattern'); + }); + + it('filters by part (case-insensitive)', async () => { + // First discover an actual part value from the data. + const all = await runtime.browse({ limit: 500 }); + const withPart = all.entries.find((e) => e.part); + expect(withPart).toBeDefined(); + + const partValue = withPart!.part!; + const filtered = await runtime.browse({ part: partValue, limit: 500 }); + const filteredLower = await runtime.browse({ part: partValue.toLowerCase(), limit: 500 }); + + expect(filtered.entries.length).toBeGreaterThan(0); + expect(filtered.total).toBeLessThan(all.total); + expect(filtered.entries.every((e) => e.part?.toLowerCase() === partValue.toLowerCase())).toBe(true); + expect(filtered.entries.length).toBe(filteredLower.entries.length); + }); + + it('respects the limit parameter', async () => { + const limited = await runtime.browse({ limit: 3 }); + expect(limited.entries.length).toBe(3); + expect(limited.total).toBeGreaterThan(3); + }); + + it('includes description for each entry', async () => { + const result = await runtime.browse({ limit: 10 }); + for (const entry of result.entries) { + expect(typeof entry.description).toBe('string'); + expect(entry.description.length).toBeGreaterThan(0); + } + }); + }); + + // ----------------------------------------------------------------------- + // search() + // ----------------------------------------------------------------------- + + describe('search()', () => { + it('returns ranked hits for a known term', async () => { + const result = await runtime.search('bounded context'); + expect(result.hits.length).toBeGreaterThan(0); + expect(result.query).toBe('bounded context'); + expect(result.snapshot.sourceHash).toBeTruthy(); + }); + + it('returns hits sorted by descending score', async () => { + const result = await runtime.search('pattern'); + const scores = result.hits.map((h) => h.score); + for (let i = 1; i < scores.length; i++) { + expect(scores[i]).toBeLessThanOrEqual(scores[i - 1]); + } + }); + + it('includes non-empty snippets in hits', async () => { + const result = await runtime.search('holon'); + expect(result.hits.length).toBeGreaterThan(0); + for (const hit of result.hits) { + expect(typeof hit.snippet).toBe('string'); + expect(hit.snippet.length).toBeGreaterThan(0); + } + }); + + it('filters search results by kind', async () => { + const all = await runtime.search('pattern'); + const routesOnly = await runtime.search('pattern', { kind: 'route' }); + + expect(routesOnly.hits.every((h) => h.kind === 'route')).toBe(true); + expect(all.total).toBeGreaterThanOrEqual(routesOnly.total); + }); + + it('respects the limit parameter', async () => { + const result = await runtime.search('pattern', { limit: 2 }); + expect(result.hits.length).toBeLessThanOrEqual(2); + }); + + it('returns zero hits for nonsense query', async () => { + const result = await runtime.search('xyzzy_nonexistent_term_12345'); + expect(result.hits.length).toBe(0); + expect(result.total).toBe(0); + }); + + it('centers snippet on matched term for dotted IDs', async () => { + const result = await runtime.search('A.1.1'); + const hit = result.hits.find((h) => h.id === 'A.1.1'); + if (hit) { + // The snippet should contain the ID or related text, not default + // to the beginning of the node. + expect(hit.snippet.toLowerCase()).toMatch(/a\.1\.1|boundedcontext|semantic/i); + } + }); + }); +}); From c527b829f503757e2bab5b880e4ee551b370934c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 02:29:15 +0000 Subject: [PATCH 3/6] fix: extractSnippet bestLen comparison + tokenize before normalize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Track bestTokenLen separately from bestLen (hit span) in extractSnippet so collapsed-token matches don't inflate the comparison threshold - Tokenize raw query instead of pre-normalized lowercase to preserve camelCase splitting (e.g. BoundedContext → bounded + context) - Fix nonsense-query test to use truly unmatchable token Co-Authored-By: Stanislau --- src/runtime/runtime.ts | 11 +++++++---- tests/discovery-layer.test.ts | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/runtime/runtime.ts b/src/runtime/runtime.ts index 6c5db29..525559f 100644 --- a/src/runtime/runtime.ts +++ b/src/runtime/runtime.ts @@ -36,7 +36,7 @@ import type { Snapshot, TraceResult, } from './types.js'; -import { normalizeForLookup, tokenize, scoreOverlap } from './text.js'; +import { tokenize, scoreOverlap } from './text.js'; import { getRuntimeObservabilitySummary } from '../observability/runtime-observability.js'; export interface FpfRuntimeOptions { @@ -305,8 +305,9 @@ export class FpfRuntime { await this.refresh(options.forceRefresh ?? false); const snapshot = await this.requireSnapshot(); - const normalizedQuery = normalizeForLookup(query); - const queryTokens = tokenize(normalizedQuery); + // Tokenize the raw query first so camelCase splits (e.g. BoundedContext → + // bounded + context) are preserved; tokenize() handles lowercasing internally. + const queryTokens = tokenize(query); const limit = Math.min(options.limit ?? 20, 100); const hits: SearchHit[] = []; @@ -522,12 +523,14 @@ function extractSnippet(searchableText: string, queryTokens: string[]): string { const lower = searchableText.toLowerCase(); let bestPos = 0; let bestLen = 0; + let bestTokenLen = 0; for (const token of queryTokens) { const hit = findTokenPosition(searchableText, lower, token); - if (hit && token.length > bestLen) { + if (hit && token.length > bestTokenLen) { bestPos = hit.pos; bestLen = hit.len; + bestTokenLen = token.length; } } diff --git a/tests/discovery-layer.test.ts b/tests/discovery-layer.test.ts index 6f28949..72d72fe 100644 --- a/tests/discovery-layer.test.ts +++ b/tests/discovery-layer.test.ts @@ -138,7 +138,7 @@ describe('Discovery layer', () => { }); it('returns zero hits for nonsense query', async () => { - const result = await runtime.search('xyzzy_nonexistent_term_12345'); + const result = await runtime.search('zqqxvwjkf'); expect(result.hits.length).toBe(0); expect(result.total).toBe(0); }); From 0948eb650b92255203a786efb6e6d4f5b9ae42b5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 04:27:14 +0000 Subject: [PATCH 4/6] fix: keep get_fpf_index_status in public bucket to match merged #36 PR #36 advertises get_fpf_index_status as public in the hosted MCP. Moving it to admin in #33 would break callers. Keep it public and move only refresh_fpf_index to admin. Co-Authored-By: Stanislau --- AGENTS.md | 2 +- src/mastra/mcp/server.ts | 2 +- src/mcp/tools.ts | 2 +- tests/mcp-server.test.ts | 3 ++- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 580db10..e073baa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,7 @@ Public tools (deployed MCP surface): - `ask_fpf` for markdown-first answers - `query_fpf_spec` for structured answer envelopes - `read_fpf_doc` for exact generated markdown pages +- `get_fpf_index_status` for runtime freshness checks Expert tools (local full-surface runtime only, via `FPF_MCP_SURFACE=full bun run mcp`): @@ -17,5 +18,4 @@ Expert tools (local full-surface runtime only, via `FPF_MCP_SURFACE=full bun run Admin tools (index management): -- `get_fpf_index_status` for runtime freshness checks - `refresh_fpf_index` to rebuild the local artifact set diff --git a/src/mastra/mcp/server.ts b/src/mastra/mcp/server.ts index 4c4138e..96774b7 100644 --- a/src/mastra/mcp/server.ts +++ b/src/mastra/mcp/server.ts @@ -12,6 +12,6 @@ export const fpfMemory = new MCPServer({ export const fpfMemoryPublic = new MCPServer({ name: 'fpf_memory', version: '1.0.0', - description: 'FPF-spec query runtime with public discovery surface (browse, search, ask, query, read).', + description: 'FPF-spec query runtime with public discovery surface (browse, search, ask, query, read, status).', tools: fpfPublicTools, }); diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index f1452ff..dc02471 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -162,6 +162,7 @@ export const fpfPublicTools = { ask_fpf: askFpfTool, query_fpf_spec: queryFpfSpecTool, read_fpf_doc: readFpfDocTool, + get_fpf_index_status: getFpfIndexStatusTool, } as const; /** Expert/debug tools — full-surface runtime only. */ @@ -174,7 +175,6 @@ export const fpfExpertTools = { /** Admin tools — index management. */ export const fpfAdminTools = { - get_fpf_index_status: getFpfIndexStatusTool, refresh_fpf_index: refreshFpfIndexTool, } as const; diff --git a/tests/mcp-server.test.ts b/tests/mcp-server.test.ts index a4e7294..0a7624d 100644 --- a/tests/mcp-server.test.ts +++ b/tests/mcp-server.test.ts @@ -195,11 +195,11 @@ describe('Mastra MCP server', () => { 'ask_fpf', 'query_fpf_spec', 'read_fpf_doc', + 'get_fpf_index_status', 'inspect_fpf_node', 'inspect_fpf_anchor', 'expand_fpf_citations', 'trace_fpf_path', - 'get_fpf_index_status', 'refresh_fpf_index', ]); @@ -284,6 +284,7 @@ describe('Mastra MCP server', () => { 'ask_fpf', 'query_fpf_spec', 'read_fpf_doc', + 'get_fpf_index_status', ]); }); From 7b97ae67eb5742e038370a10a38b1abf6355729b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 06:03:46 +0000 Subject: [PATCH 5/6] fix: interleave browse kinds + boost exact-ID search ranking Address venikman's two P2 findings: 1. browse() default page now interleaves patterns, routes, and lexemes so all kinds are visible within the default 200-entry cap, instead of burying routes/lexemes past the limit. 2. search() now boosts exact ID matches (+200) and exact title matches (+150) so that searching 'A.1.1' returns A.1.1 first, not A.1. Added tests for both behaviors. Co-Authored-By: Stanislau --- src/runtime/runtime.ts | 66 +++++++++++++++++++++++++++++++++-- tests/discovery-layer.test.ts | 15 ++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src/runtime/runtime.ts b/src/runtime/runtime.ts index 525559f..45f11c1 100644 --- a/src/runtime/runtime.ts +++ b/src/runtime/runtime.ts @@ -36,7 +36,7 @@ import type { Snapshot, TraceResult, } from './types.js'; -import { tokenize, scoreOverlap } from './text.js'; +import { normalizeForLookup, tokenize, scoreOverlap } from './text.js'; import { getRuntimeObservabilitySummary } from '../observability/runtime-observability.js'; export interface FpfRuntimeOptions { @@ -284,7 +284,13 @@ export class FpfRuntime { .map((node) => nodeToCatalogEntry(node, snapshot)); entries.sort((a, b) => a.id.localeCompare(b.id)); - const trimmed = entries.slice(0, limit); + + // When no kind filter is active, interleave kinds so the default page + // shows a representative mix of patterns, routes, and lexemes instead + // of burying routes/lexemes past the limit cutoff. + const trimmed = options.kind + ? entries.slice(0, limit) + : interleaveBrowseEntries(entries, limit); return { entries: trimmed, @@ -332,6 +338,17 @@ export class FpfRuntime { }); } + // Boost exact ID and exact title matches so precise selector queries + // rank the target node first, ahead of broader prefix matches. + const normalizedQuery = normalizeForLookup(query); + for (const hit of hits) { + if (hit.id === query || normalizeForLookup(hit.id) === normalizedQuery) { + hit.score += 200; + } else if (normalizeForLookup(hit.title) === normalizedQuery) { + hit.score += 150; + } + } + hits.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id)); const trimmed = hits.slice(0, limit); @@ -462,6 +479,51 @@ function snapshotNeedsRebuild(snapshot: Snapshot): boolean { ); } +/** + * Interleave entries by kind so the default browse page shows a + * representative mix of patterns, routes, and lexemes. Each kind + * gets a fair share of the limit, and leftover slots are filled by + * whichever kinds have entries remaining. + */ +function interleaveBrowseEntries(entries: CatalogEntry[], limit: number): CatalogEntry[] { + const byKind = new Map(); + for (const entry of entries) { + const bucket = byKind.get(entry.kind) ?? []; + bucket.push(entry); + byKind.set(entry.kind, bucket); + } + + const kinds = [...byKind.keys()].sort(); + const perKind = Math.max(1, Math.floor(limit / kinds.length)); + const result: CatalogEntry[] = []; + + // First pass: take up to perKind from each bucket. + for (const kind of kinds) { + const bucket = byKind.get(kind)!; + result.push(...bucket.splice(0, perKind)); + } + + // Second pass: fill remaining slots round-robin. + let remaining = limit - result.length; + while (remaining > 0) { + let added = false; + for (const kind of kinds) { + if (remaining <= 0) break; + const bucket = byKind.get(kind)!; + if (bucket.length > 0) { + result.push(bucket.shift()!); + remaining -= 1; + added = true; + } + } + if (!added) break; + } + + // Sort the interleaved result by ID for stable output. + result.sort((a, b) => a.id.localeCompare(b.id)); + return result; +} + function nodeToCatalogEntry( node: import('./types.js').CompiledNode, snapshot: Snapshot, diff --git a/tests/discovery-layer.test.ts b/tests/discovery-layer.test.ts index 72d72fe..51e5285 100644 --- a/tests/discovery-layer.test.ts +++ b/tests/discovery-layer.test.ts @@ -86,6 +86,15 @@ describe('Discovery layer', () => { expect(limited.total).toBeGreaterThan(3); }); + it('includes all kinds in default unfiltered page', async () => { + const result = await runtime.browse(); + const kinds = new Set(result.entries.map((e) => e.kind)); + // Default browse page must include routes and lexemes, not just patterns. + expect(kinds.has('pattern')).toBe(true); + expect(kinds.has('route')).toBe(true); + expect(kinds.has('lexeme')).toBe(true); + }); + it('includes description for each entry', async () => { const result = await runtime.browse({ limit: 10 }); for (const entry of result.entries) { @@ -152,5 +161,11 @@ describe('Discovery layer', () => { expect(hit.snippet.toLowerCase()).toMatch(/a\.1\.1|boundedcontext|semantic/i); } }); + + it('ranks exact ID match first when searching by node ID', async () => { + const result = await runtime.search('A.1.1'); + expect(result.hits.length).toBeGreaterThan(0); + expect(result.hits[0]!.id).toBe('A.1.1'); + }); }); }); From f1ec6b30511afdfb320443c5767c1f1d9cee4006 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 06:09:28 +0000 Subject: [PATCH 6/6] fix: cap interleaveBrowseEntries to respect limit when limit < kindCount When limit is smaller than the number of distinct kinds (e.g. limit=1 with 3 kinds), the first-pass perKind floor of 1 caused more entries than requested. Now truncates to limit before returning. Co-Authored-By: Stanislau --- src/runtime/runtime.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/runtime/runtime.ts b/src/runtime/runtime.ts index 45f11c1..8c1fca3 100644 --- a/src/runtime/runtime.ts +++ b/src/runtime/runtime.ts @@ -519,9 +519,12 @@ function interleaveBrowseEntries(entries: CatalogEntry[], limit: number): Catalo if (!added) break; } + // Enforce the limit — the first pass may overshoot when limit < kindCount. + const capped = result.slice(0, limit); + // Sort the interleaved result by ID for stable output. - result.sort((a, b) => a.id.localeCompare(b.id)); - return result; + capped.sort((a, b) => a.id.localeCompare(b.id)); + return capped; } function nodeToCatalogEntry(