fix(mcp): relax library id and name matching
All checks were successful
Build and publish Docker image / Build and push CPU image (push) Successful in 2m11s
Build and publish Docker image / Build and push GPU image (push) Successful in 3m1s

- 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:
moze
2026-05-06 10:53:09 +02:00
parent bfb6bb5e8c
commit 943a38fd36
4 changed files with 224 additions and 18 deletions

View File

@@ -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) {}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}