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.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<ParsedId> 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<Repository> 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<Version> 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<ParsedId> 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<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;
|
||||
}
|
||||
|
||||
record ParsedId(String repoName, @Nullable String version) {}
|
||||
|
||||
private record ResolvedLibrary(Repository repo, @Nullable String version) {}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user