From 943a38fd366c5376be167b10e92c929838c43a06 Mon Sep 17 00:00:00 2001 From: moze Date: Wed, 6 May 2026 10:53:09 +0200 Subject: [PATCH] fix(mcp): relax library id and name matching - accept single-segment library ids like /whisper-rtx2080 returned by resolve-library-id in get-library-docs - accept common owner-qualified aliases such as /mozempk/whisper-rtx2080 when the indexed repo is stored as a single-segment name - accept single-segment ids with explicit versions such as /whisper-rtx2080/v0.0.1 - relax resolve-library-id scoring across separator-only differences so queries like whisperrtx2080 still match whisper-rtx2080 - update MCP tool descriptions to document the accepted id formats Validated with focused regression tests: - TrueRefMcpToolsTest - LibraryResolverTest --- .../adapter/in/mcp/TrueRefMcpTools.java | 76 ++++++++++++---- .../adapter/in/mcp/TrueRefMcpToolsTest.java | 90 +++++++++++++++++++ .../resolve/LibraryResolverTest.java | 67 ++++++++++++++ .../application/resolve/LibraryResolver.java | 9 ++ 4 files changed, 224 insertions(+), 18 deletions(-) create mode 100644 trueref-adapters/src/test/java/com/trueref/adapter/in/mcp/TrueRefMcpToolsTest.java create mode 100644 trueref-adapters/src/test/java/com/trueref/application/resolve/LibraryResolverTest.java diff --git a/trueref-adapters/src/main/java/com/trueref/adapter/in/mcp/TrueRefMcpTools.java b/trueref-adapters/src/main/java/com/trueref/adapter/in/mcp/TrueRefMcpTools.java index af5a9cc..c133abc 100644 --- a/trueref-adapters/src/main/java/com/trueref/adapter/in/mcp/TrueRefMcpTools.java +++ b/trueref-adapters/src/main/java/com/trueref/adapter/in/mcp/TrueRefMcpTools.java @@ -10,6 +10,7 @@ import com.trueref.domain.port.in.IndexVersion; import com.trueref.domain.port.in.QueryCatalog; import com.trueref.domain.port.in.ResolveLibraryId; import com.trueref.domain.port.in.SearchLibraryDocs; +import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.Optional; @@ -64,8 +65,9 @@ public class TrueRefMcpTools { description = "Resolves a package/product name to a trueref-compatible library ID and " + "returns matching libraries. Context7-compatible. Each result " - + "includes: Title, Context7-compatible library ID (format " - + "/owner/repo[/version]), Description, Code Snippets, Versions, " + + "includes: Title, Context7-compatible library ID (format " + + "/project, /project/version, /owner/repo, or /owner/repo/version), " + + "Description, Code Snippets, Versions, " + "and a relevance Score.") public String resolveLibraryId( @ToolParam( @@ -97,12 +99,14 @@ public class TrueRefMcpTools { "Fetches up-to-date documentation for a library. You MUST call " + "'resolve-library-id' first to obtain the exact library ID " + "required to use this tool, UNLESS the user explicitly provides " - + "a library ID in the format /org/project or /org/project/version.") + + "a library ID in the format /project, /project/version, " + + "/org/project, or /org/project/version.") public String getLibraryDocs( @ToolParam( description = "Exact trueref-compatible library ID (format: " - + "/org/project or /org/project/version) " + + "/project, /project/version, " + + "/org/project, or /org/project/version) " + "retrieved from 'resolve-library-id'.") String libraryId, @ToolParam( @@ -117,22 +121,21 @@ public class TrueRefMcpTools { "Max number of tokens to return. Clamped to " + "[500, 50000]; defaults to 5000.") @Nullable Integer tokens) { - ParsedId parsed = parseLibraryId(libraryId); - if (parsed == null) { + List candidates = parseLibraryIdCandidates(libraryId); + if (candidates.isEmpty()) { return "Invalid libraryId: " + libraryId - + ". Expected format: /org/project or /org/project/version."; + + ". Expected format: /project, /project/version, " + + "/org/project, or /org/project/version."; } - Optional repoOpt = catalog.listRepositories().stream() - .filter(r -> r.name().equalsIgnoreCase(parsed.repoName())) - .findFirst(); - if (repoOpt.isEmpty()) { + ResolvedLibrary resolved = resolveLibrary(catalog.listRepositories(), candidates); + if (resolved == null) { return "No matching library found for ID: " + libraryId; } - Repository repo = repoOpt.get(); + Repository repo = resolved.repo(); List versions = catalog.listVersions(repo.id()); - SelectedVersion selected = selectVersion(repo, versions, parsed.version()); + SelectedVersion selected = selectVersion(repo, versions, resolved.version()); if (selected.searchTarget() == null) { return "No indexed version available for /" + repo.name() + ". Indexing has been enqueued; retry in ~" @@ -278,20 +281,57 @@ public class TrueRefMcpTools { return sb.toString(); } - static @Nullable ParsedId parseLibraryId(String raw) { - if (raw == null || raw.isBlank()) return null; - String s = raw.startsWith("/") ? raw.substring(1) : raw; + static List parseLibraryIdCandidates(String raw) { + if (raw == null || raw.isBlank()) return List.of(); + String s = raw.strip(); + s = s.startsWith("/") ? s.substring(1) : s; String[] parts = s.split("/"); + for (String part : parts) { + if (part.isBlank()) return List.of(); + } + if (parts.length == 1) { + return List.of(new ParsedId(parts[0], null)); + } if (parts.length == 2) { - return new ParsedId(parts[0] + "/" + parts[1], null); + return dedupeCandidates( + new ParsedId(parts[0] + "/" + parts[1], null), + new ParsedId(parts[1], null), + new ParsedId(parts[0], parts[1])); } if (parts.length == 3) { - return new ParsedId(parts[0] + "/" + parts[1], parts[2]); + return dedupeCandidates( + new ParsedId(parts[0] + "/" + parts[1], parts[2]), + new ParsedId(parts[1], parts[2])); + } + return List.of(); + } + + private static List dedupeCandidates(ParsedId... candidates) { + List result = new ArrayList<>(); + for (ParsedId candidate : candidates) { + if (!result.contains(candidate)) { + result.add(candidate); + } + } + return List.copyOf(result); + } + + private static @Nullable ResolvedLibrary resolveLibrary( + List repositories, List candidates) { + for (ParsedId candidate : candidates) { + Optional repo = repositories.stream() + .filter(r -> r.name().equalsIgnoreCase(candidate.repoName())) + .findFirst(); + if (repo.isPresent()) { + return new ResolvedLibrary(repo.get(), candidate.version()); + } } return null; } record ParsedId(String repoName, @Nullable String version) {} + private record ResolvedLibrary(Repository repo, @Nullable String version) {} + private record SelectedVersion(@Nullable Version searchTarget, @Nullable String banner) {} } diff --git a/trueref-adapters/src/test/java/com/trueref/adapter/in/mcp/TrueRefMcpToolsTest.java b/trueref-adapters/src/test/java/com/trueref/adapter/in/mcp/TrueRefMcpToolsTest.java new file mode 100644 index 0000000..bfa8a1c --- /dev/null +++ b/trueref-adapters/src/test/java/com/trueref/adapter/in/mcp/TrueRefMcpToolsTest.java @@ -0,0 +1,90 @@ +package com.trueref.adapter.in.mcp; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import com.trueref.application.resolve.LibraryResolver; +import com.trueref.domain.model.Repository; +import com.trueref.domain.model.RepositoryId; +import com.trueref.domain.model.Version; +import com.trueref.domain.model.VersionId; +import com.trueref.domain.model.VersionStatus; +import com.trueref.domain.port.in.IndexVersion; +import com.trueref.domain.port.in.QueryCatalog; +import com.trueref.domain.port.in.ResolveLibraryId; +import com.trueref.domain.port.in.SearchLibraryDocs; +import com.trueref.domain.port.out.RepositoryStore; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class TrueRefMcpToolsTest { + + @Test + void getLibraryDocsAcceptsIdsReturnedByResolveAndCommonOwnerQualifiedVariants() { + Repository repo = repository("whisper-rtx2080"); + Version version = indexedVersion(repo.id(), "v0.0.1", 206); + + QueryCatalog catalog = new QueryCatalog() { + @Override + public List listRepositories() { + return List.of(repo); + } + + @Override + public Optional findRepository(RepositoryId id) { + return Optional.of(repo); + } + + @Override + public List listVersions(RepositoryId repoId) { + return List.of(version); + } + }; + + SearchLibraryDocs search = query -> new SearchLibraryDocs.Result(List.of(), 0); + LibraryResolver libraryResolver = new LibraryResolver(mock(RepositoryStore.class), mock(IndexVersion.class)); + TrueRefMcpTools tools = new TrueRefMcpTools( + query -> new ResolveLibraryId.Result(List.of()), + libraryResolver, + catalog, + search, + mock(IndexVersion.class), + new McpProperties(5000, 500, 50_000)); + + assertThat(tools.getLibraryDocs("/whisper-rtx2080", "install", 500)).contains("CODE SNIPPETS"); + assertThat(tools.getLibraryDocs("/mozempk/whisper-rtx2080", "install", 500)).contains("CODE SNIPPETS"); + assertThat(tools.getLibraryDocs("/whisper-rtx2080/v0.0.1", "install", 500)).contains("CODE SNIPPETS"); + } + + private static Repository repository(String name) { + Instant now = Instant.parse("2026-05-06T00:00:00Z"); + return new Repository( + RepositoryId.random(), + name, + null, + "/tmp/" + name, + false, + List.of(), + 1_000_000L, + Duration.ZERO, + 10, + List.of(), + now, + now); + } + + private static Version indexedVersion(RepositoryId repoId, String tag, int chunkCount) { + return new Version( + VersionId.random(), + repoId, + tag, + "deadbeef", + VersionStatus.INDEXED, + Instant.parse("2026-05-06T00:00:00Z"), + chunkCount, + null); + } +} \ No newline at end of file diff --git a/trueref-adapters/src/test/java/com/trueref/application/resolve/LibraryResolverTest.java b/trueref-adapters/src/test/java/com/trueref/application/resolve/LibraryResolverTest.java new file mode 100644 index 0000000..3d09b15 --- /dev/null +++ b/trueref-adapters/src/test/java/com/trueref/application/resolve/LibraryResolverTest.java @@ -0,0 +1,67 @@ +package com.trueref.application.resolve; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.trueref.domain.model.Repository; +import com.trueref.domain.model.RepositoryId; +import com.trueref.domain.model.Version; +import com.trueref.domain.model.VersionStatus; +import com.trueref.domain.port.in.IndexVersion; +import com.trueref.domain.port.in.ResolveLibraryId; +import com.trueref.domain.port.out.RepositoryStore; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.Test; + +class LibraryResolverTest { + + @Test + void resolveMatchesCollapsedNamesAcrossSeparators() { + Repository repo = repository("whisper-rtx2080"); + Version version = version(repo.id(), "v0.0.1", 206); + RepositoryStore store = mock(RepositoryStore.class); + when(store.findAll()).thenReturn(List.of(repo)); + when(store.findVersionsByRepo(repo.id())).thenReturn(List.of(version)); + + LibraryResolver resolver = new LibraryResolver(store, mock(IndexVersion.class)); + + ResolveLibraryId.Result result = resolver.resolve(new ResolveLibraryId.Query("whisperrtx2080", null, null)); + + assertThat(result.matches()).singleElement().satisfies(match -> { + assertThat(match.libraryId()).isEqualTo("/whisper-rtx2080"); + assertThat(match.score()).isGreaterThanOrEqualTo(0.9); + }); + } + + private static Repository repository(String name) { + Instant now = Instant.parse("2026-05-06T00:00:00Z"); + return new Repository( + RepositoryId.random(), + name, + null, + "/tmp/" + name, + false, + List.of(), + 1_000_000L, + Duration.ZERO, + 10, + List.of(), + now, + now); + } + + private static Version version(RepositoryId repoId, String tag, int chunkCount) { + return new Version( + com.trueref.domain.model.VersionId.random(), + repoId, + tag, + "deadbeef", + VersionStatus.INDEXED, + Instant.parse("2026-05-06T00:00:00Z"), + chunkCount, + null); + } +} \ No newline at end of file diff --git a/trueref-application/src/main/java/com/trueref/application/resolve/LibraryResolver.java b/trueref-application/src/main/java/com/trueref/application/resolve/LibraryResolver.java index 6025f50..51959b5 100644 --- a/trueref-application/src/main/java/com/trueref/application/resolve/LibraryResolver.java +++ b/trueref-application/src/main/java/com/trueref/application/resolve/LibraryResolver.java @@ -28,6 +28,7 @@ public final class LibraryResolver implements ResolveLibraryId { private static final Logger log = LoggerFactory.getLogger(LibraryResolver.class); private static final Pattern SEMVER = Pattern.compile("^v?(\\d+)(?:\\.(\\d+))?(?:\\.(\\d+))?.*$"); + private static final Pattern NON_ALNUM = Pattern.compile("[^a-z0-9]+"); private final RepositoryStore store; private final IndexVersion indexer; @@ -72,7 +73,11 @@ public final class LibraryResolver implements ResolveLibraryId { private double nameScore(String haystack, String needle) { if (haystack.equals(needle)) return 1.0; if (haystack.endsWith("/" + needle) || haystack.startsWith(needle + "/")) return 0.95; + String normalizedHaystack = normalizeName(haystack); + String normalizedNeedle = normalizeName(needle); + if (!normalizedHaystack.isEmpty() && normalizedHaystack.equals(normalizedNeedle)) return 0.92; if (haystack.contains(needle)) return 0.8; + if (!normalizedNeedle.isEmpty() && normalizedHaystack.contains(normalizedNeedle)) return 0.76; // token overlap String[] hTok = haystack.split("[^a-z0-9]+"); String[] nTok = needle.split("[^a-z0-9]+"); @@ -87,6 +92,10 @@ public final class LibraryResolver implements ResolveLibraryId { return 0.3 + 0.4 * ((double) hit / Math.max(1, nTok.length)); } + private static String normalizeName(String value) { + return NON_ALNUM.matcher(value).replaceAll(""); + } + /** * Maps a version string to the closest matching tag using the repo's configured mapping rules. * Rules are tried in order.