@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/README.md ADDED
@@ -0,0 +1,147 @@
1
+ # @looptech-ai/understand-quickly-mcp
2
+
3
+ A thin [Model Context Protocol](https://modelcontextprotocol.io) server that
4
+ exposes the [understand-quickly](https://looptech-ai.github.io/understand-quickly/)
5
+ registry to any MCP client (Claude Desktop, Codex, Cursor, etc.).
6
+
7
+ > Status: stub-quality. It works end-to-end but is intentionally minimal —
8
+ > no streaming, no embeddings, no auth.
9
+
10
+ ## What it does
11
+
12
+ It wraps the public `registry.json` and exposes four tools:
13
+
14
+ | Tool | Params | Returns |
15
+ | --- | --- | --- |
16
+ | `list_repos` | `{ format?, tag?, status? }` | Array of `{ id, format, description, status, tags, last_synced, graph_url }` |
17
+ | `find_graph_for_repo` | `{ id?, github_url? }` (at least one required) | Single registry entry's graph_url + drift metadata, or `{ found: false, suggestions: [...] }` with up to 5 fuzzy-matched ids |
18
+ | `get_graph` | `{ id }` | Parsed graph JSON for that entry's `graph_url` |
19
+ | `search_concepts` | `{ query, id? }` | Default: aggregated concept matches from the precomputed `stats.json` (single GET, cached 60s). With `id`: substring match across one graph's nodes. Falls back to a capped cross-graph fan-out if `stats.json` is unreachable. |
20
+
21
+ The registry response is cached in-memory for 60 seconds. `stats.json` uses an
22
+ identical 60-second TTL cache.
23
+
24
+ ### `find_graph_for_repo`
25
+
26
+ Accepts either an `id` (the registry id, `owner/repo`) or a `github_url`. The
27
+ URL parser tolerates:
28
+
29
+ - `https://github.com/owner/repo`
30
+ - `https://github.com/owner/repo.git`
31
+ - `https://github.com/owner/repo/` (trailing slash)
32
+ - `https://github.com/owner/repo/tree/main/...` (branch / sub-path)
33
+ - `git@github.com:owner/repo.git`
34
+
35
+ When the entry is found, the response includes `last_synced`, `last_sha`,
36
+ `source_sha`, `head_sha`, `commits_behind`, and a pretty `drift_summary`
37
+ (e.g. `"behind by 17 commits"`) when those fields are present in the registry.
38
+
39
+ If the entry is not found, the response is
40
+ `{ found: false, suggestions: [...] }` with up to 5 fuzzy-matched ids
41
+ (Levenshtein distance ≤ 3 against the lowercased id).
42
+
43
+ ### `search_concepts`
44
+
45
+ By default — that is, when `id` is not provided — `search_concepts` reads the
46
+ precomputed `stats.json` aggregate (a single, cached GET) and returns matching
47
+ concept terms with their entry counts and up to 3 sample registry ids. This
48
+ replaces the previous behaviour, which fanned out up to 5 graph fetches at
49
+ request time.
50
+
51
+ When `id` is provided, it falls back to the legacy single-graph node search
52
+ (substring match against `id` / `label` / `name`). When `stats.json` is
53
+ unavailable (404 or schema mismatch), it falls back to the capped cross-graph
54
+ fan-out for backward compatibility.
55
+
56
+ The `source` field on the response indicates which mode served the request:
57
+ `"stats"`, `"graph"`, or `"fanout"`.
58
+
59
+ ## Install
60
+
61
+ ```bash
62
+ cd mcp
63
+ npm install
64
+ npm run build # compiles TypeScript -> dist/
65
+ npm test # runs node:test across registry/cache and tool logic
66
+ ```
67
+
68
+ Node 20+ is required (uses the global `fetch`).
69
+
70
+ ## Run locally
71
+
72
+ For development:
73
+
74
+ ```bash
75
+ npm run dev
76
+ ```
77
+
78
+ For a built binary:
79
+
80
+ ```bash
81
+ npm start
82
+ ```
83
+
84
+ The server speaks stdio JSON-RPC. It will not respond to keystrokes — point an
85
+ MCP client at it.
86
+
87
+ ## Register with Claude Desktop
88
+
89
+ Add the following to Claude Desktop's `claude_desktop_config.json` (the path is
90
+ `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS):
91
+
92
+ ```json
93
+ {
94
+ "mcpServers": {
95
+ "understand-quickly": {
96
+ "command": "npx",
97
+ "args": [
98
+ "tsx",
99
+ "/absolute/path/to/understand-quickly/mcp/src/index.ts"
100
+ ],
101
+ "env": {
102
+ "UNDERSTAND_QUICKLY_REGISTRY": "https://looptech-ai.github.io/understand-quickly/registry.json"
103
+ }
104
+ }
105
+ }
106
+ }
107
+ ```
108
+
109
+ Replace `/absolute/path/to/...` with the actual path to your checkout. Restart
110
+ Claude Desktop after saving.
111
+
112
+ If you would rather run the compiled output, swap to:
113
+
114
+ ```json
115
+ {
116
+ "mcpServers": {
117
+ "understand-quickly": {
118
+ "command": "node",
119
+ "args": ["/absolute/path/to/understand-quickly/mcp/dist/index.js"]
120
+ }
121
+ }
122
+ }
123
+ ```
124
+
125
+ ## Environment variables
126
+
127
+ | Variable | Default | Purpose |
128
+ | --- | --- | --- |
129
+ | `UNDERSTAND_QUICKLY_REGISTRY` | `https://looptech-ai.github.io/understand-quickly/registry.json` | Override the registry source (e.g. point at a local file or a fork). |
130
+ | `UNDERSTAND_QUICKLY_STATS` | `https://looptech-ai.github.io/understand-quickly/stats.json` | Override the precomputed stats source consumed by `search_concepts`. |
131
+
132
+ ## Current limitations
133
+
134
+ - **In-memory cache only.** Every server process refetches once a minute. No
135
+ cross-process or on-disk cache.
136
+ - **Cross-graph fan-out is only a fallback.** When `search_concepts` falls back
137
+ (no stats.json), it scans only the first 5 `status: ok` entries sequentially.
138
+ - **Substring search is dumb.** No fuzzy matching, no ranking, no embeddings.
139
+ - **No streaming or progress reporting.** Tools block until the upstream
140
+ responds.
141
+ - **Best-effort node enumeration.** The single-graph fallback assumes the graph
142
+ has a `nodes` / `entities` / `concepts` / `items` array; otherwise it walks
143
+ top-level array values.
144
+ - **No retries or backoff** on upstream `graph_url` fetch failures — failed
145
+ fetches return an empty result for that entry instead of erroring out.
146
+
147
+ These are all acceptable for an MVP. If you need more, open an issue.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,134 @@
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
+ import { listRepos } from "./tools/list-repos.js";
6
+ import { getGraph } from "./tools/get-graph.js";
7
+ import { searchConcepts } from "./tools/search-concepts.js";
8
+ import { findGraphForRepo } from "./tools/find-graph-for-repo.js";
9
+ const server = new McpServer({
10
+ name: "understand-quickly-mcp",
11
+ version: "0.1.0",
12
+ }, {
13
+ capabilities: {
14
+ tools: {},
15
+ },
16
+ });
17
+ function jsonContent(value) {
18
+ return {
19
+ content: [
20
+ {
21
+ type: "text",
22
+ text: JSON.stringify(value, null, 2),
23
+ },
24
+ ],
25
+ };
26
+ }
27
+ function errorContent(err) {
28
+ const message = err instanceof Error ? err.message : String(err);
29
+ return {
30
+ isError: true,
31
+ content: [{ type: "text", text: message }],
32
+ };
33
+ }
34
+ server.registerTool("list_repos", {
35
+ description: "List entries in the understand-quickly registry. Optional filters: format, tag, status.",
36
+ inputSchema: {
37
+ format: z
38
+ .string()
39
+ .optional()
40
+ .describe("Exact match on entry.format (e.g. \"understand-anything@1\")."),
41
+ tag: z
42
+ .string()
43
+ .optional()
44
+ .describe("Returns entries whose tags array contains this string."),
45
+ status: z
46
+ .string()
47
+ .optional()
48
+ .describe("Exact match on entry.status (typically \"ok\")."),
49
+ },
50
+ }, async ({ format, tag, status }) => {
51
+ try {
52
+ const repos = await listRepos({ format, tag, status });
53
+ return jsonContent(repos);
54
+ }
55
+ catch (err) {
56
+ return errorContent(err);
57
+ }
58
+ });
59
+ server.registerTool("get_graph", {
60
+ description: "Fetch and return the parsed knowledge graph JSON for a registry entry by id.",
61
+ inputSchema: {
62
+ id: z
63
+ .string()
64
+ .min(1)
65
+ .describe("Registry entry id, e.g. \"Lum1104/Understand-Anything\"."),
66
+ },
67
+ }, async ({ id }) => {
68
+ try {
69
+ const graph = await getGraph({ id });
70
+ return jsonContent(graph);
71
+ }
72
+ catch (err) {
73
+ return errorContent(err);
74
+ }
75
+ });
76
+ server.registerTool("search_concepts", {
77
+ description: "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.",
78
+ inputSchema: {
79
+ query: z.string().min(1).describe("Substring to search for (case-insensitive)."),
80
+ id: z
81
+ .string()
82
+ .optional()
83
+ .describe("Optional entry id. If given, scopes the search to that single graph (legacy node-level mode)."),
84
+ },
85
+ }, async ({ query, id }) => {
86
+ try {
87
+ const result = await searchConcepts({ query, id });
88
+ return jsonContent(result);
89
+ }
90
+ catch (err) {
91
+ return errorContent(err);
92
+ }
93
+ });
94
+ server.registerTool("find_graph_for_repo", {
95
+ description: "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.",
96
+ inputSchema: {
97
+ id: z
98
+ .string()
99
+ .regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/, {
100
+ message: "id must be \"owner/repo\".",
101
+ })
102
+ .optional()
103
+ .describe("Registry id, e.g. \"Lum1104/Understand-Anything\"."),
104
+ github_url: z
105
+ .string()
106
+ .min(1)
107
+ .optional()
108
+ .describe("GitHub URL (https or ssh form). Trailing .git, branches, and sub-paths are tolerated."),
109
+ },
110
+ }, async ({ id, github_url }) => {
111
+ try {
112
+ if (!id && !github_url) {
113
+ return errorContent(new Error("Provide at least one of `id` or `github_url`."));
114
+ }
115
+ const result = await findGraphForRepo({ id, github_url });
116
+ return jsonContent(result);
117
+ }
118
+ catch (err) {
119
+ return errorContent(err);
120
+ }
121
+ });
122
+ async function main() {
123
+ const transport = new StdioServerTransport();
124
+ await server.connect(transport);
125
+ // Log to stderr so we don't pollute the JSON-RPC stream on stdout.
126
+ // eslint-disable-next-line no-console
127
+ console.error("understand-quickly MCP server ready on stdio");
128
+ }
129
+ main().catch((err) => {
130
+ // eslint-disable-next-line no-console
131
+ console.error("Fatal MCP server error:", err);
132
+ process.exit(1);
133
+ });
134
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,53 @@
1
+ import type { FetchImpl, Registry, RegistryEntry, StatsJson } from "./types.js";
2
+ export declare const DEFAULT_REGISTRY_URL = "https://looptech-ai.github.io/understand-quickly/registry.json";
3
+ export declare const DEFAULT_STATS_URL = "https://looptech-ai.github.io/understand-quickly/stats.json";
4
+ export declare const DEFAULT_TTL_MS = 60000;
5
+ export interface LoadRegistryOptions {
6
+ source?: string;
7
+ fetchImpl?: FetchImpl;
8
+ cacheKey?: string;
9
+ ttlMs?: number;
10
+ now?: () => number;
11
+ }
12
+ /**
13
+ * Load `registry.json` with a small in-memory TTL cache.
14
+ *
15
+ * Pure-ish: all I/O and time goes through injected dependencies, so callers in
16
+ * tests can drive the cache with a fake fetch and clock.
17
+ */
18
+ export declare function loadRegistry(options?: LoadRegistryOptions): Promise<Registry>;
19
+ /** Drop the cached registry for a given key (default: the configured source). */
20
+ export declare function clearCache(cacheKey?: string): void;
21
+ export interface LoadStatsOptions {
22
+ source?: string;
23
+ fetchImpl?: FetchImpl;
24
+ cacheKey?: string;
25
+ ttlMs?: number;
26
+ now?: () => number;
27
+ }
28
+ /**
29
+ * Load `stats.json` with the same TTL caching pattern as `loadRegistry`.
30
+ *
31
+ * Validates the minimum shape (schema_version + concepts array). Throws on
32
+ * non-OK HTTP, malformed body, or missing concepts; callers that want a
33
+ * fallback path should catch.
34
+ */
35
+ export declare function loadStats(options?: LoadStatsOptions): Promise<StatsJson>;
36
+ /** Drop the cached stats payload (default: every key). */
37
+ export declare function clearStatsCache(cacheKey?: string): void;
38
+ /**
39
+ * Filter entries with a predicate. Trivial wrapper, but exported so the tool
40
+ * layer composes via a single named function rather than ad-hoc `.filter`s.
41
+ */
42
+ export declare function filterEntries(entries: RegistryEntry[], predicate: (entry: RegistryEntry) => boolean): RegistryEntry[];
43
+ /** Resolve the registry URL from env, falling back to the public default. */
44
+ export declare function resolveRegistrySource(): string;
45
+ /** Resolve the stats URL from env, falling back to the public default. */
46
+ export declare function resolveStatsSource(): string;
47
+ /**
48
+ * Find an entry by id. Exposed for the `get_graph` and `search_concepts` tools.
49
+ */
50
+ export declare function findEntryById(registry: Registry, id: string): RegistryEntry | undefined;
51
+ export declare function assertSafeFetchUrl(rawUrl: string): URL;
52
+ /** Fetch and parse a single graph URL. Used by `get_graph` and `search_concepts`. */
53
+ export declare function fetchGraph(graphUrl: string, fetchImpl?: FetchImpl): Promise<unknown>;
@@ -0,0 +1,191 @@
1
+ import { isIP } from "node:net";
2
+ export const DEFAULT_REGISTRY_URL = "https://looptech-ai.github.io/understand-quickly/registry.json";
3
+ export const DEFAULT_STATS_URL = "https://looptech-ai.github.io/understand-quickly/stats.json";
4
+ export const DEFAULT_TTL_MS = 60_000;
5
+ // Module-level cache keyed by source URL. Each MCP process gets its own cache;
6
+ // that is fine for a stub server because the registry is small.
7
+ const cache = new Map();
8
+ const statsCache = new Map();
9
+ /**
10
+ * Load `registry.json` with a small in-memory TTL cache.
11
+ *
12
+ * Pure-ish: all I/O and time goes through injected dependencies, so callers in
13
+ * tests can drive the cache with a fake fetch and clock.
14
+ */
15
+ export async function loadRegistry(options = {}) {
16
+ const source = options.source ?? DEFAULT_REGISTRY_URL;
17
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
18
+ const cacheKey = options.cacheKey ?? source;
19
+ const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
20
+ const now = options.now ?? Date.now;
21
+ if (!fetchImpl) {
22
+ throw new Error("No fetch implementation available. Pass `fetchImpl` or run on Node 20+.");
23
+ }
24
+ const cached = cache.get(cacheKey);
25
+ if (cached && now() - cached.fetchedAt < ttlMs) {
26
+ return cached.registry;
27
+ }
28
+ const response = await fetchImpl(source);
29
+ if (!response.ok) {
30
+ // 5xx is transient; if we have a stale cache entry, prefer it over a hard
31
+ // throw so an upstream Pages outage doesn't take down every MCP client.
32
+ if (response.status >= 500 && cached)
33
+ return cached.registry;
34
+ throw new Error(`Failed to fetch registry from ${source}: ${response.status} ${response.statusText}`);
35
+ }
36
+ const body = (await response.json());
37
+ if (!body || !Array.isArray(body.entries)) {
38
+ throw new Error(`Registry at ${source} is malformed: missing \`entries\` array`);
39
+ }
40
+ // Guard against silently consuming a future v2 registry with v1-shaped
41
+ // tools. The registry's meta.schema.json pins schema_version to const 1; an
42
+ // older MCP build hitting a newer registry should fail loudly so users know
43
+ // to upgrade rather than getting half-broken responses.
44
+ const sv = body.schema_version;
45
+ if (sv !== undefined && sv !== 1) {
46
+ throw new Error(`Registry at ${source} reports schema_version=${String(sv)}; this MCP build supports schema_version=1. Upgrade @looptech-ai/understand-quickly-mcp.`);
47
+ }
48
+ cache.set(cacheKey, { fetchedAt: now(), registry: body });
49
+ return body;
50
+ }
51
+ /** Drop the cached registry for a given key (default: the configured source). */
52
+ export function clearCache(cacheKey) {
53
+ if (cacheKey === undefined) {
54
+ cache.clear();
55
+ return;
56
+ }
57
+ cache.delete(cacheKey);
58
+ }
59
+ /**
60
+ * Load `stats.json` with the same TTL caching pattern as `loadRegistry`.
61
+ *
62
+ * Validates the minimum shape (schema_version + concepts array). Throws on
63
+ * non-OK HTTP, malformed body, or missing concepts; callers that want a
64
+ * fallback path should catch.
65
+ */
66
+ export async function loadStats(options = {}) {
67
+ const source = options.source ?? DEFAULT_STATS_URL;
68
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
69
+ const cacheKey = options.cacheKey ?? source;
70
+ const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
71
+ const now = options.now ?? Date.now;
72
+ if (!fetchImpl) {
73
+ throw new Error("No fetch implementation available. Pass `fetchImpl` or run on Node 20+.");
74
+ }
75
+ const cached = statsCache.get(cacheKey);
76
+ if (cached && now() - cached.fetchedAt < ttlMs) {
77
+ return cached.stats;
78
+ }
79
+ const response = await fetchImpl(source);
80
+ if (!response.ok) {
81
+ throw new Error(`Failed to fetch stats from ${source}: ${response.status} ${response.statusText}`);
82
+ }
83
+ const body = (await response.json());
84
+ if (!body || !Array.isArray(body.concepts)) {
85
+ throw new Error(`Stats at ${source} is malformed: missing \`concepts\` array`);
86
+ }
87
+ statsCache.set(cacheKey, { fetchedAt: now(), stats: body });
88
+ return body;
89
+ }
90
+ /** Drop the cached stats payload (default: every key). */
91
+ export function clearStatsCache(cacheKey) {
92
+ if (cacheKey === undefined) {
93
+ statsCache.clear();
94
+ return;
95
+ }
96
+ statsCache.delete(cacheKey);
97
+ }
98
+ /**
99
+ * Filter entries with a predicate. Trivial wrapper, but exported so the tool
100
+ * layer composes via a single named function rather than ad-hoc `.filter`s.
101
+ */
102
+ export function filterEntries(entries, predicate) {
103
+ return entries.filter(predicate);
104
+ }
105
+ /** Resolve the registry URL from env, falling back to the public default. */
106
+ export function resolveRegistrySource() {
107
+ return process.env.UNDERSTAND_QUICKLY_REGISTRY ?? DEFAULT_REGISTRY_URL;
108
+ }
109
+ /** Resolve the stats URL from env, falling back to the public default. */
110
+ export function resolveStatsSource() {
111
+ return process.env.UNDERSTAND_QUICKLY_STATS ?? DEFAULT_STATS_URL;
112
+ }
113
+ /**
114
+ * Find an entry by id. Exposed for the `get_graph` and `search_concepts` tools.
115
+ */
116
+ export function findEntryById(registry, id) {
117
+ return registry.entries.find((entry) => entry.id === id);
118
+ }
119
+ // SSRF guard: reject URLs that resolve to private / link-local / loopback /
120
+ // metadata addresses, or that use a non-https scheme. The registry's own
121
+ // schemas pin graph_url to https; this is defence-in-depth for the MCP path,
122
+ // where a malicious registry mirror or a misconfigured URL could otherwise
123
+ // trick this process into fetching cloud-metadata endpoints.
124
+ //
125
+ // Note: this checks the literal hostname, not a resolved IP. Full DNS-rebind
126
+ // protection requires a custom dispatcher; for v0.1 we accept that gap and
127
+ // rely on the surrounding HTTPS-only invariant (TLS makes rebinding harder
128
+ // since the cert must match the literal hostname).
129
+ export function assertSafeFetchUrl(rawUrl) {
130
+ let u;
131
+ try {
132
+ u = new URL(rawUrl);
133
+ }
134
+ catch {
135
+ throw new Error(`Invalid URL: ${rawUrl}`);
136
+ }
137
+ if (u.protocol !== "https:") {
138
+ throw new Error(`Refusing non-https URL: ${rawUrl}`);
139
+ }
140
+ const host = u.hostname.toLowerCase();
141
+ // Block obvious local / metadata targets by literal hostname.
142
+ if (host === "localhost" ||
143
+ host === "0.0.0.0" ||
144
+ host === "127.0.0.1" ||
145
+ host === "::1" ||
146
+ host === "metadata.google.internal" ||
147
+ host === "metadata" ||
148
+ host.endsWith(".internal") ||
149
+ host.endsWith(".local")) {
150
+ throw new Error(`Refusing internal host: ${host}`);
151
+ }
152
+ // Block IPv4 literals in private/link-local/loopback ranges.
153
+ const ipKind = isIP(host);
154
+ if (ipKind === 4) {
155
+ const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
156
+ if (ipv4) {
157
+ const [a, b] = ipv4.slice(1).map(Number);
158
+ if (a === 10 ||
159
+ a === 127 ||
160
+ (a === 169 && b === 254) || // link-local incl. AWS/GCP metadata 169.254.169.254
161
+ (a === 172 && b >= 16 && b <= 31) ||
162
+ (a === 192 && b === 168) ||
163
+ a === 0) {
164
+ throw new Error(`Refusing private IPv4 host: ${host}`);
165
+ }
166
+ }
167
+ }
168
+ else if (ipKind === 6) {
169
+ // URL.hostname strips the brackets from IPv6 literals (e.g. `fc00::1`,
170
+ // not `[fc00::1]`), so a startsWith("[") check would never fire. Match
171
+ // against the normalized address prefix directly.
172
+ // - fc00::/7 (unique-local) → first hex digit 'f' + second 'c' or 'd'
173
+ // - fe80::/10 (link-local) → first hex digit 'f' + second 'e' + third 8|9|a|b
174
+ // - ::1 loopback (caught earlier by literal hostname check)
175
+ // - ::ffff:0:0/96 IPv4-mapped → defer to Node which will resolve via dual-stack
176
+ if (/^f[cd]/i.test(host) || /^fe[89ab]/i.test(host)) {
177
+ throw new Error(`Refusing private IPv6 host: ${host}`);
178
+ }
179
+ }
180
+ return u;
181
+ }
182
+ /** Fetch and parse a single graph URL. Used by `get_graph` and `search_concepts`. */
183
+ export async function fetchGraph(graphUrl, fetchImpl = globalThis.fetch) {
184
+ assertSafeFetchUrl(graphUrl);
185
+ const response = await fetchImpl(graphUrl);
186
+ if (!response.ok) {
187
+ throw new Error(`Failed to fetch graph from ${graphUrl}: ${response.status} ${response.statusText}`);
188
+ }
189
+ return response.json();
190
+ }
191
+ //# sourceMappingURL=registry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registry.js","sourceRoot":"","sources":["../src/registry.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,53 @@
1
+ import type { FetchImpl, FindGraphForRepoParams } from "../types.js";
2
+ /**
3
+ * Parse a github URL into a registry id. Accepts:
4
+ * - https://github.com/owner/repo
5
+ * - https://github.com/owner/repo.git
6
+ * - https://github.com/owner/repo/ (trailing slash)
7
+ * - https://github.com/owner/repo/tree/branch/...
8
+ * - git@github.com:owner/repo.git
9
+ */
10
+ export declare function parseGithubUrl(url: string): string | undefined;
11
+ /** Levenshtein distance, capped early when it exceeds `maxDistance` for speed. */
12
+ export declare function levenshtein(a: string, b: string, maxDistance?: number): number;
13
+ export interface FindGraphForRepoFoundResult {
14
+ found: true;
15
+ id: string;
16
+ format: string;
17
+ graph_url: string;
18
+ status?: string;
19
+ last_synced?: string;
20
+ last_sha?: string;
21
+ source_sha?: string;
22
+ head_sha?: string;
23
+ commits_behind?: number;
24
+ drift_summary?: string;
25
+ }
26
+ export interface FindGraphForRepoNotFoundResult {
27
+ found: false;
28
+ suggestions: string[];
29
+ }
30
+ export type FindGraphForRepoResult = FindGraphForRepoFoundResult | FindGraphForRepoNotFoundResult;
31
+ export interface FindGraphForRepoOptions {
32
+ fetchImpl?: FetchImpl;
33
+ source?: string;
34
+ }
35
+ export declare function findGraphForRepo(params: FindGraphForRepoParams, options?: FindGraphForRepoOptions): Promise<FindGraphForRepoResult>;
36
+ export declare const findGraphForRepoToolDefinition: {
37
+ name: string;
38
+ description: string;
39
+ inputSchema: {
40
+ type: "object";
41
+ properties: {
42
+ id: {
43
+ type: string;
44
+ description: string;
45
+ };
46
+ github_url: {
47
+ type: string;
48
+ description: string;
49
+ };
50
+ };
51
+ additionalProperties: boolean;
52
+ };
53
+ };