Skip to content

Commit a738b11

Browse files
author
DeepWiki Dev
committed
fix(mcp): continue past invalid library candidates
Reject empty, archived, disabled, and branchless GitHub search results before they reach the index builder. Strip trailing documentation qualifiers for discovery and verify an official owner/docs repository when available, allowing requests such as GitHub Actions documentation to resolve to github/docs. Return a ranked, deduplicated candidate set from discovery and let the resolver try a bounded number of candidates instead of failing permanently on the first clone or indexing error. Resolve registry and GitHub candidates concurrently to avoid adding lookup latency. Add a regression test covering the zero-size exact-name repository that caused an unborn-branch checkout. Verified the original MCP call now returns /github/docs from the commit-pinned local index; lint, build, typecheck, and all 21 tests pass.
1 parent f6cbfc6 commit a738b11

3 files changed

Lines changed: 199 additions & 36 deletions

File tree

packages/mcp/src/local/discovery.ts

Lines changed: 107 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@ import { normalizeSearchName, parseGitHubRepository } from "./library-id.js";
22
import type { DiscoveredLibrary, LibraryRef, LocalContext7Config } from "./types.js";
33

44
type JsonObject = Record<string, unknown>;
5+
const DOCUMENTATION_QUALIFIERS = new Set([
6+
"docs",
7+
"documentation",
8+
"manual",
9+
"reference",
10+
"references",
11+
]);
12+
const MAX_GITHUB_SEARCH_CANDIDATES = 3;
513

