diff --git a/ee/apps/den-api/src/mcp/admin-capabilities.ts b/ee/apps/den-api/src/mcp/admin-capabilities.ts index 31d1689e32..4181e13f07 100644 --- a/ee/apps/den-api/src/mcp/admin-capabilities.ts +++ b/ee/apps/den-api/src/mcp/admin-capabilities.ts @@ -1,8 +1,8 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js" import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" -import type { CapabilityMatch } from "./search.js" -import { scoreText, tokenize } from "./search.js" +import { rankCapabilities } from "./ranking.js" +import type { CapabilityCandidate, CapabilityMatch } from "./ranking.js" import { DEN_ADMIN_MCP_VERSION, registerAdminMcpTools } from "./admin-tools.js" export const ADMIN_CAPABILITY_PREFIX = "admin:" @@ -34,30 +34,40 @@ export function parseAdminCapabilityName(name: string): string | null { return toolName.length > 0 ? toolName : null } -export async function searchAdminCapabilities(query: string, limit = 5): Promise { - const queryTokens = tokenize(query) - const boundedLimit = Math.max(1, Math.min(20, Math.trunc(limit) || 5)) +export async function listAdminCapabilityCandidates(): Promise { const { tools } = await withAdminClient((client) => client.listTools()) - return tools - .map((tool) => ({ - name: `${ADMIN_CAPABILITY_PREFIX}${tool.name}`, - method: "MCP", - path: "/mcp/admin", - score: scoreText( - tokenize(tool.name), - tokenize(tool.description ?? ""), - queryTokens, - ["admin", "platform"], - ), - summary: `[OpenWork Admin] ${tool.description ?? tool.name}`, - pathParams: [], - queryParams: [], - hasBody: Object.keys(tool.inputSchema.properties ?? {}).length > 0, - })) - .filter((match) => match.score > 0) - .sort((a, b) => (b.score - a.score) || a.name.localeCompare(b.name)) - .slice(0, boundedLimit) + return tools.map((tool) => { + const summary = `[OpenWork Admin] ${tool.description ?? tool.name}` + return { + match: { + name: `${ADMIN_CAPABILITY_PREFIX}${tool.name}`, + method: "MCP", + path: "/mcp/admin", + summary, + pathParams: [], + queryParams: [], + hasBody: Object.keys(tool.inputSchema.properties ?? {}).length > 0, + source: "admin", + }, + searchText: { + name: tool.name, + summary, + path: "/mcp/admin", + keywords: ["admin", "platform", "openwork"], + }, + } + }) +} + +export async function searchAdminCapabilities(query: string, limit = 5): Promise { + return rankCapabilities(query, await listAdminCapabilityCandidates(), { limit }) +} + +export async function listAvailableAdminCapabilityCandidates( + platformAdmin: boolean, +): Promise { + return platformAdmin ? listAdminCapabilityCandidates() : [] } export async function searchAvailableAdminCapabilities( diff --git a/ee/apps/den-api/src/mcp/agent.ts b/ee/apps/den-api/src/mcp/agent.ts index f26e293bf6..3f227f41ca 100644 --- a/ee/apps/den-api/src/mcp/agent.ts +++ b/ee/apps/den-api/src/mcp/agent.ts @@ -14,14 +14,16 @@ import { getMcpResourceContext, verifyMcpRequest } from "./auth.js" import { invokeMcpOperation, normalizeToolBody, normalizeToolRecord } from "./invoke.js" import { getCatalog, protectedResourceMetadata } from "./index.js" import { preflightMcpJsonRpcRequest } from "./json-rpc-preflight.js" -import { compareCapabilityMatches, SEARCH_CAPABILITIES_TOOL_NAME, searchCapabilities, searchCapabilitySourceFilter, type CapabilityMatch } from "./search.js" -import { executeExternalCapability, externalMcpSearchCoverageHint, parseExternalCapabilityName, resolveMcpMemberIdentity, searchExternalCapabilities, type ExternalCapabilityExecuteResult } from "./external-capabilities.js" -import { executeMarketplaceCapability, parseMarketplaceCapabilityName, searchMarketplaceCapabilities, type MarketplaceCapabilityObjectType } from "./marketplace-capabilities.js" -import { executeSkillCapability, parseSkillCapabilityName, searchSkillCapabilities } from "./skill-capabilities.js" +import { buildRestCandidates, SEARCH_CAPABILITIES_TOOL_NAME, searchCapabilitySourceFilter, type CapabilityMatch } from "./search.js" +import { buildZeroResultSuggestions, parseQuery, rankCapabilities } from "./ranking.js" +import type { CapabilityCandidate } from "./ranking.js" +import { executeExternalCapability, EXTERNAL_MCP_SEARCH_MATCH_LIMIT, externalMcpSearchCoverageHint, parseExternalCapabilityName, resolveMcpMemberIdentity, searchExternalCapabilities, type ExternalCapabilityExecuteResult } from "./external-capabilities.js" +import { executeMarketplaceCapability, listMarketplaceCapabilityCandidates, parseMarketplaceCapabilityName, type MarketplaceCapabilityObjectType } from "./marketplace-capabilities.js" +import { executeSkillCapability, listSkillCapabilityCandidates, parseSkillCapabilityName } from "./skill-capabilities.js" import { resolvePublicOrigin } from "../capability-sources/generic-oauth.js" import { env } from "../env.js" import { isPlatformAdminUserId } from "../middleware/admin.js" -import { executeAvailableAdminCapability, parseAdminCapabilityName, searchAvailableAdminCapabilities } from "./admin-capabilities.js" +import { executeAvailableAdminCapability, listAvailableAdminCapabilityCandidates, parseAdminCapabilityName } from "./admin-capabilities.js" export const EXECUTE_CAPABILITY_TOOL_NAME = "execute_capability" const searchCapabilityTypeSchema = z.enum(["all", "api", "admin", "mcp", "marketplace", "skills"]) @@ -85,6 +87,7 @@ const capabilityMatchOutputSchema = z.object({ pathParams: z.array(z.string()), queryParams: z.array(z.string()), hasBody: z.boolean(), + source: z.enum(["rest", "admin", "external_mcp", "marketplace", "skills"]).optional(), kind: z.string().optional(), status: z.string().optional(), hint: z.string().optional(), @@ -94,6 +97,7 @@ const capabilityMatchOutputSchema = z.object({ export const SEARCH_CAPABILITIES_OUTPUT_SCHEMA = z.object({ matches: z.array(capabilityMatchOutputSchema), hint: z.string().optional(), + suggestions: z.array(z.string()).optional(), }) export const AGENT_MCP_INSTRUCTIONS = [ @@ -133,18 +137,40 @@ export function externalCapabilityErrorToolResult( } } -export function capabilitySearchToolResult(matches: T[], coverageHint?: string) { +export function capabilitySearchToolResult( + matches: T[], + coverageHint?: string, + suggestions: string[] = [], + emptyHint = "No matches. Try broader or different keywords.", +) { const hint = [ - ...(matches.length === 0 ? ["No matches. Try broader or different keywords."] : []), + ...(matches.length === 0 ? [emptyHint] : []), ...(coverageHint ? [coverageHint] : []), ].join(" ") - const result = hint ? { matches, hint } : { matches } + const result = { + matches, + ...(hint ? { hint } : {}), + ...(matches.length === 0 && suggestions.length > 0 ? { suggestions } : {}), + } return { content: textContent(JSON.stringify(result, null, 2)), structuredContent: result, } } +function candidateFromMatch(match: CapabilityMatch): CapabilityCandidate { + const { score: _score, ...matchFields } = match + return { + match: matchFields, + searchText: { + name: match.name, + summary: match.summary, + path: match.path, + keywords: [match.method, match.source ?? ""], + }, + } +} + function unknownCapabilityText(name: string): string { return JSON.stringify({ error: "unknown_capability", @@ -321,49 +347,64 @@ export function registerAgentMcpRoutes { - externalCoverageHint = externalMcpSearchCoverageHint(coverage) - }, - }) - : [] - const marketplaceMatches = sourceFilter.marketplace && externalMcpConnectionsEnabled - ? await searchMarketplaceCapabilities({ - organizationId: principal.organizationId, - member: memberIdentity, - objectTypes: marketplaceObjectTypes, - query, - limit: boundedLimit, - enabled: externalMcpConnectionsEnabled, - }) - : [] - const skillMatches = sourceFilter.skills - ? await searchSkillCapabilities({ - organizationId: principal.organizationId, - member: memberIdentity, - query, - limit: boundedLimit, - }) - : [] - const matches = [...restMatches, ...adminMatches, ...externalMatches, ...marketplaceMatches, ...skillMatches] - .sort(compareCapabilityMatches) - .slice(0, boundedLimit) - return capabilitySearchToolResult(matches, externalCoverageHint) + const [adminCandidates, externalMatches, marketplaceCandidates, skillCandidates] = await Promise.all([ + sourceFilter.admin + ? resolvePlatformAdmin().then(listAvailableAdminCapabilityCandidates) + : Promise.resolve([]), + sourceFilter.mcp && externalMcpConnectionsEnabled + ? searchExternalCapabilities({ + organizationId: principal.organizationId, + member: memberIdentity, + query, + redirectUriBase: resolvePublicOrigin(c.req.raw, env.apiPublicUrl), + limit: EXTERNAL_MCP_SEARCH_MATCH_LIMIT, + reportCoverage: (coverage) => { + externalCoverageHint = externalMcpSearchCoverageHint(coverage) + }, + }) + : Promise.resolve([]), + sourceFilter.marketplace && externalMcpConnectionsEnabled + ? listMarketplaceCapabilityCandidates({ + organizationId: principal.organizationId, + member: memberIdentity, + objectTypes: marketplaceObjectTypes, + enabled: externalMcpConnectionsEnabled, + }) + : Promise.resolve([]), + sourceFilter.skills + ? listSkillCapabilityCandidates({ + organizationId: principal.organizationId, + member: memberIdentity, + }) + : Promise.resolve([]), + ]) + const candidates: CapabilityCandidate[] = [ + ...restCandidates, + ...adminCandidates, + ...externalMatches.map(candidateFromMatch), + ...marketplaceCandidates, + ...skillCandidates, + ] + const matches = rankCapabilities(query, candidates, { limit: boundedLimit }) + return capabilitySearchToolResult( + matches, + externalCoverageHint, + buildZeroResultSuggestions(query, candidates), + ) }, ) diff --git a/ee/apps/den-api/src/mcp/external-capabilities.ts b/ee/apps/den-api/src/mcp/external-capabilities.ts index 60c306cbfd..2702d28ae6 100644 --- a/ee/apps/den-api/src/mcp/external-capabilities.ts +++ b/ee/apps/den-api/src/mcp/external-capabilities.ts @@ -23,6 +23,8 @@ import { import { getConnectedAccount } from "../capability-sources/oauth-credentials.js" import { db } from "../db.js" import { listTeamsForMember } from "../orgs.js" +import { parseQuery, rankCapabilities } from "./ranking.js" +import type { CapabilityCandidate } from "./ranking.js" import { compareCapabilityMatches, tokenize } from "./search.js" import type { CapabilityMatch } from "./search.js" @@ -170,6 +172,21 @@ export type ExternalConnectionStatus = { diagnostic?: ExternalMcpDiagnostic } +function candidateFromExternalMatch( + match: ExternalCapabilityMatch, +): CapabilityCandidate> { + const { score: _score, ...matchFields } = match + return { + match: matchFields, + searchText: { + name: match.connectionStatus?.connectionName ?? match.name, + summary: match.summary, + path: match.path, + keywords: [match.method, match.connectionStatus?.connectionName ?? ""], + }, + } +} + const ERROR_MESSAGE_LIMIT = 300 const LIVE_PROBE_HINT = "This is a live probe, not a cached result — repeating the same search without changing anything will return the same error." const INVALID_REFRESH_TOKEN_PATTERN = /\binvalid[ _-]?refresh[ _-]?token\b/i @@ -408,6 +425,7 @@ function statusMatch(input: { pathParams: [], queryParams: [], hasBody: false, + source: "external_mcp", kind: "connection_status", status: input.status, hint: input.hint, @@ -610,6 +628,7 @@ async function probeExternalMcpConnection(input: { pathParams: [], queryParams: [], hasBody: true, + source: "external_mcp", }) } return matches @@ -630,7 +649,8 @@ export async function searchExternalCapabilities(input: { reportCoverage?: (coverage: ExternalMcpSearchCoverage) => void }): Promise { if (!input.member) return [] - const queryTokens = tokenize(input.query) + const queryTokens = [...new Set(parseQuery(input.query).flatMap((concept) => + concept.terms.map((term) => term.token)))] if (queryTokens.length === 0) return [] const requestedLimit = input.limit ?? 5 if (!Number.isFinite(requestedLimit) || requestedLimit <= 0) return [] @@ -647,7 +667,7 @@ export async function searchExternalCapabilities(input: { probedConnections: selectedConnections.length, truncated: selectedConnections.length < connections.length, }) - return await collectBoundedExternalMcpSearchMatches({ + const preliminaryMatches = await collectBoundedExternalMcpSearchMatches({ connections: selectedConnections, deadline, limit, @@ -660,6 +680,7 @@ export async function searchExternalCapabilities(input: { deadline: sharedDeadline, }), }) + return rankCapabilities(input.query, preliminaryMatches.map(candidateFromExternalMatch), { limit }) } export type ExternalCapabilityExecuteResult = diff --git a/ee/apps/den-api/src/mcp/marketplace-capabilities.ts b/ee/apps/den-api/src/mcp/marketplace-capabilities.ts index c38051cc2e..11f22c09de 100644 --- a/ee/apps/den-api/src/mcp/marketplace-capabilities.ts +++ b/ee/apps/den-api/src/mcp/marketplace-capabilities.ts @@ -16,9 +16,9 @@ import { listUsableExternalMcpConnections } from "../capability-sources/external import { getConnectedAccount } from "../capability-sources/oauth-credentials.js" import { db } from "../db.js" import { resolvePluginArchGrantRole } from "../routes/org/plugin-system/access.js" -import { scoreText, tokenize } from "./search.js" +import { rankCapabilities } from "./ranking.js" import type { McpMemberIdentity } from "./external-capabilities.js" -import type { CapabilityMatch } from "./search.js" +import type { CapabilityCandidate, CapabilityMatchFields, RankedCapabilityMatch } from "./ranking.js" const MARKETPLACE_CAPABILITY_PREFIX = "plugin:" const PROVENANCE_SUFFIX = "in your organization's library." @@ -46,7 +46,7 @@ type GrantRow = { } type GrantWithResourceId = GrantRow & { resourceId: string } -export type MarketplaceCapabilityMatch = CapabilityMatch & { +export type MarketplaceCapabilityFields = CapabilityMatchFields & { kind: ConfigObjectType plugin: string marketplace?: string @@ -54,6 +54,9 @@ export type MarketplaceCapabilityMatch = CapabilityMatch & { hint?: string } +export type MarketplaceCapabilityCandidate = CapabilityCandidate +export type MarketplaceCapabilityMatch = RankedCapabilityMatch + type MarketplaceCapabilityStatus = "connection_available" | "content_not_synced" | "needs_connection" | "needs_install" | "unsupported" export type MarketplaceCapabilityExecutePayload = { @@ -199,19 +202,6 @@ function summaryFor(row: MarketplaceCapabilityRow): string { return description ? `${prefix}: ${description}` : prefix } -function scoreMarketplaceRow(row: MarketplaceCapabilityRow, queryTokens: string[]): number { - const score = scoreText( - tokenize(row.configObject.title), - tokenize(row.configObject.description ?? ""), - queryTokens, - tokenize(row.configObject.searchText ?? ""), - ) - if (row.configObject.objectType !== "skill") return score - return queryTokens.some((queryToken) => queryToken === "skill" || queryToken === "skills") - ? score + 1 - : score -} - function basePayload(row: MarketplaceCapabilityRow): MarketplaceCapabilityExecutePayload { return { kind: row.configObject.objectType, @@ -695,17 +685,13 @@ function commandArguments(body: unknown): string { return typeof body.arguments === "string" ? body.arguments : "" } -export async function searchMarketplaceCapabilities(input: { +export async function listMarketplaceCapabilityCandidates(input: { enabled?: boolean - limit?: number member: McpMemberIdentity | null objectTypes?: MarketplaceCapabilityObjectType[] organizationId: string - query: string -}): Promise { +}): Promise { if (input.enabled === false || !input.member) return [] - const queryTokens = tokenize(input.query) - if (queryTokens.length === 0) return [] const organizationId = normalizeDenTypeId("organization", input.organizationId) const memberRow = await getActiveMember(organizationId, input.member) @@ -717,23 +703,21 @@ export async function searchMarketplaceCapabilities(input: { memberRow, rows: await listActiveMarketplaceRows(organizationId), }) - const matchesByName = new Map() + const candidatesByName = new Map() for (const row of rows) { if (input.objectTypes && !input.objectTypes.includes(row.configObject.objectType)) continue - const score = scoreMarketplaceRow(row, queryTokens) - if (score <= 0) continue const name = buildMarketplaceCapabilityName(row.plugin.id, row.configObject.id) - if (matchesByName.has(name)) continue - const match: MarketplaceCapabilityMatch = { + if (candidatesByName.has(name)) continue + const match: MarketplaceCapabilityFields = { name, method: "PLUGIN", path: pluginPath(row), - score, summary: summaryFor(row), pathParams: [], queryParams: [], hasBody: row.configObject.objectType === "command", + source: "marketplace", kind: row.configObject.objectType, plugin: row.plugin.name, marketplace: row.marketplace.name, @@ -742,12 +726,36 @@ export async function searchMarketplaceCapabilities(input: { match.status = "needs_install" match.hint = objectHint(row) } - matchesByName.set(name, match) + candidatesByName.set(name, { + match, + searchText: { + name: `${row.plugin.name} ${row.configObject.title}`, + summary: [row.configObject.description, row.configObject.searchText] + .filter((entry) => typeof entry === "string" && entry.trim().length > 0) + .join(" ") || row.configObject.title, + path: pluginPath(row), + keywords: [row.marketplace.name, row.plugin.name, row.configObject.objectType], + }, + }) } - return [...matchesByName.values()] - .sort((a, b) => (b.score - a.score) || a.name.localeCompare(b.name)) - .slice(0, input.limit ?? 5) + return [...candidatesByName.values()] +} + +export async function searchMarketplaceCapabilities(input: { + enabled?: boolean + limit?: number + member: McpMemberIdentity | null + objectTypes?: MarketplaceCapabilityObjectType[] + organizationId: string + query: string +}): Promise { + return rankCapabilities(input.query, await listMarketplaceCapabilityCandidates({ + enabled: input.enabled, + member: input.member, + objectTypes: input.objectTypes, + organizationId: input.organizationId, + }), { limit: input.limit }) } export async function executeMarketplaceCapability(input: { diff --git a/ee/apps/den-api/src/mcp/ranking.ts b/ee/apps/den-api/src/mcp/ranking.ts new file mode 100644 index 0000000000..75919a7a89 --- /dev/null +++ b/ee/apps/den-api/src/mcp/ranking.ts @@ -0,0 +1,444 @@ +export type CapabilitySource = "rest" | "admin" | "external_mcp" | "marketplace" | "skills" + +export type CapabilityMatch = { + name: string + method: string + path: string + score: number + summary: string + /** Path parameter names this tool's `path` template requires, e.g. ["workerId"]. */ + pathParams: string[] + /** Query parameter names this tool documents, if any. */ + queryParams: string[] + /** Whether calling this tool requires a JSON `body`. */ + hasBody: boolean + source?: CapabilitySource + status?: "needs_connection" | "error" | "needs_install" | "content_not_synced" + hint?: string +} + +export type CapabilityMatchFields = Omit + +export type CapabilitySearchText = { + name: string + summary: string + path?: string + keywords?: string[] +} + +export type CapabilityCandidate = { + match: TMatch + searchText: CapabilitySearchText +} + +export type RankedCapabilityMatch = TMatch & { + score: number +} + +export type QueryConcept = { + original: string + terms: { factor: number; token: string }[] +} + +const FIELD_WEIGHTS = { + nameExact: 5, + namePrefix: 3, + summaryExact: 2, + keywordExact: 2, + pathExact: 1, +} + +const MAX_SCORE_PER_CONCEPT = + FIELD_WEIGHTS.nameExact + + FIELD_WEIGHTS.summaryExact + + FIELD_WEIGHTS.keywordExact + + FIELD_WEIGHTS.pathExact + +const STOPWORD_SOURCE = [ + "a", + "an", + "the", + "to", + "for", + "of", + "in", + "on", + "at", + "by", + "with", + "and", + "or", + "my", + "your", + "our", + "their", + "its", + "this", + "that", + "these", + "those", + "i", + "me", + "we", + "you", + "they", + "it", + "is", + "are", + "be", + "been", + "was", + "do", + "does", + "did", + "done", + "how", + "what", + "which", + "who", + "when", + "where", + "can", + "could", + "should", + "would", + "will", + "shall", + "may", + "might", + "must", + "please", + "want", + "wants", + "need", + "needs", + "about", + "into", + "from", +] + +const SYNONYM_GROUP_SOURCE = [ + ["create", "add", "new", "make", "register", "post"], + ["save", "store", "persist", "record", "write", "remember", "post"], + ["get", "fetch", "read", "show", "view", "retrieve"], + ["list", "enumerate", "browse"], + ["update", "edit", "modify", "change", "rename", "set", "patch", "put"], + ["delete", "remove", "destroy", "erase", "forget"], + ["search", "find", "query", "lookup", "discover"], + ["grant", "allow", "authorize", "share", "assign"], + ["revoke", "deny", "unshare"], + ["connect", "link", "attach", "login", "authenticate"], + ["disconnect", "unlink", "detach", "logout"], + ["cancel", "abort", "stop"], + ["organization", "org", "company", "tenant"], + ["member", "user", "person", "people", "teammate"], + ["team", "group"], + ["role", "permission"], + ["invitation", "invite"], + ["memory", "note", "fact"], + ["skill", "playbook", "guide"], + ["worker", "agent", "bot", "machine"], + ["credential", "secret", "token", "key"], + ["connection", "connector", "integration"], + ["config", "configuration", "setting", "preference"], + ["plugin", "extension", "addon"], + ["llm", "model", "ai"], + ["billing", "payment", "subscription", "invoice"], + ["repo", "repository", "github"], + ["auth", "oauth", "sso"], + ["email", "mail", "gmail"], + ["heartbeat", "activity", "health"], +] + +const STOPWORDS = new Set(STOPWORD_SOURCE.map((word) => normalizeToken(word))) +const SYNONYMS = buildSynonyms() + +export function tokenizeText(value: string): string[] { + return value + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter((token) => token.length > 0) +} + +export function normalizeToken(token: string): string { + if (token.length < 4 || /\d/.test(token)) return token + + let normalized = token + + if (normalized.length >= 5 && normalized.endsWith("ies")) { + normalized = applyIfUseful(normalized, `${normalized.slice(0, -3)}y`) + } + + if (normalized.length >= 5 && shouldDropEs(normalized)) { + normalized = applyIfUseful(normalized, normalized.slice(0, -2)) + } else if ( + normalized.length >= 4 + && normalized.endsWith("s") + && !normalized.endsWith("ss") + && !normalized.endsWith("us") + && !normalized.endsWith("is") + ) { + normalized = applyIfUseful(normalized, normalized.slice(0, -1)) + } + + if (normalized.length >= 6 && normalized.endsWith("ing")) { + normalized = applyIfUseful(normalized, undoubleTrailingConsonant(normalized.slice(0, -3))) + } else if (normalized.length >= 5 && normalized.endsWith("ed")) { + normalized = applyIfUseful(normalized, undoubleTrailingConsonant(normalized.slice(0, -2))) + } + + if (normalized.endsWith("e")) { + normalized = applyIfUseful(normalized, normalized.slice(0, -1)) + } + + return normalized +} + +export function parseQuery(query: string): QueryConcept[] { + const concepts: QueryConcept[] = [] + const seen = new Set() + + for (const token of tokenizeText(query).map((entry) => normalizeToken(entry))) { + if (STOPWORDS.has(token)) continue + if (seen.has(token)) continue + seen.add(token) + concepts.push({ + original: token, + terms: expandTerms(token), + }) + if (concepts.length >= 12) break + } + + return concepts +} + +export function rankCapabilities( + query: string, + candidates: CapabilityCandidate[], + opts: { limit?: number } = {}, +): RankedCapabilityMatch[] { + const boundedLimit = boundLimit(opts.limit) + const concepts = parseQuery(query) + if (concepts.length === 0 || candidates.length === 0) return [] + + const docs = candidates.map((candidate) => ({ + candidate, + tokens: tokenizeCandidate(candidate.searchText), + })) + const documentFrequency = buildDocumentFrequency(concepts, docs) + const rarityDenominator = concepts.reduce((total, concept) => total + rarity(concept, documentFrequency, docs.length), 0) + if (rarityDenominator === 0) return [] + + const ranked = docs + .map((doc) => { + const scored = scoreCandidate(doc.tokens, concepts, documentFrequency, docs.length, rarityDenominator) + if (scored.score <= 0) return null + return { + match: doc.candidate.match, + nameTokenCount: doc.tokens.name.length, + score: scored.score, + floorExempt: doc.candidate.match.status === "needs_connection", + } + }) + .filter((entry) => entry !== null) + .sort((a, b) => (b.score - a.score) || (a.nameTokenCount - b.nameTokenCount) || a.match.name.localeCompare(b.match.name)) + + const aboveFloor = ranked.filter((entry) => entry.score >= 8 || entry.floorExempt) + const visible = aboveFloor.length > 0 ? aboveFloor : ranked.slice(0, 3) + + return visible.slice(0, boundedLimit).map((entry) => ({ + ...entry.match, + score: entry.score, + })) +} + +export function buildZeroResultSuggestions(query: string, candidates: CapabilityCandidate[]): string[] { + const concepts = parseQuery(query) + if (concepts.length === 0 || candidates.length === 0) return [] + + const scored = candidates + .map((candidate) => { + const nameTokens = tokenizeText(candidate.searchText.name).map((token) => normalizeToken(token)) + let best = 0 + for (const concept of concepts) { + for (const token of nameTokens) { + best = Math.max(best, sharedPrefixLength(concept.original, token)) + } + } + return { name: candidate.match.name, score: best } + }) + .filter((entry) => entry.score >= 3) + .sort((a, b) => (b.score - a.score) || a.name.localeCompare(b.name)) + + return [...new Set(scored.map((entry) => entry.name))].slice(0, 3) +} + +function boundLimit(limit: number | undefined): number { + return Math.max(1, Math.min(20, Math.trunc(limit ?? 5) || 5)) +} + +function applyIfUseful(original: string, next: string): string { + return next.length >= 3 ? next : original +} + +function shouldDropEs(token: string): boolean { + return token.endsWith("ses") + || token.endsWith("xes") + || token.endsWith("zes") + || token.endsWith("ches") + || token.endsWith("shes") + || token.endsWith("oes") +} + +function undoubleTrailingConsonant(token: string): string { + if (token.length < 2) return token + const last = token.at(-1) + const previous = token.at(-2) + if (!last || last !== previous) return token + if ("aeiou".includes(last)) return token + return token.slice(0, -1) +} + +function buildSynonyms(): Map { + const synonyms = new Map() + for (const group of SYNONYM_GROUP_SOURCE) { + const normalized = [...new Set(group.map((entry) => normalizeToken(entry)))] + for (const token of normalized) { + synonyms.set(token, normalized.filter((entry) => entry !== token)) + } + } + return synonyms +} + +function expandTerms(token: string): { factor: number; token: string }[] { + const terms = [{ factor: 1, token }] + for (const synonym of SYNONYMS.get(token) ?? []) { + terms.push({ factor: 0.7, token: synonym }) + } + return terms +} + +function tokenizeCandidate(searchText: CapabilitySearchText) { + return { + name: tokenizeText(searchText.name).map((token) => normalizeToken(token)), + summary: tokenizeText(searchText.summary).map((token) => normalizeToken(token)), + path: tokenizeText(searchText.path ?? "").map((token) => normalizeToken(token)), + keywords: (searchText.keywords ?? []).flatMap((keyword) => tokenizeText(keyword).map((token) => normalizeToken(token))), + } +} + +function buildDocumentFrequency( + concepts: QueryConcept[], + docs: { tokens: ReturnType }[], +): Map { + const frequency = new Map() + for (const concept of concepts) { + let count = 0 + for (const doc of docs) { + const allTokens = new Set([...doc.tokens.name, ...doc.tokens.summary, ...doc.tokens.path, ...doc.tokens.keywords]) + if (allTokens.has(concept.original)) count += 1 + } + frequency.set(concept.original, count) + } + return frequency +} + +function rarity(concept: QueryConcept, documentFrequency: Map, documentCount: number): number { + if (documentCount <= 0) return 1 + return Math.max(0.25, 1 - ((documentFrequency.get(concept.original) ?? 0) / documentCount)) +} + +function scoreCandidate( + tokens: ReturnType, + concepts: QueryConcept[], + documentFrequency: Map, + documentCount: number, + rarityDenominator: number, +): { score: number } { + let raw = 0 + let matched = 0 + + for (const concept of concepts) { + const conceptScore = scoreConcept(tokens, concept) + if (conceptScore > 0) matched += 1 + raw += conceptScore * rarity(concept, documentFrequency, documentCount) + } + + if (matched === 0) return { score: 0 } + + const coverageFactor = 0.3 + (0.7 * (matched / concepts.length)) + const bonus = adjacencyBonus(tokens, concepts) * exactNameBonus(tokens.name, concepts) + const nameSpecificity = Math.min(1, concepts.length / Math.max(1, tokens.name.length)) + const score = Math.min(100, Math.round((100 * raw * coverageFactor * bonus * nameSpecificity) / (rarityDenominator * MAX_SCORE_PER_CONCEPT))) + return { score } +} + +function scoreConcept(tokens: ReturnType, concept: QueryConcept): number { + let best = 0 + for (const term of concept.terms) { + const directScore = ( + scoreName(tokens.name, term.token) + + (tokens.summary.includes(term.token) ? FIELD_WEIGHTS.summaryExact : 0) + + (tokens.keywords.includes(term.token) ? FIELD_WEIGHTS.keywordExact : 0) + + (tokens.path.includes(term.token) ? FIELD_WEIGHTS.pathExact : 0) + ) + best = Math.max(best, directScore * term.factor) + } + return best +} + +function scoreName(tokens: string[], token: string): number { + if (tokens.includes(token)) return FIELD_WEIGHTS.nameExact + return tokens.some((entry) => isBidirectionalPrefix(entry, token)) ? FIELD_WEIGHTS.namePrefix : 0 +} + +function isBidirectionalPrefix(a: string, b: string): boolean { + return a.length >= 3 && b.length >= 3 && (a.startsWith(b) || b.startsWith(a)) +} + +function adjacencyBonus(tokens: ReturnType, concepts: QueryConcept[]): number { + const originals = concepts.map((concept) => concept.original) + if (originals.length < 2) return 1 + + if (hasOrderedSubsequence(tokens.name, originals) || hasOrderedSubsequence(tokens.summary, originals)) { + return 1.25 + } + return hasAdjacentPair(tokens.name, originals) || hasAdjacentPair(tokens.summary, originals) ? 1.1 : 1 +} + +function exactNameBonus(nameTokens: string[], concepts: QueryConcept[]): number { + const queryName = concepts.map((concept) => concept.original).join(" ") + return queryName.length > 0 && queryName === nameTokens.join(" ") ? 1.5 : 1 +} + +function hasOrderedSubsequence(tokens: string[], queryTokens: string[]): boolean { + const docTokens = tokens.filter((token) => !STOPWORDS.has(token)) + if (queryTokens.length === 0 || queryTokens.length > docTokens.length) return false + for (let index = 0; index <= docTokens.length - queryTokens.length; index += 1) { + let matched = true + for (let offset = 0; offset < queryTokens.length; offset += 1) { + if (docTokens[index + offset] !== queryTokens[offset]) { + matched = false + break + } + } + if (matched) return true + } + return false +} + +function hasAdjacentPair(tokens: string[], queryTokens: string[]): boolean { + if (queryTokens.length < 2) return false + for (let index = 0; index < queryTokens.length - 1; index += 1) { + if (hasOrderedSubsequence(tokens, [queryTokens[index], queryTokens[index + 1]])) return true + } + return false +} + +function sharedPrefixLength(a: string, b: string): number { + const max = Math.min(a.length, b.length) + let index = 0 + while (index < max && a[index] === b[index]) index += 1 + return index +} diff --git a/ee/apps/den-api/src/mcp/search.ts b/ee/apps/den-api/src/mcp/search.ts index fc733c3411..a361b10540 100644 --- a/ee/apps/den-api/src/mcp/search.ts +++ b/ee/apps/den-api/src/mcp/search.ts @@ -1,4 +1,6 @@ import { getParameters, hasJsonRequestBody, pathParameterNamesFromTemplate, type McpToolOperation } from "./catalog.js" +import { rankCapabilities, tokenizeText } from "./ranking.js" +import type { CapabilityCandidate, CapabilityMatch } from "./ranking.js" /** * `search_capabilities` is the "search" half of a search+execute facade laid @@ -19,19 +21,7 @@ import { getParameters, hasJsonRequestBody, pathParameterNamesFromTemplate, type export const SEARCH_CAPABILITIES_TOOL_NAME = "search_capabilities" export type SearchCapabilityType = "all" | "api" | "admin" | "mcp" | "marketplace" | "skills" -export type CapabilityMatch = { - name: string - method: string - path: string - score: number - summary: string - /** Path parameter names this tool's `path` template requires, e.g. ["workerId"]. */ - pathParams: string[] - /** Query parameter names this tool documents, if any. */ - queryParams: string[] - /** Whether calling this tool requires a JSON `body`. */ - hasBody: boolean -} +export type { CapabilityCandidate, CapabilityMatch } from "./ranking.js" export function compareCapabilityMatches(a: CapabilityMatch, b: CapabilityMatch): number { const statusPriority = Number("kind" in b && b.kind === "connection_status") @@ -51,10 +41,7 @@ export function searchCapabilitySourceFilter(type?: SearchCapabilityType) { } export function tokenize(value: string): string[] { - return value - .toLowerCase() - .split(/[^a-z0-9]+/) - .filter((token) => token.length > 0) + return tokenizeText(value) } export function scoreText( @@ -84,24 +71,36 @@ export function scoreText( * Splits a camelCase / PascalCase tool name into lowercase word tokens so a * query like "organization" matches a tool named `getOrganizations`. */ -function tokenizeToolName(name: string): string[] { - const spaced = name.replace(/([a-z0-9])([A-Z])/g, "$1 $2") - return tokenize(spaced) -} - function summaryFor(operation: McpToolOperation): string { return operation.operation.summary ?? operation.operation.description ?? `${operation.method} ${operation.path}` } -function scoreOperation(operation: McpToolOperation, queryTokens: string[]): number { - if (queryTokens.length === 0) { - return 0 - } +function parameterNames(parameters: ReturnType): string[] { + return parameters.flatMap((parameter) => typeof parameter.name === "string" ? [parameter.name] : []) +} - const nameTokens = tokenizeToolName(operation.name) - const summaryTokens = tokenize(summaryFor(operation)) - const pathTokens = tokenize(operation.path) - return scoreText(nameTokens, summaryTokens, queryTokens, pathTokens) +export function buildRestCandidates(catalog: McpToolOperation[]): CapabilityCandidate[] { + return catalog.map((operation) => { + const summary = summaryFor(operation) + return { + match: { + name: operation.name, + method: operation.method, + path: operation.path, + summary, + pathParams: pathParameterNamesFromTemplate(operation.path), + queryParams: parameterNames(getParameters(operation.operation, "query")), + hasBody: hasJsonRequestBody(operation.operation), + source: "rest", + }, + searchText: { + name: operation.name, + summary, + path: operation.path, + keywords: operation.operation.tags ?? [], + }, + } + }) } export function searchCapabilities( @@ -109,21 +108,5 @@ export function searchCapabilities( query: string, limit = 5, ): CapabilityMatch[] { - const queryTokens = tokenize(query) - const boundedLimit = Math.max(1, Math.min(20, Math.trunc(limit) || 5)) - - return catalog - .map((operation) => ({ - name: operation.name, - method: operation.method, - path: operation.path, - score: scoreOperation(operation, queryTokens), - summary: summaryFor(operation), - pathParams: pathParameterNamesFromTemplate(operation.path), - queryParams: getParameters(operation.operation, "query").map((parameter) => parameter.name as string), - hasBody: hasJsonRequestBody(operation.operation), - })) - .filter((match) => match.score > 0) - .sort(compareCapabilityMatches) - .slice(0, boundedLimit) + return rankCapabilities(query, buildRestCandidates(catalog), { limit }) } diff --git a/ee/apps/den-api/src/mcp/skill-capabilities.ts b/ee/apps/den-api/src/mcp/skill-capabilities.ts index 9bdddb0492..50f09ad01c 100644 --- a/ee/apps/den-api/src/mcp/skill-capabilities.ts +++ b/ee/apps/den-api/src/mcp/skill-capabilities.ts @@ -2,8 +2,8 @@ import { and, desc, eq, inArray, or } from "@openwork-ee/den-db/drizzle" import { SkillHubMemberTable, SkillHubSkillTable, SkillHubTable, SkillTable } from "@openwork-ee/den-db/schema" import { normalizeDenTypeId, type DenTypeId } from "@openwork-ee/utils/typeid" import { db } from "../db.js" -import { tokenize } from "./search.js" -import type { CapabilityMatch } from "./search.js" +import { rankCapabilities } from "./ranking.js" +import type { CapabilityCandidate, CapabilityMatch } from "./ranking.js" import type { McpMemberIdentity } from "./external-capabilities.js" const SKILL_CAPABILITY_PREFIX = "skill:" @@ -34,25 +34,6 @@ export function parseSkillCapabilityName(name: string): string | null { return skillId.length > 0 ? skillId : null } -function scoreText(nameTokens: string[], summaryTokens: string[], queryTokens: string[]): number { - let score = 0 - for (const queryToken of queryTokens) { - if (queryToken === "skill" || queryToken === "skills") { - score += 1 - continue - } - if (nameTokens.includes(queryToken)) { - score += 5 - } else if (nameTokens.some((token) => token.startsWith(queryToken) || queryToken.startsWith(token))) { - score += 3 - } - if (summaryTokens.includes(queryToken)) { - score += 2 - } - } - return score -} - async function listHubAccessibleSkillIds(input: { organizationId: OrganizationId member: McpMemberIdentity @@ -157,36 +138,47 @@ async function getAccessibleSkill(input: { return canAccessSkill({ member, skill, hubAccessibleSkillIds }) ? skill : null } -export async function searchSkillCapabilities(input: { +export async function listSkillCapabilityCandidates(input: { organizationId: string member: McpMemberIdentity | null - query: string - limit?: number -}): Promise { - const queryTokens = tokenize(input.query) - if (queryTokens.length === 0) return [] +}): Promise { const skills = await listAccessibleSkills({ organizationId: input.organizationId, member: input.member, }) - const matches = skills - .map((skill) => { - const summary = skill.description ?? skill.title - return { + return skills.map((skill) => { + const summary = skill.description ?? skill.title + return { + match: { name: buildSkillCapabilityName(skill.id), method: "SKILL", path: "SKILL.md", - score: scoreText(tokenize(skill.title), tokenize(summary), queryTokens), summary: `[Skill] ${skill.title}${skill.description ? ` - ${skill.description}` : ""}`, pathParams: [], queryParams: [], hasBody: false, - } - }) - .filter((match) => match.score > 0) - .sort((a, b) => (b.score - a.score) || a.name.localeCompare(b.name)) + source: "skills", + }, + searchText: { + name: skill.title, + summary, + path: "SKILL.md", + keywords: ["skill", "playbook", "guide"], + }, + } + }) +} - return matches.slice(0, input.limit ?? 5) +export async function searchSkillCapabilities(input: { + organizationId: string + member: McpMemberIdentity | null + query: string + limit?: number +}): Promise { + return rankCapabilities(input.query, await listSkillCapabilityCandidates({ + organizationId: input.organizationId, + member: input.member, + }), { limit: input.limit }) } export type SkillCapabilityExecuteResult = diff --git a/ee/apps/den-api/test/external-capabilities-search-divergence.test.ts b/ee/apps/den-api/test/external-capabilities-search-divergence.test.ts index 2a74903dff..857fdbac38 100644 --- a/ee/apps/den-api/test/external-capabilities-search-divergence.test.ts +++ b/ee/apps/den-api/test/external-capabilities-search-divergence.test.ts @@ -742,7 +742,7 @@ test("per-member-name-mismatch: needs_connection only appears when query matches expect(matches[0]?.status).toBe("needs_connection") }) -test("user-transcript-repro: Slack connection status ranks above Notion's summary-only Slack hit", async () => { +test("user-transcript-repro: Slack connection status suppresses Notion's summary-only Slack hit", async () => { if (!notionServer) throw new Error("Notion MCP server was not started") if (!slackServer) throw new Error("Slack MCP server was not started") @@ -763,10 +763,9 @@ test("user-transcript-repro: Slack connection status ranks above Notion's summar await expectConnectionListed(seed, notionConnection.id) await expectConnectionListed(seed, slackConnection.id) const matches = await search(seed, "slack") - expect(matches.length).toBe(2) + expect(matches.length).toBe(1) expect(matches[0]?.name).toBe(`mcp:${slackConnection.id}:*`) expect(matches[0]?.status).toBe("needs_connection") expect(matches[0]?.score).toBeGreaterThanOrEqual(7) - expect(matches[1]?.name).toBe(`mcp:${notionConnection.id}:notion-search`) - expect(matches[1]?.score).toBe(2) + expect(matches.some((match) => match.name === `mcp:${notionConnection.id}:notion-search`)).toBe(false) }) diff --git a/ee/apps/den-api/test/mcp-capability-ranking.test.ts b/ee/apps/den-api/test/mcp-capability-ranking.test.ts new file mode 100644 index 0000000000..c1c77b2d5d --- /dev/null +++ b/ee/apps/den-api/test/mcp-capability-ranking.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, test } from "bun:test" +import { + buildZeroResultSuggestions, + normalizeToken, + parseQuery, + rankCapabilities, + tokenizeText, + type CapabilityCandidate, +} from "../src/mcp/ranking.js" + +function candidate(input: { + name: string + source: "rest" | "admin" | "external_mcp" | "marketplace" | "skills" + searchName?: string + summary: string + keywords?: string[] + status?: "needs_connection" +}): CapabilityCandidate { + return { + match: { + name: input.name, + method: input.source === "rest" ? "GET" : "MCP", + path: input.source === "rest" ? "/v1/example" : "https://example.test/mcp", + summary: input.summary, + pathParams: [], + queryParams: [], + hasBody: input.name.startsWith("post") || input.source === "external_mcp", + source: input.source, + status: input.status, + hint: input.status === "needs_connection" ? "Connect this account, then search again." : undefined, + }, + searchText: { + name: input.searchName ?? input.name, + summary: input.summary, + keywords: input.keywords, + }, + } +} + +const corpus: CapabilityCandidate[] = [ + candidate({ + name: "postMemory", + source: "rest", + summary: "Save a memory for the current user.", + keywords: ["Memory"], + }), + candidate({ + name: "getMemorySearch", + source: "rest", + summary: "Search memories by semantic text.", + keywords: ["Memory"], + }), + candidate({ + name: "getWorkers", + source: "rest", + summary: "List hosted workers.", + keywords: ["Workers"], + }), + candidate({ + name: "postWorkers", + source: "rest", + summary: "Create a hosted worker.", + keywords: ["Workers"], + }), + candidate({ + name: "skill:memory-keeper", + source: "marketplace", + searchName: "Memory keeper", + summary: "A skill for remembering customer facts.", + keywords: ["skill"], + }), + candidate({ + name: "mcp:linear:*", + source: "external_mcp", + searchName: "Linear", + summary: "Linear is available, but you have not connected your account.", + keywords: ["Linear"], + status: "needs_connection", + }), + candidate({ + name: "mcp:notion:createPage", + source: "external_mcp", + searchName: "Notion createPage", + summary: "Create a page in Notion.", + keywords: ["Notion"], + }), + candidate({ + name: "skill:incident-response", + source: "skills", + searchName: "Incident response playbook", + summary: "Guide responders through a production outage.", + keywords: ["skill", "playbook"], + }), + candidate({ + name: "admin:listOrganizations", + source: "admin", + searchName: "list organizations", + summary: "List organizations for platform support.", + keywords: ["admin", "platform"], + }), +] + +describe("capability lexical ranking", () => { + test("tokenizes camelCase and normalizes conservative stems", () => { + expect(tokenizeText("postMemory")).toEqual(["post", "memory"]) + expect(tokenizeText("mcp:conn:notion-search")).toEqual(["mcp", "conn", "notion", "search"]) + + expect(normalizeToken("workers")).toBe("worker") + expect(normalizeToken("memories")).toBe("memory") + expect(normalizeToken("create")).toBe("creat") + expect(normalizeToken("creates")).toBe("creat") + expect(normalizeToken("created")).toBe("creat") + expect(normalizeToken("creating")).toBe("creat") + expect(normalizeToken("setting")).toBe("set") + expect(normalizeToken("status")).toBe("status") + expect(normalizeToken("access")).toBe("access") + expect(normalizeToken("analysis")).toBe("analysis") + expect(normalizeToken("v1")).toBe("v1") + }) + + test("removes stopwords, dedupes concepts, and expands synonyms", () => { + expect(parseQuery("to the a")).toEqual([]) + + const concepts = parseQuery("please save save a memory") + expect(concepts.map((concept) => concept.original)).toEqual(["sav", "memory"]) + const save = concepts[0] + expect(save?.terms.some((term) => term.token === "post" && term.factor < 1)).toBe(true) + }) + + test("ranks vocabulary mismatch and direct search cases", () => { + expect(rankCapabilities("save a memory", corpus, { limit: 5 })[0]?.name).toBe("postMemory") + expect(rankCapabilities("search my memories", corpus, { limit: 5 })[0]?.name).toBe("getMemorySearch") + expect(rankCapabilities("store a note", corpus, { limit: 5 })[0]?.name).toBe("postMemory") + }) + + test("coverage and adjacency beat single-field stacking", () => { + const matches = rankCapabilities("list workers", corpus, { limit: 5 }) + expect(matches[0]?.name).toBe("getWorkers") + expect(matches.findIndex((match) => match.name === "getWorkers")).toBeLessThan( + matches.findIndex((match) => match.name === "postWorkers"), + ) + }) + + test("cross-source ranking keeps needs_connection rows and source metadata", () => { + const linear = rankCapabilities("linear", corpus, { limit: 5 })[0] + expect(linear?.name).toBe("mcp:linear:*") + expect(linear?.source).toBe("external_mcp") + expect(linear?.status).toBe("needs_connection") + expect(linear?.hint).toContain("Connect") + + const notion = rankCapabilities("notion page", corpus, { limit: 5 })[0] + expect(notion?.name).toBe("mcp:notion:createPage") + expect(notion?.source).toBe("external_mcp") + + expect(rankCapabilities("production outage playbook", corpus, { limit: 5 })[0]).toEqual( + expect.objectContaining({ name: "skill:incident-response", source: "skills" }), + ) + expect(rankCapabilities("platform organizations", corpus, { limit: 5 })[0]).toEqual( + expect.objectContaining({ name: "admin:listOrganizations", source: "admin" }), + ) + }) + + test("returns bounded sorted integer scores", () => { + const matches = rankCapabilities("memory", corpus, { limit: 100 }) + expect(matches.length).toBeLessThanOrEqual(20) + for (let index = 0; index < matches.length; index += 1) { + const score = matches[index]?.score ?? 0 + expect(Number.isInteger(score)).toBe(true) + expect(score).toBeGreaterThan(0) + expect(score).toBeLessThanOrEqual(100) + if (index > 0) { + expect(score).toBeLessThanOrEqual(matches[index - 1]?.score ?? 100) + } + } + }) + + test("suggests near-miss names by shared prefix", () => { + expect(buildZeroResultSuggestions("memor", corpus)).toContain("postMemory") + expect(buildZeroResultSuggestions("zzzz", corpus)).toEqual([]) + }) +}) diff --git a/ee/apps/den-api/test/mcp-capability-search.test.ts b/ee/apps/den-api/test/mcp-capability-search.test.ts new file mode 100644 index 0000000000..298736f948 --- /dev/null +++ b/ee/apps/den-api/test/mcp-capability-search.test.ts @@ -0,0 +1,40 @@ +import { beforeAll, describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" + +let buildMcpCatalog: typeof import("../src/mcp/catalog.js")["buildMcpCatalog"] +let searchCapabilities: typeof import("../src/mcp/search.js")["searchCapabilities"] +let catalog: ReturnType + +beforeAll(async () => { + buildMcpCatalog = (await import("../src/mcp/catalog.js")).buildMcpCatalog + searchCapabilities = (await import("../src/mcp/search.js")).searchCapabilities + const openApiJson = readFileSync("../../../packages/docs/openapi.json", "utf8") + catalog = buildMcpCatalog(JSON.parse(openApiJson)) +}) + +function search(query: string, limit = 10) { + return searchCapabilities(catalog, query, limit) +} + +function names(query: string, limit = 10): string[] { + return search(query, limit).map((match) => match.name) +} + +describe("MCP capability catalog search", () => { + test("pins high-value catalog ordering", () => { + expect(names("list workers", 5)[0]).toBe("getWorkers") + expect(names("list organization", 5)[0]).toBe("getOrg") + expect(names("invite a teammate", 5)).toContain("postInvitations") + }) + + test("returns rest source and normalized score scale", () => { + const matches = search("list organization", 5) + expect(matches.length).toBeGreaterThan(0) + for (const match of matches) { + expect(match.source).toBe("rest") + expect(Number.isInteger(match.score)).toBe(true) + expect(match.score).toBeGreaterThan(0) + expect(match.score).toBeLessThanOrEqual(100) + } + }) +})