@looptech-ai/understand-quickly-mcp 0.1.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/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@looptech-ai/understand-quickly-mcp",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "publishConfig": {
6
+ "access": "public",
7
+ "provenance": true
8
+ },
9
+ "description": "Thin MCP server exposing the understand-quickly registry as MCP tools.",
10
+ "author": "Alex Macdonald-Smith <Alex.Mac@LoopTech.AI>",
11
+ "contributors": [
12
+ "LoopTech.AI <https://looptech.ai>"
13
+ ],
14
+ "license": "Apache-2.0 AND LicenseRef-Understand-Quickly-Data-License-1.0",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/looptech-ai/understand-quickly.git",
18
+ "directory": "mcp"
19
+ },
20
+ "type": "module",
21
+ "engines": {
22
+ "node": ">=18.0.0"
23
+ },
24
+ "main": "dist/index.js",
25
+ "bin": {
26
+ "understand-quickly-mcp": "dist/index.js"
27
+ },
28
+ "scripts": {
29
+ "dev": "tsx src/index.ts",
30
+ "build": "tsc -p tsconfig.json",
31
+ "start": "node dist/index.js",
32
+ "test": "tsx --test tests/registry.test.ts tests/tools.test.ts"
33
+ },
34
+ "dependencies": {
35
+ "@modelcontextprotocol/sdk": "^1.29.0",
36
+ "zod": "^4.4.0"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "^18.19.0",
40
+ "tsx": "^4.19.0",
41
+ "typescript": "^5.5.0"
42
+ }
43
+ }
package/src/index.ts ADDED
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+
6
+ import { listRepos } from "./tools/list-repos.js";
7
+ import { getGraph } from "./tools/get-graph.js";
8
+ import { searchConcepts } from "./tools/search-concepts.js";
9
+ import { findGraphForRepo } from "./tools/find-graph-for-repo.js";
10
+
11
+ const server = new McpServer(
12
+ {
13
+ name: "understand-quickly-mcp",
14
+ version: "0.1.0",
15
+ },
16
+ {
17
+ capabilities: {
18
+ tools: {},
19
+ },
20
+ },
21
+ );
22
+
23
+ function jsonContent(value: unknown) {
24
+ return {
25
+ content: [
26
+ {
27
+ type: "text" as const,
28
+ text: JSON.stringify(value, null, 2),
29
+ },
30
+ ],
31
+ };
32
+ }
33
+
34
+ function errorContent(err: unknown) {
35
+ const message = err instanceof Error ? err.message : String(err);
36
+ return {
37
+ isError: true,
38
+ content: [{ type: "text" as const, text: message }],
39
+ };
40
+ }
41
+
42
+ server.registerTool(
43
+ "list_repos",
44
+ {
45
+ description:
46
+ "List entries in the understand-quickly registry. Optional filters: format, tag, status.",
47
+ inputSchema: {
48
+ format: z
49
+ .string()
50
+ .optional()
51
+ .describe("Exact match on entry.format (e.g. \"understand-anything@1\")."),
52
+ tag: z
53
+ .string()
54
+ .optional()
55
+ .describe("Returns entries whose tags array contains this string."),
56
+ status: z
57
+ .string()
58
+ .optional()
59
+ .describe("Exact match on entry.status (typically \"ok\")."),
60
+ },
61
+ },
62
+ async ({ format, tag, status }) => {
63
+ try {
64
+ const repos = await listRepos({ format, tag, status });
65
+ return jsonContent(repos);
66
+ } catch (err) {
67
+ return errorContent(err);
68
+ }
69
+ },
70
+ );
71
+
72
+ server.registerTool(
73
+ "get_graph",
74
+ {
75
+ description:
76
+ "Fetch and return the parsed knowledge graph JSON for a registry entry by id.",
77
+ inputSchema: {
78
+ id: z
79
+ .string()
80
+ .min(1)
81
+ .describe("Registry entry id, e.g. \"Lum1104/Understand-Anything\"."),
82
+ },
83
+ },
84
+ async ({ id }) => {
85
+ try {
86
+ const graph = await getGraph({ id });
87
+ return jsonContent(graph);
88
+ } catch (err) {
89
+ return errorContent(err);
90
+ }
91
+ },
92
+ );
93
+
94
+ server.registerTool(
95
+ "search_concepts",
96
+ {
97
+ description:
98
+ "Search aggregated concept terms across the registry. By default reads the precomputed stats.json (single GET, cached 60s) and returns matching terms with sample entry ids. If `id` is given, falls back to a single-graph node search. If stats.json is unavailable, falls back to a capped cross-graph node fan-out.",
99
+ inputSchema: {
100
+ query: z.string().min(1).describe("Substring to search for (case-insensitive)."),
101
+ id: z
102
+ .string()
103
+ .optional()
104
+ .describe(
105
+ "Optional entry id. If given, scopes the search to that single graph (legacy node-level mode).",
106
+ ),
107
+ },
108
+ },
109
+ async ({ query, id }) => {
110
+ try {
111
+ const result = await searchConcepts({ query, id });
112
+ return jsonContent(result);
113
+ } catch (err) {
114
+ return errorContent(err);
115
+ }
116
+ },
117
+ );
118
+
119
+ server.registerTool(
120
+ "find_graph_for_repo",
121
+ {
122
+ description:
123
+ "Look up a registry entry by `id` (\"owner/repo\") or `github_url` (https or ssh form, with optional .git/branch/path). Returns graph_url + drift metadata, or {found:false, suggestions} with up to 5 fuzzy-matched ids.",
124
+ inputSchema: {
125
+ id: z
126
+ .string()
127
+ .regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/, {
128
+ message: "id must be \"owner/repo\".",
129
+ })
130
+ .optional()
131
+ .describe("Registry id, e.g. \"Lum1104/Understand-Anything\"."),
132
+ github_url: z
133
+ .string()
134
+ .min(1)
135
+ .optional()
136
+ .describe(
137
+ "GitHub URL (https or ssh form). Trailing .git, branches, and sub-paths are tolerated.",
138
+ ),
139
+ },
140
+ },
141
+ async ({ id, github_url }) => {
142
+ try {
143
+ if (!id && !github_url) {
144
+ return errorContent(
145
+ new Error("Provide at least one of `id` or `github_url`."),
146
+ );
147
+ }
148
+ const result = await findGraphForRepo({ id, github_url });
149
+ return jsonContent(result);
150
+ } catch (err) {
151
+ return errorContent(err);
152
+ }
153
+ },
154
+ );
155
+
156
+ async function main() {
157
+ const transport = new StdioServerTransport();
158
+ await server.connect(transport);
159
+ // Log to stderr so we don't pollute the JSON-RPC stream on stdout.
160
+ // eslint-disable-next-line no-console
161
+ console.error("understand-quickly MCP server ready on stdio");
162
+ }
163
+
164
+ main().catch((err) => {
165
+ // eslint-disable-next-line no-console
166
+ console.error("Fatal MCP server error:", err);
167
+ process.exit(1);
168
+ });
@@ -0,0 +1,272 @@
1
+ import { isIP } from "node:net";
2
+ import type {
3
+ FetchImpl,
4
+ Registry,
5
+ RegistryEntry,
6
+ StatsJson,
7
+ } from "./types.js";
8
+
9
+ export const DEFAULT_REGISTRY_URL =
10
+ "https://looptech-ai.github.io/understand-quickly/registry.json";
11
+ export const DEFAULT_STATS_URL =
12
+ "https://looptech-ai.github.io/understand-quickly/stats.json";
13
+ export const DEFAULT_TTL_MS = 60_000;
14
+
15
+ interface CacheRecord {
16
+ fetchedAt: number;
17
+ registry: Registry;
18
+ }
19
+
20
+ interface StatsCacheRecord {
21
+ fetchedAt: number;
22
+ stats: StatsJson;
23
+ }
24
+
25
+ // Module-level cache keyed by source URL. Each MCP process gets its own cache;
26
+ // that is fine for a stub server because the registry is small.
27
+ const cache = new Map<string, CacheRecord>();
28
+ const statsCache = new Map<string, StatsCacheRecord>();
29
+
30
+ export interface LoadRegistryOptions {
31
+ source?: string;
32
+ fetchImpl?: FetchImpl;
33
+ cacheKey?: string;
34
+ ttlMs?: number;
35
+ now?: () => number;
36
+ }
37
+
38
+ /**
39
+ * Load `registry.json` with a small in-memory TTL cache.
40
+ *
41
+ * Pure-ish: all I/O and time goes through injected dependencies, so callers in
42
+ * tests can drive the cache with a fake fetch and clock.
43
+ */
44
+ export async function loadRegistry(
45
+ options: LoadRegistryOptions = {},
46
+ ): Promise<Registry> {
47
+ const source = options.source ?? DEFAULT_REGISTRY_URL;
48
+ const fetchImpl = options.fetchImpl ?? (globalThis.fetch as unknown as FetchImpl);
49
+ const cacheKey = options.cacheKey ?? source;
50
+ const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
51
+ const now = options.now ?? Date.now;
52
+
53
+ if (!fetchImpl) {
54
+ throw new Error(
55
+ "No fetch implementation available. Pass `fetchImpl` or run on Node 20+.",
56
+ );
57
+ }
58
+
59
+ const cached = cache.get(cacheKey);
60
+ if (cached && now() - cached.fetchedAt < ttlMs) {
61
+ return cached.registry;
62
+ }
63
+
64
+ const response = await fetchImpl(source);
65
+ if (!response.ok) {
66
+ // 5xx is transient; if we have a stale cache entry, prefer it over a hard
67
+ // throw so an upstream Pages outage doesn't take down every MCP client.
68
+ if (response.status >= 500 && cached) return cached.registry;
69
+ throw new Error(
70
+ `Failed to fetch registry from ${source}: ${response.status} ${response.statusText}`,
71
+ );
72
+ }
73
+ const body = (await response.json()) as Registry;
74
+ if (!body || !Array.isArray(body.entries)) {
75
+ throw new Error(
76
+ `Registry at ${source} is malformed: missing \`entries\` array`,
77
+ );
78
+ }
79
+ // Guard against silently consuming a future v2 registry with v1-shaped
80
+ // tools. The registry's meta.schema.json pins schema_version to const 1; an
81
+ // older MCP build hitting a newer registry should fail loudly so users know
82
+ // to upgrade rather than getting half-broken responses.
83
+ const sv = (body as { schema_version?: unknown }).schema_version;
84
+ if (sv !== undefined && sv !== 1) {
85
+ throw new Error(
86
+ `Registry at ${source} reports schema_version=${String(sv)}; this MCP build supports schema_version=1. Upgrade @looptech-ai/understand-quickly-mcp.`,
87
+ );
88
+ }
89
+ cache.set(cacheKey, { fetchedAt: now(), registry: body });
90
+ return body;
91
+ }
92
+
93
+ /** Drop the cached registry for a given key (default: the configured source). */
94
+ export function clearCache(cacheKey?: string): void {
95
+ if (cacheKey === undefined) {
96
+ cache.clear();
97
+ return;
98
+ }
99
+ cache.delete(cacheKey);
100
+ }
101
+
102
+ export interface LoadStatsOptions {
103
+ source?: string;
104
+ fetchImpl?: FetchImpl;
105
+ cacheKey?: string;
106
+ ttlMs?: number;
107
+ now?: () => number;
108
+ }
109
+
110
+ /**
111
+ * Load `stats.json` with the same TTL caching pattern as `loadRegistry`.
112
+ *
113
+ * Validates the minimum shape (schema_version + concepts array). Throws on
114
+ * non-OK HTTP, malformed body, or missing concepts; callers that want a
115
+ * fallback path should catch.
116
+ */
117
+ export async function loadStats(
118
+ options: LoadStatsOptions = {},
119
+ ): Promise<StatsJson> {
120
+ const source = options.source ?? DEFAULT_STATS_URL;
121
+ const fetchImpl = options.fetchImpl ?? (globalThis.fetch as unknown as FetchImpl);
122
+ const cacheKey = options.cacheKey ?? source;
123
+ const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
124
+ const now = options.now ?? Date.now;
125
+
126
+ if (!fetchImpl) {
127
+ throw new Error(
128
+ "No fetch implementation available. Pass `fetchImpl` or run on Node 20+.",
129
+ );
130
+ }
131
+
132
+ const cached = statsCache.get(cacheKey);
133
+ if (cached && now() - cached.fetchedAt < ttlMs) {
134
+ return cached.stats;
135
+ }
136
+
137
+ const response = await fetchImpl(source);
138
+ if (!response.ok) {
139
+ throw new Error(
140
+ `Failed to fetch stats from ${source}: ${response.status} ${response.statusText}`,
141
+ );
142
+ }
143
+ const body = (await response.json()) as StatsJson;
144
+ if (!body || !Array.isArray(body.concepts)) {
145
+ throw new Error(
146
+ `Stats at ${source} is malformed: missing \`concepts\` array`,
147
+ );
148
+ }
149
+ statsCache.set(cacheKey, { fetchedAt: now(), stats: body });
150
+ return body;
151
+ }
152
+
153
+ /** Drop the cached stats payload (default: every key). */
154
+ export function clearStatsCache(cacheKey?: string): void {
155
+ if (cacheKey === undefined) {
156
+ statsCache.clear();
157
+ return;
158
+ }
159
+ statsCache.delete(cacheKey);
160
+ }
161
+
162
+ /**
163
+ * Filter entries with a predicate. Trivial wrapper, but exported so the tool
164
+ * layer composes via a single named function rather than ad-hoc `.filter`s.
165
+ */
166
+ export function filterEntries(
167
+ entries: RegistryEntry[],
168
+ predicate: (entry: RegistryEntry) => boolean,
169
+ ): RegistryEntry[] {
170
+ return entries.filter(predicate);
171
+ }
172
+
173
+ /** Resolve the registry URL from env, falling back to the public default. */
174
+ export function resolveRegistrySource(): string {
175
+ return process.env.UNDERSTAND_QUICKLY_REGISTRY ?? DEFAULT_REGISTRY_URL;
176
+ }
177
+
178
+ /** Resolve the stats URL from env, falling back to the public default. */
179
+ export function resolveStatsSource(): string {
180
+ return process.env.UNDERSTAND_QUICKLY_STATS ?? DEFAULT_STATS_URL;
181
+ }
182
+
183
+ /**
184
+ * Find an entry by id. Exposed for the `get_graph` and `search_concepts` tools.
185
+ */
186
+ export function findEntryById(
187
+ registry: Registry,
188
+ id: string,
189
+ ): RegistryEntry | undefined {
190
+ return registry.entries.find((entry) => entry.id === id);
191
+ }
192
+
193
+ // SSRF guard: reject URLs that resolve to private / link-local / loopback /
194
+ // metadata addresses, or that use a non-https scheme. The registry's own
195
+ // schemas pin graph_url to https; this is defence-in-depth for the MCP path,
196
+ // where a malicious registry mirror or a misconfigured URL could otherwise
197
+ // trick this process into fetching cloud-metadata endpoints.
198
+ //
199
+ // Note: this checks the literal hostname, not a resolved IP. Full DNS-rebind
200
+ // protection requires a custom dispatcher; for v0.1 we accept that gap and
201
+ // rely on the surrounding HTTPS-only invariant (TLS makes rebinding harder
202
+ // since the cert must match the literal hostname).
203
+ export function assertSafeFetchUrl(rawUrl: string): URL {
204
+ let u: URL;
205
+ try {
206
+ u = new URL(rawUrl);
207
+ } catch {
208
+ throw new Error(`Invalid URL: ${rawUrl}`);
209
+ }
210
+ if (u.protocol !== "https:") {
211
+ throw new Error(`Refusing non-https URL: ${rawUrl}`);
212
+ }
213
+ const host = u.hostname.toLowerCase();
214
+ // Block obvious local / metadata targets by literal hostname.
215
+ if (
216
+ host === "localhost" ||
217
+ host === "0.0.0.0" ||
218
+ host === "127.0.0.1" ||
219
+ host === "::1" ||
220
+ host === "metadata.google.internal" ||
221
+ host === "metadata" ||
222
+ host.endsWith(".internal") ||
223
+ host.endsWith(".local")
224
+ ) {
225
+ throw new Error(`Refusing internal host: ${host}`);
226
+ }
227
+ // Block IPv4 literals in private/link-local/loopback ranges.
228
+ const ipKind = isIP(host);
229
+ if (ipKind === 4) {
230
+ const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
231
+ if (ipv4) {
232
+ const [a, b] = ipv4.slice(1).map(Number);
233
+ if (
234
+ a === 10 ||
235
+ a === 127 ||
236
+ (a === 169 && b === 254) || // link-local incl. AWS/GCP metadata 169.254.169.254
237
+ (a === 172 && b >= 16 && b <= 31) ||
238
+ (a === 192 && b === 168) ||
239
+ a === 0
240
+ ) {
241
+ throw new Error(`Refusing private IPv4 host: ${host}`);
242
+ }
243
+ }
244
+ } else if (ipKind === 6) {
245
+ // URL.hostname strips the brackets from IPv6 literals (e.g. `fc00::1`,
246
+ // not `[fc00::1]`), so a startsWith("[") check would never fire. Match
247
+ // against the normalized address prefix directly.
248
+ // - fc00::/7 (unique-local) → first hex digit 'f' + second 'c' or 'd'
249
+ // - fe80::/10 (link-local) → first hex digit 'f' + second 'e' + third 8|9|a|b
250
+ // - ::1 loopback (caught earlier by literal hostname check)
251
+ // - ::ffff:0:0/96 IPv4-mapped → defer to Node which will resolve via dual-stack
252
+ if (/^f[cd]/i.test(host) || /^fe[89ab]/i.test(host)) {
253
+ throw new Error(`Refusing private IPv6 host: ${host}`);
254
+ }
255
+ }
256
+ return u;
257
+ }
258
+
259
+ /** Fetch and parse a single graph URL. Used by `get_graph` and `search_concepts`. */
260
+ export async function fetchGraph(
261
+ graphUrl: string,
262
+ fetchImpl: FetchImpl = globalThis.fetch as unknown as FetchImpl,
263
+ ): Promise<unknown> {
264
+ assertSafeFetchUrl(graphUrl);
265
+ const response = await fetchImpl(graphUrl);
266
+ if (!response.ok) {
267
+ throw new Error(
268
+ `Failed to fetch graph from ${graphUrl}: ${response.status} ${response.statusText}`,
269
+ );
270
+ }
271
+ return response.json();
272
+ }
@@ -0,0 +1,221 @@
1
+ import {
2
+ findEntryById,
3
+ loadRegistry,
4
+ resolveRegistrySource,
5
+ } from "../registry.js";
6
+ import type {
7
+ FetchImpl,
8
+ FindGraphForRepoParams,
9
+ RegistryEntry,
10
+ } from "../types.js";
11
+
12
+ // Registry id pattern: `<owner>/<repo>` with the same character class the
13
+ // site/scripts already use in error messages and validation.
14
+ const ID_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
15
+
16
+ const HTTPS_GITHUB_RE = /^https?:\/\/github\.com\/([^/\s]+)\/([^/\s?#]+)(?:[\/?#].*)?$/i;
17
+ const SSH_GITHUB_RE = /^git@github\.com:([^/\s]+)\/([^/\s?#]+?)(?:\.git)?$/i;
18
+
19
+ /**
20
+ * Parse a github URL into a registry id. Accepts:
21
+ * - https://github.com/owner/repo
22
+ * - https://github.com/owner/repo.git
23
+ * - https://github.com/owner/repo/ (trailing slash)
24
+ * - https://github.com/owner/repo/tree/branch/...
25
+ * - git@github.com:owner/repo.git
26
+ */
27
+ export function parseGithubUrl(url: string): string | undefined {
28
+ if (typeof url !== "string" || url.length === 0) return undefined;
29
+ const trimmed = url.trim();
30
+
31
+ const ssh = trimmed.match(SSH_GITHUB_RE);
32
+ if (ssh) {
33
+ const owner = ssh[1];
34
+ const repo = ssh[2].replace(/\.git$/i, "");
35
+ if (!owner || !repo) return undefined;
36
+ return `${owner}/${repo}`;
37
+ }
38
+
39
+ const https = trimmed.match(HTTPS_GITHUB_RE);
40
+ if (https) {
41
+ const owner = https[1];
42
+ let repo = https[2];
43
+ repo = repo.replace(/\.git$/i, "");
44
+ if (!owner || !repo) return undefined;
45
+ return `${owner}/${repo}`;
46
+ }
47
+
48
+ return undefined;
49
+ }
50
+
51
+ /** Levenshtein distance, capped early when it exceeds `maxDistance` for speed. */
52
+ export function levenshtein(a: string, b: string, maxDistance = Infinity): number {
53
+ if (a === b) return 0;
54
+ const al = a.length;
55
+ const bl = b.length;
56
+ if (al === 0) return bl;
57
+ if (bl === 0) return al;
58
+ if (Math.abs(al - bl) > maxDistance) return maxDistance + 1;
59
+
60
+ // Two-row DP (rolling).
61
+ let prev = new Array<number>(bl + 1);
62
+ let curr = new Array<number>(bl + 1);
63
+ for (let j = 0; j <= bl; j++) prev[j] = j;
64
+ for (let i = 1; i <= al; i++) {
65
+ curr[0] = i;
66
+ let rowMin = curr[0];
67
+ for (let j = 1; j <= bl; j++) {
68
+ const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
69
+ curr[j] = Math.min(
70
+ curr[j - 1] + 1, // insertion
71
+ prev[j] + 1, // deletion
72
+ prev[j - 1] + cost, // substitution
73
+ );
74
+ if (curr[j] < rowMin) rowMin = curr[j];
75
+ }
76
+ if (rowMin > maxDistance) return maxDistance + 1;
77
+ [prev, curr] = [curr, prev];
78
+ }
79
+ return prev[bl];
80
+ }
81
+
82
+ function suggestFuzzy(target: string, ids: string[], max = 5, maxDistance = 3): string[] {
83
+ const targetLower = target.toLowerCase();
84
+ const scored: Array<{ id: string; dist: number }> = [];
85
+ for (const id of ids) {
86
+ const d = levenshtein(targetLower, id.toLowerCase(), maxDistance);
87
+ if (d <= maxDistance) scored.push({ id, dist: d });
88
+ }
89
+ scored.sort((a, b) => a.dist - b.dist || a.id.localeCompare(b.id));
90
+ return scored.slice(0, max).map((s) => s.id);
91
+ }
92
+
93
+ function driftSummary(entry: RegistryEntry): string | undefined {
94
+ const behind = entry.commits_behind;
95
+ if (typeof behind === "number" && behind > 0) {
96
+ return `behind by ${behind} commit${behind === 1 ? "" : "s"}`;
97
+ }
98
+ if (typeof behind === "number" && behind === 0) {
99
+ return "up to date";
100
+ }
101
+ return undefined;
102
+ }
103
+
104
+ export interface FindGraphForRepoFoundResult {
105
+ found: true;
106
+ id: string;
107
+ format: string;
108
+ graph_url: string;
109
+ status?: string;
110
+ last_synced?: string;
111
+ last_sha?: string;
112
+ source_sha?: string;
113
+ head_sha?: string;
114
+ commits_behind?: number;
115
+ drift_summary?: string;
116
+ }
117
+
118
+ export interface FindGraphForRepoNotFoundResult {
119
+ found: false;
120
+ suggestions: string[];
121
+ }
122
+
123
+ export type FindGraphForRepoResult =
124
+ | FindGraphForRepoFoundResult
125
+ | FindGraphForRepoNotFoundResult;
126
+
127
+ export interface FindGraphForRepoOptions {
128
+ fetchImpl?: FetchImpl;
129
+ source?: string;
130
+ }
131
+
132
+ export async function findGraphForRepo(
133
+ params: FindGraphForRepoParams,
134
+ options: FindGraphForRepoOptions = {},
135
+ ): Promise<FindGraphForRepoResult> {
136
+ const hasId = typeof params?.id === "string" && params.id.length > 0;
137
+ const hasUrl =
138
+ typeof params?.github_url === "string" && params.github_url.length > 0;
139
+
140
+ if (!hasId && !hasUrl) {
141
+ throw new Error(
142
+ "Provide at least one of `id` or `github_url` (e.g. \"owner/repo\" or \"https://github.com/owner/repo\").",
143
+ );
144
+ }
145
+
146
+ let resolvedId: string | undefined;
147
+ if (hasId) {
148
+ if (!ID_PATTERN.test(params.id as string)) {
149
+ throw new Error(
150
+ `\`id\` must match \`owner/repo\` (got "${params.id}").`,
151
+ );
152
+ }
153
+ resolvedId = params.id as string;
154
+ } else if (hasUrl) {
155
+ const parsed = parseGithubUrl(params.github_url as string);
156
+ if (!parsed) {
157
+ throw new Error(
158
+ `Could not extract owner/repo from \`github_url\`: "${params.github_url}".`,
159
+ );
160
+ }
161
+ if (!ID_PATTERN.test(parsed)) {
162
+ throw new Error(
163
+ `Parsed id "${parsed}" does not match \`owner/repo\` pattern.`,
164
+ );
165
+ }
166
+ resolvedId = parsed;
167
+ }
168
+
169
+ const registry = await loadRegistry({
170
+ source: options.source ?? resolveRegistrySource(),
171
+ fetchImpl: options.fetchImpl,
172
+ });
173
+
174
+ const entry = findEntryById(registry, resolvedId as string);
175
+ if (!entry) {
176
+ const suggestions = suggestFuzzy(
177
+ resolvedId as string,
178
+ registry.entries.map((e) => e.id),
179
+ );
180
+ return { found: false, suggestions };
181
+ }
182
+
183
+ const out: FindGraphForRepoFoundResult = {
184
+ found: true,
185
+ id: entry.id,
186
+ format: entry.format,
187
+ graph_url: entry.graph_url,
188
+ };
189
+ if (entry.status !== undefined) out.status = entry.status;
190
+ if (entry.last_synced !== undefined) out.last_synced = entry.last_synced;
191
+ if (entry.last_sha !== undefined) out.last_sha = entry.last_sha;
192
+ if (entry.source_sha !== undefined) out.source_sha = entry.source_sha;
193
+ if (entry.head_sha !== undefined) out.head_sha = entry.head_sha;
194
+ if (typeof entry.commits_behind === "number") {
195
+ out.commits_behind = entry.commits_behind;
196
+ }
197
+ const drift = driftSummary(entry);
198
+ if (drift) out.drift_summary = drift;
199
+ return out;
200
+ }
201
+
202
+ export const findGraphForRepoToolDefinition = {
203
+ name: "find_graph_for_repo",
204
+ description:
205
+ "Look up a registry entry by `id` (\"owner/repo\") or `github_url` (https or ssh). Returns the entry's graph_url and drift metadata, or `{found:false, suggestions}` with up to 5 fuzzy-matched ids.",
206
+ inputSchema: {
207
+ type: "object" as const,
208
+ properties: {
209
+ id: {
210
+ type: "string",
211
+ description: "Registry id (\"owner/repo\"). At least one of `id` or `github_url` is required.",
212
+ },
213
+ github_url: {
214
+ type: "string",
215
+ description:
216
+ "GitHub URL (https or ssh form). Branch/path suffixes and a trailing `.git` are tolerated.",
217
+ },
218
+ },
219
+ additionalProperties: false,
220
+ },
221
+ };