614
function asObject(value: unknown): JsonObject | undefined {
715
return value && typeof value === "object" ? (value as JsonObject) : undefined;
@@ -17,6 +25,48 @@ function repositoryFromValue(value: unknown): LibraryRef | undefined {
1725
return object ? parseGitHubRepository(asString(object.url) ?? "") : undefined;
1826
}
1927

28+
function discoveryNames(name: string): string[] {
29+
const original = name.trim();
30+
const words = original.split(/\s+/);
31+
while (words.length > 1 && DOCUMENTATION_QUALIFIERS.has((words.at(-1) ?? "").toLowerCase())) {
32+
words.pop();
33+
}
34+
const withoutQualifier = words.join(" ");
35+
return withoutQualifier && withoutQualifier !== original
36+
? [original, withoutQualifier]
37+
: [original];
38+
}
39+
40+
function usableGitHubRepository(value: JsonObject | undefined): boolean {
41+
if (!value || value.archived === true || value.disabled === true) return false;
42+
return typeof value.size !== "number" || value.size > 0;
43+
}
44+
45+
function discoveredFromGitHub(value: JsonObject | undefined): DiscoveredLibrary | undefined {
46+
if (!usableGitHubRepository(value)) return undefined;
47+
const fullName = asString(value?.full_name);
48+
const ref = fullName ? parseGitHubRepository(fullName) : undefined;
49+
const defaultBranch = asString(value?.default_branch);
50+
if (!ref || !defaultBranch) return undefined;
51+
return {
52+
ref,
53+
title: asString(value?.name),
54+
description: asString(value?.description),
55+
stars: typeof value?.stargazers_count === "number" ? value.stargazers_count : undefined,
56+
defaultBranch,
57+
};
58+
}
59+
60+
function uniqueCandidates(candidates: DiscoveredLibrary[]): DiscoveredLibrary[] {
61+
const seen = new Set<string>();
62+
return candidates.filter((candidate) => {
63+
const key = candidate.ref.id.toLowerCase();
64+
if (seen.has(key)) return false;
65+
seen.add(key);
66+
return true;
67+
});
68+
}
69+
2070
export class LibraryDiscovery {
2171
constructor(private readonly config: LocalContext7Config) {}
2272

@@ -53,6 +103,19 @@ export class LibraryDiscovery {
53103
}
54104
}
55105

106+
private async verifiedGitHubRepository(ref: LibraryRef): Promise<DiscoveredLibrary | undefined> {
107+
const headers: Record<string, string> = {};
108+
if (this.config.githubToken) headers.Authorization = `Bearer ${this.config.githubToken}`;
109+
try {
110+
const data = asObject(
111+
await this.fetchJson(`https://api.github.com/repos/${ref.owner}/${ref.repo}`, headers)
112+
);
113+
return discoveredFromGitHub(data);
114+
} catch {
115+
return undefined;
116+
}
117+
}
118+
56119
private async fromNpm(name: string): Promise<DiscoveredLibrary | undefined> {
57120
try {
58121
const data = asObject(
@@ -110,7 +173,18 @@ export class LibraryDiscovery {
110173
}
111174
}
112175

113-
private async fromGitHubSearch(name: string): Promise<DiscoveredLibrary | undefined> {
176+
private async fromOfficialDocumentationRepository(
177+
originalName: string,
178+
searchName: string
179+
): Promise<DiscoveredLibrary | undefined> {
180+
if (originalName.trim() === searchName.trim()) return undefined;
181+
const owner = searchName.trim().split(/\s+/, 1)[0];
182+
if (!owner || !/^[A-Za-z0-9_.-]+$/.test(owner)) return undefined;
183+
const ref = parseGitHubRepository(`/${owner}/docs`);
184+
return ref ? this.verifiedGitHubRepository(ref) : undefined;
185+
}
186+
187+
private async fromGitHubSearch(name: string): Promise<DiscoveredLibrary[]> {
114188
const headers: Record<string, string> = {};
115189
if (this.config.githubToken) headers.Authorization = `Bearer ${this.config.githubToken}`;
116190
try {
@@ -122,32 +196,43 @@ export class LibraryDiscovery {
122196
);
123197
const items = Array.isArray(root?.items) ? root.items.map(asObject).filter(Boolean) : [];
124198
const normalized = normalizeSearchName(name);
125-
const exact = items.find(
126-
(item) => normalizeSearchName(asString(item?.name) ?? "") === normalized
127-
);
128-
const fullName = asString(exact?.full_name);
129-
const ref = fullName ? parseGitHubRepository(fullName) : undefined;
130-
if (!ref) return undefined;
131-
return {
132-
ref,
133-
title: asString(exact?.name),
134-
description: asString(exact?.description),
135-
stars: typeof exact?.stargazers_count === "number" ? exact.stargazers_count : undefined,
136-
defaultBranch: asString(exact?.default_branch),
137-
};
199+
return items
200+
.filter((item) => normalizeSearchName(asString(item?.name) ?? "") === normalized)
201+
.map(discoveredFromGitHub)
202+
.filter((candidate): candidate is DiscoveredLibrary => Boolean(candidate))
203+
.sort((left, right) => (right.stars ?? 0) - (left.stars ?? 0))
204+
.slice(0, MAX_GITHUB_SEARCH_CANDIDATES);
138205
} catch {
139-
return undefined;
206+
return [];
140207
}
141208
}
142209

143-
async discover(name: string): Promise<DiscoveredLibrary | undefined> {
210+
async discoverCandidates(name: string): Promise<DiscoveredLibrary[]> {
144211
const direct = parseGitHubRepository(name);
145-
if (direct) return this.decorate(direct);
212+
if (direct) return [await this.decorate(direct)];
146213

147-
for (const resolver of [this.fromNpm, this.fromPyPi, this.fromCrates, this.fromGitHubSearch]) {
148-
const result = await resolver.call(this, name);
149-
if (result) return result;
150-
}
151-
return undefined;
214+
const names = discoveryNames(name);
215+
const registryCandidates = await Promise.all(
216+
names.flatMap((candidateName) =>
217+
[this.fromNpm, this.fromPyPi, this.fromCrates].map((resolver) =>
218+
resolver.call(this, candidateName)
219+
)
220+
)
221+
);
222+
const [officialDocumentation, ...githubCandidates] = await Promise.all([
223+
this.fromOfficialDocumentationRepository(names[0]!, names.at(-1)!),
224+
...names.map((candidateName) => this.fromGitHubSearch(candidateName)),
225+
]);
226+
return uniqueCandidates([
227+
...registryCandidates.filter((candidate): candidate is DiscoveredLibrary =>
228+
Boolean(candidate)
229+
),
230+
...(officialDocumentation ? [officialDocumentation] : []),
231+
...githubCandidates.flat(),
232+
]);
233+
}
234+
235+
async discover(name: string): Promise<DiscoveredLibrary | undefined> {
236+
return (await this.discoverCandidates(name))[0];
152237
}
153238
}

packages/mcp/src/local/service.ts

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import type {
2929
LocalContext7Config,
3030
} from "./types.js";
3131

32+
const MAX_INDEX_CANDIDATES = 5;
33+
3234
function resultFromManifest(manifest: LibraryManifest): SearchResult {
3335
const safe = (value: string, limit: number) =>
3436
value
@@ -166,27 +168,34 @@ export class LocalContext7Service {
166168
normalizeSearchName(manifest.id) === normalizeSearchName(libraryName)
167169
);
168170

169-
try {
170-
const discovered = exact
171-
? {
172-
ref: parseLibraryId(exact.id),
173-
title: exact.title,
174-
description: exact.description,
175-
stars: exact.stars,
176-
defaultBranch: exact.branch,
177-
}
178-
: await this.discovery.discover(libraryName);
179-
if (discovered) {
180-
const ensured = await this.ensure(discovered);
171+
const candidates = exact
172+
? {
173+
ref: parseLibraryId(exact.id),
174+
title: exact.title,
175+
description: exact.description,
176+
stars: exact.stars,
177+
defaultBranch: exact.branch,
178+
}
179+
: undefined;
180+
const discovered = candidates
181+
? [candidates]
182+
: await this.discovery.discoverCandidates(libraryName);
183+
const failures: string[] = [];
184+
for (const candidate of discovered.slice(0, MAX_INDEX_CANDIDATES)) {
185+
try {
186+
const ensured = await this.ensure(candidate);
181187
const others = local.filter((manifest) => manifest.id !== ensured.manifest.id);
182188
return {
183189
results: [resultFromManifest(ensured.manifest), ...others.map(resultFromManifest)],
184190
};
191+
} catch (error) {
192+
failures.push(`${candidate.ref.id}: ${safeError(error)}`);
185193
}
186-
} catch (error) {
194+
}
195+
if (failures.length > 0) {
187196
return {
188197
results: local.map(resultFromManifest),
189-
error: `Local indexing failed for ${libraryName}: ${safeError(error)}`,
198+
error: `Local indexing failed for ${libraryName} after trying ${failures.join("; ")}`,
190199
};
191200
}
192201

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { afterEach, describe, expect, test, vi } from "vitest";
2+
import { LibraryDiscovery } from "../src/local/discovery.js";
3+
import type { LocalContext7Config } from "../src/local/types.js";
4+
5+
const config: LocalContext7Config = {
6+
storageDir: "unused",
7+
refreshIntervalMs: 60_000,
8+
gitTimeoutMs: 30_000,
9+
fetchTimeoutMs: 5_000,
10+
maxFiles: 100,
11+
maxFileBytes: 100_000,
12+
maxIndexBytes: 1_000_000,
13+
maxResultChars: 10_000,
14+
};
15+
16+
afterEach(() => {
17+
vi.restoreAllMocks();
18+
});
19+
20+
describe("local library discovery", () => {
21+
test("prefers an official docs repository and rejects empty exact-name matches", async () => {
22+
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
23+
const url = String(input);
24+
if (url === "https://api.github.com/repos/GitHub/docs") {
25+
return Response.json({
26+
full_name: "github/docs",
27+
name: "docs",
28+
description: "The open-source repository for docs.github.com",
29+
default_branch: "main",
30+
stargazers_count: 17_000,
31+
size: 500_000,
32+
});
33+
}
34+
if (url.startsWith("https://api.github.com/search/repositories")) {
35+
return Response.json({
36+
items: [
37+
{
38+
full_name: "example/github-actions-documentation",
39+
name: "github-actions-documentation",
40+
default_branch: "main",
41+
stargazers_count: 0,
42+
size: 0,
43+
},
44+
{
45+
full_name: "example-valid/github-actions-documentation",
46+
name: "github-actions-documentation",
47+
default_branch: "main",
48+
stargazers_count: 1,
49+
size: 10,
50+
},
51+
],
52+
});
53+
}
54+
return new Response("not found", { status: 404 });
55+
});
56+
57+
const candidates = await new LibraryDiscovery(config).discoverCandidates(
58+
"GitHub Actions documentation"
59+
);
60+
61+
expect(candidates[0]?.ref.id.toLowerCase()).toBe("/github/docs");
62+
expect(candidates.map((candidate) => candidate.ref.id)).not.toContain(
63+
"/example/github-actions-documentation"
64+
);
65+
expect(candidates.map((candidate) => candidate.ref.id)).toContain(
66+
"/example-valid/github-actions-documentation"
67+
);
68+
});
69+
});

0 commit comments

Comments
 (0)