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
This commit is contained in:
@@ -10,6 +10,7 @@ import com.trueref.domain.port.in.IndexVersion;
|
|||||||
import com.trueref.domain.port.in.QueryCatalog;
|
import com.trueref.domain.port.in.QueryCatalog;
|
||||||
import com.trueref.domain.port.in.ResolveLibraryId;
|
import com.trueref.domain.port.in.ResolveLibraryId;
|
||||||
import com.trueref.domain.port.in.SearchLibraryDocs;
|
import com.trueref.domain.port.in.SearchLibraryDocs;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -64,8 +65,9 @@ public class TrueRefMcpTools {
|
|||||||
description =
|
description =
|
||||||
"Resolves a package/product name to a trueref-compatible library ID and "
|
"Resolves a package/product name to a trueref-compatible library ID and "
|
||||||
+ "returns matching libraries. Context7-compatible. Each result "
|
+ "returns matching libraries. Context7-compatible. Each result "
|
||||||
+ "includes: Title, Context7-compatible library ID (format "
|
+ "includes: Title, Context7-compatible library ID (format "
|
||||||
+ "/owner/repo[/version]), Description, Code Snippets, Versions, "
|
+ "/project, /project/version, /owner/repo, or /owner/repo/version), "
|
||||||
|
+ "Description, Code Snippets, Versions, "
|
||||||
+ "and a relevance Score.")
|
+ "and a relevance Score.")
|
||||||
public String resolveLibraryId(
|
public String resolveLibraryId(
|
||||||
@ToolParam(
|
@ToolParam(
|
||||||
@@ -97,12 +99,14 @@ public class TrueRefMcpTools {
|
|||||||
"Fetches up-to-date documentation for a library. You MUST call "
|
"Fetches up-to-date documentation for a library. You MUST call "
|
||||||
+ "'resolve-library-id' first to obtain the exact library ID "
|
+ "'resolve-library-id' first to obtain the exact library ID "
|
||||||
+ "required to use this tool, UNLESS the user explicitly provides "
|
+ "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(
|
public String getLibraryDocs(
|
||||||
@ToolParam(
|
@ToolParam(
|
||||||
description =
|
description =
|
||||||
"Exact trueref-compatible library ID (format: "
|
"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'.")
|
+ "retrieved from 'resolve-library-id'.")
|
||||||
String libraryId,
|
String libraryId,
|
||||||
@ToolParam(
|
@ToolParam(
|
||||||
@@ -117,22 +121,21 @@ public class TrueRefMcpTools {
|
|||||||
"Max number of tokens to return. Clamped to "
|
"Max number of tokens to return. Clamped to "
|
||||||
+ "[500, 50000]; defaults to 5000.")
|
+ "[500, 50000]; defaults to 5000.")
|
||||||
@Nullable Integer tokens) {
|
@Nullable Integer tokens) {
|
||||||
ParsedId parsed = parseLibraryId(libraryId);
|
List<ParsedId> candidates = parseLibraryIdCandidates(libraryId);
|
||||||
if (parsed == null) {
|
if (candidates.isEmpty()) {
|
||||||
return "Invalid libraryId: " + libraryId
|
return "Invalid libraryId: " + libraryId
|
||||||
+ ". Expected format: /org/project or /org/project/version.";
|
+ ". Expected format: /project, /project/version, "
|
||||||
|
+ "/org/project, or /org/project/version.";
|
||||||
}
|
}
|
||||||
|
|
||||||
Optional<Repository> repoOpt = catalog.listRepositories().stream()
|
ResolvedLibrary resolved = resolveLibrary(catalog.listRepositories(), candidates);
|
||||||
.filter(r -> r.name().equalsIgnoreCase(parsed.repoName()))
|
if (resolved == null) {
|
||||||
.findFirst();
|
|
||||||
if (repoOpt.isEmpty()) {
|
|
||||||
return "No matching library found for ID: " + libraryId;
|
return "No matching library found for ID: " + libraryId;
|
||||||
}
|
}
|
||||||
Repository repo = repoOpt.get();
|
Repository repo = resolved.repo();
|
||||||
List<Version> versions = catalog.listVersions(repo.id());
|
List<Version> versions = catalog.listVersions(repo.id());
|
||||||
|
|
||||||
SelectedVersion selected = selectVersion(repo, versions, parsed.version());
|
SelectedVersion selected = selectVersion(repo, versions, resolved.version());
|
||||||
if (selected.searchTarget() == null) {
|
if (selected.searchTarget() == null) {
|
||||||
return "No indexed version available for /" + repo.name()
|
return "No indexed version available for /" + repo.name()
|
||||||
+ ". Indexing has been enqueued; retry in ~"
|
+ ". Indexing has been enqueued; retry in ~"
|
||||||
@@ -278,20 +281,57 @@ public class TrueRefMcpTools {
|
|||||||
return sb.toString();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
static @Nullable ParsedId parseLibraryId(String raw) {
|
static List<ParsedId> parseLibraryIdCandidates(String raw) {
|
||||||
if (raw == null || raw.isBlank()) return null;
|
if (raw == null || raw.isBlank()) return List.of();
|
||||||
String s = raw.startsWith("/") ? raw.substring(1) : raw;
|
String s = raw.strip();
|
||||||
|
s = s.startsWith("/") ? s.substring(1) : s;
|
||||||
String[] parts = s.split("/");
|
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) {
|
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) {
|
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<ParsedId> dedupeCandidates(ParsedId... candidates) {
|
||||||
|
List<ParsedId> result = new ArrayList<>();
|
||||||
|
for (ParsedId candidate : candidates) {
|
||||||
|
if (!result.contains(candidate)) {
|
||||||
|
result.add(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return List.copyOf(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static @Nullable ResolvedLibrary resolveLibrary(
|
||||||
|
List<Repository> repositories, List<ParsedId> candidates) {
|
||||||
|
for (ParsedId candidate : candidates) {
|
||||||
|
Optional<Repository> repo = repositories.stream()
|
||||||
|
.filter(r -> r.name().equalsIgnoreCase(candidate.repoName()))
|
||||||
|
.findFirst();
|
||||||
|
if (repo.isPresent()) {
|
||||||
|
return new ResolvedLibrary(repo.get(), candidate.version());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
record ParsedId(String repoName, @Nullable String version) {}
|
record ParsedId(String repoName, @Nullable String version) {}
|
||||||
|
|
||||||
|
private record ResolvedLibrary(Repository repo, @Nullable String version) {}
|
||||||
|
|
||||||
private record SelectedVersion(@Nullable Version searchTarget, @Nullable String banner) {}
|
private record SelectedVersion(@Nullable Version searchTarget, @Nullable String banner) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Repository> listRepositories() {
|
||||||
|
return List.of(repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<Repository> findRepository(RepositoryId id) {
|
||||||
|
return Optional.of(repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Version> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ public final class LibraryResolver implements ResolveLibraryId {
|
|||||||
private static final Logger log = LoggerFactory.getLogger(LibraryResolver.class);
|
private static final Logger log = LoggerFactory.getLogger(LibraryResolver.class);
|
||||||
|
|
||||||
private static final Pattern SEMVER = Pattern.compile("^v?(\\d+)(?:\\.(\\d+))?(?:\\.(\\d+))?.*$");
|
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 RepositoryStore store;
|
||||||
private final IndexVersion indexer;
|
private final IndexVersion indexer;
|
||||||
@@ -72,7 +73,11 @@ public final class LibraryResolver implements ResolveLibraryId {
|
|||||||
private double nameScore(String haystack, String needle) {
|
private double nameScore(String haystack, String needle) {
|
||||||
if (haystack.equals(needle)) return 1.0;
|
if (haystack.equals(needle)) return 1.0;
|
||||||
if (haystack.endsWith("/" + needle) || haystack.startsWith(needle + "/")) return 0.95;
|
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 (haystack.contains(needle)) return 0.8;
|
||||||
|
if (!normalizedNeedle.isEmpty() && normalizedHaystack.contains(normalizedNeedle)) return 0.76;
|
||||||
// token overlap
|
// token overlap
|
||||||
String[] hTok = haystack.split("[^a-z0-9]+");
|
String[] hTok = haystack.split("[^a-z0-9]+");
|
||||||
String[] nTok = needle.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));
|
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.
|
* Maps a version string to the closest matching tag using the repo's configured mapping rules.
|
||||||
* Rules are tried in order.
|
* Rules are tried in order.
|
||||||
|
|||||||
Reference in New Issue
Block a user