github-search-cli 9.5.0 → 9.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +85 -3
- package/README.md +65 -31
- package/completions/ghfind.bash +1 -1
- package/completions/ghfind.fish +1 -1
- package/completions/ghfind.zsh +2 -0
- package/dist/cli.js +813 -10
- package/package.json +1 -1
- package/release-notes/9.5.1.md +36 -0
- package/release-notes/9.6.0.md +67 -0
- package/scripts/brew.rb +31 -10
- package/scripts/install.sh +118 -0
- package/src/agent-skill.ts +140 -0
- package/src/cli.ts +60 -4
- package/src/doctor.ts +4 -0
- package/src/help.ts +4 -0
- package/src/mcp-server.ts +894 -0
- package/src/search.ts +69 -5
- package/src/types.ts +2 -0
|
@@ -0,0 +1,894 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp-server.ts — `ghfind mcp`: a Model Context Protocol (MCP) server that
|
|
3
|
+
* exposes ghfind's search capabilities as tools any AI agent can call.
|
|
4
|
+
*
|
|
5
|
+
* Zero new dependencies: speaks JSON-RPC 2.0 over stdio per the MCP spec
|
|
6
|
+
* (2025-06-18 lifecycle: initialize → notifications/initialized → operation)
|
|
7
|
+
* and drives the existing domain modules through their seams:
|
|
8
|
+
*
|
|
9
|
+
* tool module reused
|
|
10
|
+
* ────────────────────────── ─────────────────────────────────
|
|
11
|
+
* ghfind_search_repos search.ts (SearchProvider seam)
|
|
12
|
+
* ghfind_trending search.ts (TrendingAdapter)
|
|
13
|
+
* ghfind_npm_packages package.ts
|
|
14
|
+
* ghfind_org_profile org.ts
|
|
15
|
+
* ghfind_user_profile user.ts
|
|
16
|
+
* ghfind_compare_repos compare.ts + search.ts
|
|
17
|
+
* ghfind_deep_dive deepdive.ts
|
|
18
|
+
* ghfind_bookmarked_releases releases.ts + bookmarks.ts (read-only)
|
|
19
|
+
* ghfind_skill agent-skill.ts
|
|
20
|
+
*
|
|
21
|
+
* Only the tools capability is declared (no prompts/resources yet), so those
|
|
22
|
+
* methods fall through to "Method not found".
|
|
23
|
+
*/
|
|
24
|
+
import type { Repo, SearchOptions, SortStrategy } from "./types";
|
|
25
|
+
import {
|
|
26
|
+
parseQuery,
|
|
27
|
+
rankRepos,
|
|
28
|
+
createGitHubSearch,
|
|
29
|
+
createTrendingSearch,
|
|
30
|
+
TRENDING_LANGUAGES,
|
|
31
|
+
trendingLanguageSlug,
|
|
32
|
+
} from "./search";
|
|
33
|
+
import {
|
|
34
|
+
createPackageSearch,
|
|
35
|
+
sortPackages,
|
|
36
|
+
type PackageSortMode,
|
|
37
|
+
} from "./package";
|
|
38
|
+
import { fetchOrgProfile, formatOrgText } from "./org";
|
|
39
|
+
import { fetchUserProfile, formatUserJson } from "./user";
|
|
40
|
+
import { fetchDeepDive, buildDeepDiveText } from "./deepdive";
|
|
41
|
+
import { buildComparisonTable } from "./compare";
|
|
42
|
+
import { allCachedReleases } from "./releases";
|
|
43
|
+
import { getBookmarks } from "./bookmarks";
|
|
44
|
+
import { appendHistory } from "./history";
|
|
45
|
+
import { loadConfig } from "./config";
|
|
46
|
+
import { ensureBuiltinSkill, getSkill } from "./agent-skill";
|
|
47
|
+
import { getVersion } from "./version";
|
|
48
|
+
|
|
49
|
+
// ─── Protocol constants ────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/** MCP protocol versions this server can speak, newest first. */
|
|
52
|
+
const SUPPORTED_PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
|
|
53
|
+
const LATEST_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS[0];
|
|
54
|
+
|
|
55
|
+
const JSONRPC_VERSION = "2.0";
|
|
56
|
+
const PARSE_ERROR = -32700;
|
|
57
|
+
const INVALID_REQUEST = -32600;
|
|
58
|
+
const METHOD_NOT_FOUND = -32601;
|
|
59
|
+
const INVALID_PARAMS = -32602;
|
|
60
|
+
|
|
61
|
+
// ─── Wire types ────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
type JsonRpcId = number | string | null;
|
|
64
|
+
|
|
65
|
+
interface JsonRpcRequest {
|
|
66
|
+
jsonrpc?: string;
|
|
67
|
+
id?: JsonRpcId;
|
|
68
|
+
method?: string;
|
|
69
|
+
params?: Record<string, unknown>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface JSONSchemaProperty {
|
|
73
|
+
type: string;
|
|
74
|
+
description: string;
|
|
75
|
+
enum?: string[];
|
|
76
|
+
minimum?: number;
|
|
77
|
+
maximum?: number;
|
|
78
|
+
minItems?: number;
|
|
79
|
+
maxItems?: number;
|
|
80
|
+
items?: { type: string };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface ToolDefinition {
|
|
84
|
+
name: string;
|
|
85
|
+
title: string;
|
|
86
|
+
description: string;
|
|
87
|
+
inputSchema: {
|
|
88
|
+
type: "object";
|
|
89
|
+
properties: Record<string, JSONSchemaProperty>;
|
|
90
|
+
required?: string[];
|
|
91
|
+
};
|
|
92
|
+
run(args: Record<string, unknown>, state: ServerState): Promise<ToolOutcome>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface ToolOutcome {
|
|
96
|
+
/** LLM-facing text content block. */
|
|
97
|
+
text: string;
|
|
98
|
+
/** When present, also returned as `structuredContent` (spec: JSON object). */
|
|
99
|
+
data?: Record<string, unknown>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Per-connection state; one instance per stdio session. */
|
|
103
|
+
export interface ServerState {
|
|
104
|
+
initialized: boolean;
|
|
105
|
+
/** Optional token override from `ghfind mcp --token <t>`. */
|
|
106
|
+
token?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A tool error — reported as a tool execution error (isError: true) per the
|
|
111
|
+
* MCP spec, not a protocol error. Network failures, bad queries, unknown
|
|
112
|
+
* repos, invalid args, etc.
|
|
113
|
+
*/
|
|
114
|
+
export class McpToolError extends Error {
|
|
115
|
+
constructor(message: string) {
|
|
116
|
+
super(message);
|
|
117
|
+
this.name = "McpToolError";
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ─── Token resolution ──────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
function resolveToken(state: ServerState, arg: unknown): string | undefined {
|
|
124
|
+
if (typeof arg === "string" && arg.trim()) return arg.trim();
|
|
125
|
+
if (state.token) return state.token;
|
|
126
|
+
return loadConfig().githubToken ?? process.env.GITHUB_TOKEN;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ─── Arg coercion helpers ──────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
function coerceInt(
|
|
132
|
+
value: unknown,
|
|
133
|
+
fallback: number,
|
|
134
|
+
min: number,
|
|
135
|
+
max: number,
|
|
136
|
+
): number {
|
|
137
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
138
|
+
if (!Number.isFinite(n)) return fallback;
|
|
139
|
+
return Math.min(Math.max(Math.trunc(n), min), max);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function coerceEnum<T extends string>(
|
|
143
|
+
value: unknown,
|
|
144
|
+
allowed: readonly T[],
|
|
145
|
+
fallback: T,
|
|
146
|
+
): T {
|
|
147
|
+
return typeof value === "string" &&
|
|
148
|
+
(allowed as readonly string[]).includes(value)
|
|
149
|
+
? (value as T)
|
|
150
|
+
: fallback;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ─── Formatting helpers ────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
const fmtInt = (n: number): string => n.toLocaleString("en-US");
|
|
156
|
+
|
|
157
|
+
function truncate(s: string, max: number): string {
|
|
158
|
+
return s.length <= max ? s : `${s.slice(0, max - 1)}…`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Slim, agent-friendly repo shape (drops fields only the TUI needs). */
|
|
162
|
+
function slimRepo(r: Repo): Record<string, unknown> {
|
|
163
|
+
return {
|
|
164
|
+
fullName: r.fullName,
|
|
165
|
+
description: r.description,
|
|
166
|
+
url: r.url,
|
|
167
|
+
stars: r.stars,
|
|
168
|
+
forks: r.forks,
|
|
169
|
+
language: r.language,
|
|
170
|
+
topics: r.topics,
|
|
171
|
+
pushedAt: r.pushedAt,
|
|
172
|
+
archived: r.archived,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function formatRepoLines(repos: Repo[]): string {
|
|
177
|
+
return repos
|
|
178
|
+
.map((r, i) => {
|
|
179
|
+
const desc = r.description ? ` — ${truncate(r.description, 100)}` : "";
|
|
180
|
+
const lang = r.language ? ` [${r.language}]` : "";
|
|
181
|
+
return `${i + 1}. ${r.fullName} ★ ${fmtInt(r.stars)}${lang}${desc}`;
|
|
182
|
+
})
|
|
183
|
+
.join("\n");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function formatPackageLines(
|
|
187
|
+
pkgs: Array<{
|
|
188
|
+
name: string;
|
|
189
|
+
version: string;
|
|
190
|
+
description: string | null;
|
|
191
|
+
downloads: number;
|
|
192
|
+
}>,
|
|
193
|
+
): string {
|
|
194
|
+
return pkgs
|
|
195
|
+
.map((p, i) => {
|
|
196
|
+
const desc = p.description ? ` — ${truncate(p.description, 90)}` : "";
|
|
197
|
+
return `${i + 1}. ${p.name}@${p.version}${desc} (↓ ${fmtInt(p.downloads)}/mo)`;
|
|
198
|
+
})
|
|
199
|
+
.join("\n");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const REPO_NAME_RE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
|
|
203
|
+
|
|
204
|
+
function parseRepoName(input: string): { owner: string; name: string } {
|
|
205
|
+
const fullName = input
|
|
206
|
+
.trim()
|
|
207
|
+
.replace(/\.git$/, "")
|
|
208
|
+
.replace(/^@/, "");
|
|
209
|
+
if (!REPO_NAME_RE.test(fullName)) {
|
|
210
|
+
throw new McpToolError(
|
|
211
|
+
`"${input}" is not a repo name — use owner/name, e.g. facebook/react.`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
const [owner, name] = fullName.split("/");
|
|
215
|
+
return { owner, name };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Minimal Repo for deep-dive (fetchDeepDive only reads owner/name). */
|
|
219
|
+
function stubRepo(fullName: string): Repo {
|
|
220
|
+
const { owner, name } = parseRepoName(fullName);
|
|
221
|
+
return {
|
|
222
|
+
id: 0,
|
|
223
|
+
fullName: `${owner}/${name}`,
|
|
224
|
+
name,
|
|
225
|
+
owner,
|
|
226
|
+
description: null,
|
|
227
|
+
url: `https://github.com/${owner}/${name}`,
|
|
228
|
+
stars: 0,
|
|
229
|
+
forks: 0,
|
|
230
|
+
watchers: 0,
|
|
231
|
+
language: null,
|
|
232
|
+
topics: [],
|
|
233
|
+
archived: false,
|
|
234
|
+
isFork: false,
|
|
235
|
+
private: false,
|
|
236
|
+
createdAt: "",
|
|
237
|
+
updatedAt: "",
|
|
238
|
+
pushedAt: "",
|
|
239
|
+
score: 0,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const TRENDING_QUERY = { keywords: [], qualifiers: [], raw: "trending" };
|
|
244
|
+
|
|
245
|
+
// ─── Tool registry ─────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
const REPO_SORTS = ["best-match", "stars", "updated", "forks"] as const;
|
|
248
|
+
const TRENDING_SINCE = ["daily", "weekly", "monthly"] as const;
|
|
249
|
+
const PKG_SORTS = ["best-match", "score", "downloads", "name"] as const;
|
|
250
|
+
|
|
251
|
+
const TOOLS: ToolDefinition[] = [
|
|
252
|
+
{
|
|
253
|
+
name: "ghfind_search_repos",
|
|
254
|
+
title: "Search GitHub repositories",
|
|
255
|
+
description:
|
|
256
|
+
"Search GitHub repositories with the GitHub search syntax. Supports qualifiers like language:, stars:>1000, topic:, org:, user:, pushed:>2026-01-01, quoted phrases, and -negation. Returns fullNames, stars, languages, descriptions, and URLs.",
|
|
257
|
+
inputSchema: {
|
|
258
|
+
type: "object",
|
|
259
|
+
properties: {
|
|
260
|
+
query: {
|
|
261
|
+
type: "string",
|
|
262
|
+
description:
|
|
263
|
+
'GitHub search query, e.g. "rust cli stars:>500" or "language:TypeScript topic:mcp".',
|
|
264
|
+
},
|
|
265
|
+
limit: {
|
|
266
|
+
type: "number",
|
|
267
|
+
description: "Max results, 1-100 (default 20).",
|
|
268
|
+
minimum: 1,
|
|
269
|
+
maximum: 100,
|
|
270
|
+
},
|
|
271
|
+
sort: {
|
|
272
|
+
type: "string",
|
|
273
|
+
description: "Sort strategy (default best-match).",
|
|
274
|
+
enum: [...REPO_SORTS],
|
|
275
|
+
},
|
|
276
|
+
token: {
|
|
277
|
+
type: "string",
|
|
278
|
+
description:
|
|
279
|
+
"Optional GitHub token (raises the rate limit from 60/hr to 5,000/hr).",
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
required: ["query"],
|
|
283
|
+
},
|
|
284
|
+
async run(args, state) {
|
|
285
|
+
const query = String(args.query ?? "").trim();
|
|
286
|
+
if (!query) throw new McpToolError("query must be a non-empty string.");
|
|
287
|
+
const limit = coerceInt(args.limit, 20, 1, 100);
|
|
288
|
+
const sort = coerceEnum<SortStrategy>(
|
|
289
|
+
args.sort,
|
|
290
|
+
REPO_SORTS,
|
|
291
|
+
"best-match",
|
|
292
|
+
);
|
|
293
|
+
const token = resolveToken(state, args.token);
|
|
294
|
+
|
|
295
|
+
const provider = createGitHubSearch(undefined, token ? [token] : []);
|
|
296
|
+
const parsed = parseQuery(query);
|
|
297
|
+
const options: SearchOptions = {
|
|
298
|
+
limit,
|
|
299
|
+
sort,
|
|
300
|
+
json: false,
|
|
301
|
+
verbose: false,
|
|
302
|
+
token,
|
|
303
|
+
};
|
|
304
|
+
const response = await provider.search(parsed, options);
|
|
305
|
+
const repos = rankRepos(response.repos, sort);
|
|
306
|
+
|
|
307
|
+
// Make agent searches visible in the user's TUI history (best-effort).
|
|
308
|
+
try {
|
|
309
|
+
appendHistory({
|
|
310
|
+
query,
|
|
311
|
+
mode: "search",
|
|
312
|
+
timestamp: Date.now(),
|
|
313
|
+
resultCount: repos.length,
|
|
314
|
+
});
|
|
315
|
+
} catch {
|
|
316
|
+
// non-critical
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const text =
|
|
320
|
+
repos.length === 0
|
|
321
|
+
? `No repositories found for "${query}" (total_count ${response.totalCount}).`
|
|
322
|
+
: formatRepoLines(repos) +
|
|
323
|
+
(response.totalCount > repos.length
|
|
324
|
+
? `\n\n(${fmtInt(response.totalCount)} total matches; showing ${repos.length}.)`
|
|
325
|
+
: "");
|
|
326
|
+
return {
|
|
327
|
+
text,
|
|
328
|
+
data: {
|
|
329
|
+
totalCount: response.totalCount,
|
|
330
|
+
repos: repos.map(slimRepo),
|
|
331
|
+
...(response.rateLimited ? { rateLimited: true } : {}),
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
},
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
name: "ghfind_trending",
|
|
338
|
+
title: "Trending GitHub repositories",
|
|
339
|
+
description:
|
|
340
|
+
"List repositories trending on GitHub today, this week, or this month, optionally filtered by language. Uses github.com/trending, so results carry stars-today momentum the Search API cannot provide.",
|
|
341
|
+
inputSchema: {
|
|
342
|
+
type: "object",
|
|
343
|
+
properties: {
|
|
344
|
+
since: {
|
|
345
|
+
type: "string",
|
|
346
|
+
description: "Trending period (default daily).",
|
|
347
|
+
enum: [...TRENDING_SINCE],
|
|
348
|
+
},
|
|
349
|
+
language: {
|
|
350
|
+
type: "string",
|
|
351
|
+
description:
|
|
352
|
+
'Optional language filter slug, e.g. "rust", "python", "typescript".',
|
|
353
|
+
},
|
|
354
|
+
limit: {
|
|
355
|
+
type: "number",
|
|
356
|
+
description: "Max results, 1-50 (default 25).",
|
|
357
|
+
minimum: 1,
|
|
358
|
+
maximum: 50,
|
|
359
|
+
},
|
|
360
|
+
token: { type: "string", description: "Optional GitHub token." },
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
async run(args, state) {
|
|
364
|
+
const since = coerceEnum(args.since, TRENDING_SINCE, "daily");
|
|
365
|
+
const limit = coerceInt(args.limit, 25, 1, 50);
|
|
366
|
+
const token = resolveToken(state, args.token);
|
|
367
|
+
|
|
368
|
+
let language: string | undefined;
|
|
369
|
+
const langArg =
|
|
370
|
+
typeof args.language === "string" ? args.language.trim() : "";
|
|
371
|
+
if (langArg) {
|
|
372
|
+
const slug = trendingLanguageSlug(langArg);
|
|
373
|
+
if (!TRENDING_LANGUAGES.has(slug)) {
|
|
374
|
+
throw new McpToolError(
|
|
375
|
+
`"${langArg}" is not a trending language filter. Try one of: rust, python, typescript, javascript, go, zig.`,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
language = slug;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const trending = createTrendingSearch();
|
|
382
|
+
const options: SearchOptions = {
|
|
383
|
+
limit,
|
|
384
|
+
sort: "stars",
|
|
385
|
+
json: false,
|
|
386
|
+
verbose: false,
|
|
387
|
+
trendingSince: since,
|
|
388
|
+
...(language ? { trendingLanguage: language } : {}),
|
|
389
|
+
token,
|
|
390
|
+
};
|
|
391
|
+
const response = await trending.search(TRENDING_QUERY, options);
|
|
392
|
+
const repos = response.repos.slice(0, limit);
|
|
393
|
+
const text =
|
|
394
|
+
repos.length === 0
|
|
395
|
+
? `No trending repositories returned (${since}${language ? `, ${language}` : ""}).`
|
|
396
|
+
: formatRepoLines(repos);
|
|
397
|
+
return {
|
|
398
|
+
text,
|
|
399
|
+
data: {
|
|
400
|
+
since,
|
|
401
|
+
...(language ? { language } : {}),
|
|
402
|
+
repos: repos.map(slimRepo),
|
|
403
|
+
},
|
|
404
|
+
};
|
|
405
|
+
},
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
name: "ghfind_npm_packages",
|
|
409
|
+
title: "Search npm packages",
|
|
410
|
+
description:
|
|
411
|
+
"Search the npm registry: name, version, monthly downloads, quality score, and description for each package.",
|
|
412
|
+
inputSchema: {
|
|
413
|
+
type: "object",
|
|
414
|
+
properties: {
|
|
415
|
+
query: {
|
|
416
|
+
type: "string",
|
|
417
|
+
description: 'Package search text, e.g. "terminal ui".',
|
|
418
|
+
},
|
|
419
|
+
limit: {
|
|
420
|
+
type: "number",
|
|
421
|
+
description: "Max results, 1-100 (default 20).",
|
|
422
|
+
minimum: 1,
|
|
423
|
+
maximum: 100,
|
|
424
|
+
},
|
|
425
|
+
sort: {
|
|
426
|
+
type: "string",
|
|
427
|
+
description: "Sort strategy (default best-match).",
|
|
428
|
+
enum: [...PKG_SORTS],
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
required: ["query"],
|
|
432
|
+
},
|
|
433
|
+
async run(args) {
|
|
434
|
+
const query = String(args.query ?? "").trim();
|
|
435
|
+
if (!query) throw new McpToolError("query must be a non-empty string.");
|
|
436
|
+
const limit = coerceInt(args.limit, 20, 1, 100);
|
|
437
|
+
const sort = coerceEnum<PackageSortMode>(
|
|
438
|
+
args.sort,
|
|
439
|
+
PKG_SORTS,
|
|
440
|
+
"best-match",
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
const { totalCount, packages } =
|
|
444
|
+
await createPackageSearch().searchPackage(query, limit);
|
|
445
|
+
const sorted = sortPackages(packages, sort);
|
|
446
|
+
const text =
|
|
447
|
+
sorted.length === 0
|
|
448
|
+
? `No npm packages found for "${query}".`
|
|
449
|
+
: formatPackageLines(sorted);
|
|
450
|
+
return { text, data: { totalCount, packages: sorted } };
|
|
451
|
+
},
|
|
452
|
+
},
|
|
453
|
+
{
|
|
454
|
+
name: "ghfind_org_profile",
|
|
455
|
+
title: "GitHub org profile",
|
|
456
|
+
description:
|
|
457
|
+
"Aggregate profile for a GitHub organization: repo count, total stars/forks of fetched repos, top languages, top repos by stars, and recently active repos.",
|
|
458
|
+
inputSchema: {
|
|
459
|
+
type: "object",
|
|
460
|
+
properties: {
|
|
461
|
+
org: { type: "string", description: 'Org login, e.g. "vercel".' },
|
|
462
|
+
limit: {
|
|
463
|
+
type: "number",
|
|
464
|
+
description: "How many repos to aggregate, 1-500 (default 100).",
|
|
465
|
+
minimum: 1,
|
|
466
|
+
maximum: 500,
|
|
467
|
+
},
|
|
468
|
+
token: { type: "string", description: "Optional GitHub token." },
|
|
469
|
+
},
|
|
470
|
+
required: ["org"],
|
|
471
|
+
},
|
|
472
|
+
async run(args, state) {
|
|
473
|
+
const org = String(args.org ?? "").trim();
|
|
474
|
+
if (!org) throw new McpToolError("org must be a non-empty string.");
|
|
475
|
+
const limit = coerceInt(args.limit, 100, 1, 500);
|
|
476
|
+
const profile = await fetchOrgProfile(org, {
|
|
477
|
+
token: resolveToken(state, args.token),
|
|
478
|
+
limit,
|
|
479
|
+
});
|
|
480
|
+
return { text: formatOrgText(profile), data: { ...profile } };
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
{
|
|
484
|
+
name: "ghfind_user_profile",
|
|
485
|
+
title: "GitHub user profile",
|
|
486
|
+
description:
|
|
487
|
+
"Aggregate profile for a GitHub user: repo count, total stars/forks, followers, top languages, top repos, and recently active repos.",
|
|
488
|
+
inputSchema: {
|
|
489
|
+
type: "object",
|
|
490
|
+
properties: {
|
|
491
|
+
user: { type: "string", description: 'GitHub login, e.g. "torvalds".' },
|
|
492
|
+
limit: {
|
|
493
|
+
type: "number",
|
|
494
|
+
description: "How many repos to aggregate, 1-500 (default 100).",
|
|
495
|
+
minimum: 1,
|
|
496
|
+
maximum: 500,
|
|
497
|
+
},
|
|
498
|
+
token: { type: "string", description: "Optional GitHub token." },
|
|
499
|
+
},
|
|
500
|
+
required: ["user"],
|
|
501
|
+
},
|
|
502
|
+
async run(args, state) {
|
|
503
|
+
const user = String(args.user ?? "").trim();
|
|
504
|
+
if (!user) throw new McpToolError("user must be a non-empty string.");
|
|
505
|
+
const limit = coerceInt(args.limit, 100, 1, 500);
|
|
506
|
+
const profile = await fetchUserProfile(user, {
|
|
507
|
+
token: resolveToken(state, args.token),
|
|
508
|
+
limit,
|
|
509
|
+
});
|
|
510
|
+
return { text: formatUserJson(profile), data: { ...profile } };
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
{
|
|
514
|
+
name: "ghfind_compare_repos",
|
|
515
|
+
title: "Compare GitHub repositories",
|
|
516
|
+
description:
|
|
517
|
+
"Fetch 2-5 repositories and return a side-by-side comparison (stars, forks, language, age, topics, descriptions).",
|
|
518
|
+
inputSchema: {
|
|
519
|
+
type: "object",
|
|
520
|
+
properties: {
|
|
521
|
+
repos: {
|
|
522
|
+
type: "array",
|
|
523
|
+
description:
|
|
524
|
+
'Repo fullNames to compare, e.g. ["oven-sh/bun", "nodejs/node"].',
|
|
525
|
+
items: { type: "string" },
|
|
526
|
+
minItems: 2,
|
|
527
|
+
maxItems: 5,
|
|
528
|
+
},
|
|
529
|
+
token: { type: "string", description: "Optional GitHub token." },
|
|
530
|
+
},
|
|
531
|
+
required: ["repos"],
|
|
532
|
+
},
|
|
533
|
+
async run(args, state) {
|
|
534
|
+
const names = Array.isArray(args.repos)
|
|
535
|
+
? args.repos.map((r) => String(r))
|
|
536
|
+
: [];
|
|
537
|
+
if (names.length < 2 || names.length > 5) {
|
|
538
|
+
throw new McpToolError("repos must contain 2-5 repo fullNames.");
|
|
539
|
+
}
|
|
540
|
+
const token = resolveToken(state, args.token);
|
|
541
|
+
const provider = createGitHubSearch(undefined, token ? [token] : []);
|
|
542
|
+
const found: Repo[] = [];
|
|
543
|
+
const missing: string[] = [];
|
|
544
|
+
for (const fullName of names) {
|
|
545
|
+
const res = await provider.search(parseQuery(fullName), {
|
|
546
|
+
limit: 1,
|
|
547
|
+
sort: "stars",
|
|
548
|
+
json: false,
|
|
549
|
+
verbose: false,
|
|
550
|
+
token,
|
|
551
|
+
});
|
|
552
|
+
if (res.repos.length > 0) found.push(res.repos[0]);
|
|
553
|
+
else missing.push(fullName);
|
|
554
|
+
}
|
|
555
|
+
if (found.length < 2) {
|
|
556
|
+
throw new McpToolError(
|
|
557
|
+
`Could not resolve enough repos to compare (found ${found.length}${missing.length ? `; not found: ${missing.join(", ")}` : ""}).`,
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
const text =
|
|
561
|
+
buildComparisonTable(found) +
|
|
562
|
+
(missing.length ? `\n\nNot found: ${missing.join(", ")}` : "");
|
|
563
|
+
return { text, data: { repos: found.map(slimRepo) } };
|
|
564
|
+
},
|
|
565
|
+
},
|
|
566
|
+
{
|
|
567
|
+
name: "ghfind_deep_dive",
|
|
568
|
+
title: "GitHub repo deep-dive",
|
|
569
|
+
description:
|
|
570
|
+
"Rich detail for one repository: language breakdown (bytes), top contributors, and a README excerpt.",
|
|
571
|
+
inputSchema: {
|
|
572
|
+
type: "object",
|
|
573
|
+
properties: {
|
|
574
|
+
repo: {
|
|
575
|
+
type: "string",
|
|
576
|
+
description: 'Repo fullName, e.g. "facebook/react".',
|
|
577
|
+
},
|
|
578
|
+
token: { type: "string", description: "Optional GitHub token." },
|
|
579
|
+
},
|
|
580
|
+
required: ["repo"],
|
|
581
|
+
},
|
|
582
|
+
async run(args, state) {
|
|
583
|
+
const repo = String(args.repo ?? "").trim();
|
|
584
|
+
if (!repo) throw new McpToolError("repo must be a non-empty string.");
|
|
585
|
+
const stub = stubRepo(repo);
|
|
586
|
+
const data = await fetchDeepDive(stub, resolveToken(state, args.token));
|
|
587
|
+
return { text: buildDeepDiveText(data), data: { ...data } };
|
|
588
|
+
},
|
|
589
|
+
},
|
|
590
|
+
{
|
|
591
|
+
name: "ghfind_bookmarked_releases",
|
|
592
|
+
title: "Bookmarked repo releases",
|
|
593
|
+
description:
|
|
594
|
+
"The cached release feed for the user's ghfind bookmarks (read-only). Run `ghfind --releases` in a terminal first to refresh the cache.",
|
|
595
|
+
inputSchema: { type: "object", properties: {} },
|
|
596
|
+
async run() {
|
|
597
|
+
const releases = allCachedReleases();
|
|
598
|
+
const bookmarkCount = getBookmarks().length;
|
|
599
|
+
const text =
|
|
600
|
+
releases.length === 0
|
|
601
|
+
? bookmarkCount === 0
|
|
602
|
+
? "No bookmarks yet. Bookmark repos in the ghfind TUI (Space → Bookmark)."
|
|
603
|
+
: `${bookmarkCount} bookmarks, no cached releases. Run \`ghfind --releases\` in a terminal to refresh.`
|
|
604
|
+
: releases
|
|
605
|
+
.map(
|
|
606
|
+
(r) =>
|
|
607
|
+
`${r.fullName} ${r.tagName}${r.prerelease ? " [pre]" : ""} — ${r.publishedAt.slice(0, 10)}\n ${r.url}`,
|
|
608
|
+
)
|
|
609
|
+
.join("\n");
|
|
610
|
+
return {
|
|
611
|
+
text,
|
|
612
|
+
data: {
|
|
613
|
+
bookmarkCount,
|
|
614
|
+
releases: releases.map((r) => ({ ...r })),
|
|
615
|
+
},
|
|
616
|
+
};
|
|
617
|
+
},
|
|
618
|
+
},
|
|
619
|
+
{
|
|
620
|
+
name: "ghfind_skill",
|
|
621
|
+
title: "ghfind CLI usage guide for agents",
|
|
622
|
+
description:
|
|
623
|
+
"Print a token-efficient guide to running ghfind directly in a shell (search syntax, flags, rate limits, token-efficiency tips). Prefer this over parsing --help output.",
|
|
624
|
+
inputSchema: { type: "object", properties: {} },
|
|
625
|
+
async run() {
|
|
626
|
+
ensureBuiltinSkill();
|
|
627
|
+
const skill = getSkill("ghfind-cli");
|
|
628
|
+
return {
|
|
629
|
+
text: skill?.content ?? "ghfind skill unavailable.",
|
|
630
|
+
data: { name: "ghfind-cli" },
|
|
631
|
+
};
|
|
632
|
+
},
|
|
633
|
+
},
|
|
634
|
+
];
|
|
635
|
+
|
|
636
|
+
/** Tools/list payload (name, title, description, inputSchema only). */
|
|
637
|
+
export function toolSummaries(): Array<Record<string, unknown>> {
|
|
638
|
+
return TOOLS.map((t) => ({
|
|
639
|
+
name: t.name,
|
|
640
|
+
title: t.title,
|
|
641
|
+
description: t.description,
|
|
642
|
+
inputSchema: t.inputSchema,
|
|
643
|
+
}));
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// ─── Response helpers ──────────────────────────────────────────────────
|
|
647
|
+
|
|
648
|
+
function respond(id: JsonRpcId, result: unknown): string {
|
|
649
|
+
return JSON.stringify({ jsonrpc: JSONRPC_VERSION, id, result });
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function respondError(
|
|
653
|
+
id: JsonRpcId,
|
|
654
|
+
code: number,
|
|
655
|
+
message: string,
|
|
656
|
+
data?: unknown,
|
|
657
|
+
): string {
|
|
658
|
+
return JSON.stringify({
|
|
659
|
+
jsonrpc: JSONRPC_VERSION,
|
|
660
|
+
id,
|
|
661
|
+
error: { code, message, ...(data !== undefined ? { data } : {}) },
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// ─── Tool dispatch ─────────────────────────────────────────────────────
|
|
666
|
+
|
|
667
|
+
function findTool(name: string): ToolDefinition | undefined {
|
|
668
|
+
return TOOLS.find((t) => t.name === name);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Run a tool by name. Throws McpToolError for unknown tools or failures;
|
|
673
|
+
* callers turn that into a tool execution error (isError: true).
|
|
674
|
+
*/
|
|
675
|
+
export async function callTool(
|
|
676
|
+
name: string,
|
|
677
|
+
args: Record<string, unknown>,
|
|
678
|
+
state: ServerState,
|
|
679
|
+
): Promise<ToolOutcome> {
|
|
680
|
+
const tool = findTool(name);
|
|
681
|
+
if (!tool) throw new McpToolError(`Unknown tool: ${name}`);
|
|
682
|
+
try {
|
|
683
|
+
return await tool.run(args, state);
|
|
684
|
+
} catch (err) {
|
|
685
|
+
if (err instanceof McpToolError) throw err;
|
|
686
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
687
|
+
throw new McpToolError(`ghfind ${name} failed: ${msg}`);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// ─── Request routing ───────────────────────────────────────────────────
|
|
692
|
+
|
|
693
|
+
interface ServerConfig {
|
|
694
|
+
/** Emitted lines are written here (stdout in prod, capturable in tests). */
|
|
695
|
+
write(line: string): void;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Process one JSON-RPC line. Notifications (including parse garbage with no
|
|
700
|
+
* id) produce no response; everything else writes exactly one response line.
|
|
701
|
+
*/
|
|
702
|
+
export async function processServerLine(
|
|
703
|
+
line: string,
|
|
704
|
+
state: ServerState,
|
|
705
|
+
config: ServerConfig,
|
|
706
|
+
): Promise<void> {
|
|
707
|
+
let msg: JsonRpcRequest;
|
|
708
|
+
try {
|
|
709
|
+
msg = JSON.parse(line) as JsonRpcRequest;
|
|
710
|
+
} catch {
|
|
711
|
+
config.write(respondError(null, PARSE_ERROR, "Parse error"));
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
if (
|
|
715
|
+
typeof msg !== "object" ||
|
|
716
|
+
msg === null ||
|
|
717
|
+
typeof msg.method !== "string"
|
|
718
|
+
) {
|
|
719
|
+
config.write(respondError(null, INVALID_REQUEST, "Invalid Request"));
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const isNotification = msg.id === undefined || msg.id === null;
|
|
724
|
+
const id = isNotification ? null : msg.id;
|
|
725
|
+
|
|
726
|
+
// Notifications never get responses.
|
|
727
|
+
if (msg.method === "notifications/initialized") {
|
|
728
|
+
state.initialized = true;
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
if (isNotification) return; // unknown notifications are ignored
|
|
732
|
+
|
|
733
|
+
// Pre-initialization guard (spec: only initialize and ping may precede it).
|
|
734
|
+
if (
|
|
735
|
+
!state.initialized &&
|
|
736
|
+
msg.method !== "initialize" &&
|
|
737
|
+
msg.method !== "ping"
|
|
738
|
+
) {
|
|
739
|
+
config.write(
|
|
740
|
+
respondError(
|
|
741
|
+
id,
|
|
742
|
+
INVALID_REQUEST,
|
|
743
|
+
`Server not initialized — send "initialize" first (method was "${msg.method}").`,
|
|
744
|
+
),
|
|
745
|
+
);
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
switch (msg.method) {
|
|
750
|
+
case "initialize": {
|
|
751
|
+
const requested = String(msg.params?.protocolVersion ?? "");
|
|
752
|
+
const negotiated = SUPPORTED_PROTOCOL_VERSIONS.includes(requested)
|
|
753
|
+
? requested
|
|
754
|
+
: LATEST_PROTOCOL_VERSION;
|
|
755
|
+
// The spec sends notifications/initialized next, but some clients skip
|
|
756
|
+
// it; treat the initialize response as the capability gate.
|
|
757
|
+
state.initialized = true;
|
|
758
|
+
config.write(
|
|
759
|
+
respond(id, {
|
|
760
|
+
protocolVersion: negotiated,
|
|
761
|
+
capabilities: { tools: { listChanged: false } },
|
|
762
|
+
serverInfo: {
|
|
763
|
+
name: "ghfind",
|
|
764
|
+
title: "ghfind — GitHub search for agents",
|
|
765
|
+
version: getVersion(),
|
|
766
|
+
},
|
|
767
|
+
instructions:
|
|
768
|
+
"ghfind gives agents GitHub search: repositories, trending, npm packages, org/user profiles, repo comparison and deep-dives. " +
|
|
769
|
+
"Call ghfind_skill for the full CLI guide. Unauthenticated requests are rate-limited to 60/hr; pass a token or set GITHUB_TOKEN for 5,000/hr.",
|
|
770
|
+
}),
|
|
771
|
+
);
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
case "ping": {
|
|
776
|
+
config.write(respond(id, {}));
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
case "tools/list": {
|
|
781
|
+
config.write(respond(id, { tools: toolSummaries() }));
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
case "tools/call": {
|
|
786
|
+
const name = String(msg.params?.name ?? "");
|
|
787
|
+
const tool = findTool(name);
|
|
788
|
+
if (!tool) {
|
|
789
|
+
// Per the MCP spec's own example, unknown tools are -32602.
|
|
790
|
+
config.write(respondError(id, INVALID_PARAMS, `Unknown tool: ${name}`));
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
const rawArgs = msg.params?.arguments;
|
|
794
|
+
const args =
|
|
795
|
+
typeof rawArgs === "object" && rawArgs !== null
|
|
796
|
+
? (rawArgs as Record<string, unknown>)
|
|
797
|
+
: {};
|
|
798
|
+
try {
|
|
799
|
+
const outcome = await callTool(name, args, state);
|
|
800
|
+
config.write(
|
|
801
|
+
respond(id, {
|
|
802
|
+
content: [{ type: "text", text: outcome.text }],
|
|
803
|
+
...(outcome.data ? { structuredContent: outcome.data } : {}),
|
|
804
|
+
isError: false,
|
|
805
|
+
}),
|
|
806
|
+
);
|
|
807
|
+
} catch (err) {
|
|
808
|
+
const message =
|
|
809
|
+
err instanceof McpToolError
|
|
810
|
+
? err.message
|
|
811
|
+
: `ghfind tool "${name}" failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
812
|
+
config.write(
|
|
813
|
+
respond(id, {
|
|
814
|
+
content: [{ type: "text", text: message }],
|
|
815
|
+
isError: true,
|
|
816
|
+
}),
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
default: {
|
|
823
|
+
config.write(
|
|
824
|
+
respondError(id, METHOD_NOT_FOUND, `Method not found: ${msg.method}`),
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// ─── stdio transport ───────────────────────────────────────────────────
|
|
831
|
+
|
|
832
|
+
function writeStdout(line: string): void {
|
|
833
|
+
process.stdout.write(line + "\n");
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Run the MCP server over stdio until stdin closes. In-flight tool calls are
|
|
838
|
+
* drained before exit (with a grace timeout) so one-shot pipelines and fast
|
|
839
|
+
* closing clients still receive their responses.
|
|
840
|
+
*/
|
|
841
|
+
export async function runMcpServer(
|
|
842
|
+
options: { token?: string } = {},
|
|
843
|
+
): Promise<void> {
|
|
844
|
+
const state: ServerState = {
|
|
845
|
+
initialized: false,
|
|
846
|
+
...(options.token ? { token: options.token } : {}),
|
|
847
|
+
};
|
|
848
|
+
const config: ServerConfig = { write: writeStdout };
|
|
849
|
+
|
|
850
|
+
let pending = 0;
|
|
851
|
+
let stdinClosed = false;
|
|
852
|
+
let shutdownTimer: ReturnType<typeof setTimeout> | undefined;
|
|
853
|
+
|
|
854
|
+
const maybeExit = (): void => {
|
|
855
|
+
if (!stdinClosed || pending > 0) return;
|
|
856
|
+
process.exit(0);
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
process.stdin.setEncoding("utf8");
|
|
860
|
+
let buffer = "";
|
|
861
|
+
process.stdin.on("data", (chunk: string) => {
|
|
862
|
+
buffer += chunk;
|
|
863
|
+
let idx: number;
|
|
864
|
+
while ((idx = buffer.indexOf("\n")) >= 0) {
|
|
865
|
+
const line = buffer.slice(0, idx).trim();
|
|
866
|
+
buffer = buffer.slice(idx + 1);
|
|
867
|
+
if (!line) continue;
|
|
868
|
+
pending++;
|
|
869
|
+
void processServerLine(line, state, config).finally(() => {
|
|
870
|
+
pending--;
|
|
871
|
+
if (shutdownTimer) {
|
|
872
|
+
clearTimeout(shutdownTimer);
|
|
873
|
+
shutdownTimer = undefined;
|
|
874
|
+
}
|
|
875
|
+
maybeExit();
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
});
|
|
879
|
+
const close = (): void => {
|
|
880
|
+
stdinClosed = true;
|
|
881
|
+
if (pending === 0) {
|
|
882
|
+
process.exit(0);
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
// Grace period for in-flight requests; network calls have their own
|
|
886
|
+
// timeouts, so 30s is a generous ceiling.
|
|
887
|
+
shutdownTimer = setTimeout(() => process.exit(0), 30_000);
|
|
888
|
+
};
|
|
889
|
+
process.stdin.on("end", close);
|
|
890
|
+
process.stdin.on("error", close);
|
|
891
|
+
await new Promise<never>(() => {
|
|
892
|
+
// park forever; the transport owns the lifetime
|
|
893
|
+
});
|
|
894
|
+
}
|