awess 0.1.0 → 0.3.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 +1 -2
- package/src/diff.ts +42 -0
- package/src/enricher.ts +190 -8
- package/src/index.ts +308 -207
- package/src/types.ts +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "awess",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "MCP server for searching curated awesome lists and their contents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -34,7 +34,6 @@
|
|
|
34
34
|
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
35
35
|
"mdast-util-from-markdown": "^2.0.2",
|
|
36
36
|
"minisearch": "^7.2.0",
|
|
37
|
-
"octokit": "^5.0.5",
|
|
38
37
|
"zod": "^4.3.5"
|
|
39
38
|
}
|
|
40
39
|
}
|
package/src/diff.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Item, DiffResult } from "./types";
|
|
2
|
+
|
|
3
|
+
export function diffItems(oldItems: Item[], newItems: Item[]): DiffResult {
|
|
4
|
+
const oldByUrl = new Map(oldItems.map(i => [i.url, i]));
|
|
5
|
+
const newByUrl = new Map(newItems.map(i => [i.url, i]));
|
|
6
|
+
|
|
7
|
+
const added: Item[] = [];
|
|
8
|
+
const removed: Item[] = [];
|
|
9
|
+
const unchanged: Item[] = [];
|
|
10
|
+
const updated: Item[] = [];
|
|
11
|
+
|
|
12
|
+
// Check new items against old
|
|
13
|
+
for (const newItem of newItems) {
|
|
14
|
+
const oldItem = oldByUrl.get(newItem.url);
|
|
15
|
+
if (!oldItem) {
|
|
16
|
+
added.push(newItem);
|
|
17
|
+
} else if (oldItem.name === newItem.name && oldItem.description === newItem.description) {
|
|
18
|
+
// Unchanged - preserve enrichment data from old item
|
|
19
|
+
unchanged.push({
|
|
20
|
+
...newItem,
|
|
21
|
+
github: oldItem.github,
|
|
22
|
+
lastEnriched: oldItem.lastEnriched,
|
|
23
|
+
});
|
|
24
|
+
} else {
|
|
25
|
+
// Updated - preserve enrichment data, use new metadata
|
|
26
|
+
updated.push({
|
|
27
|
+
...newItem,
|
|
28
|
+
github: oldItem.github,
|
|
29
|
+
lastEnriched: oldItem.lastEnriched,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Find removed items
|
|
35
|
+
for (const oldItem of oldItems) {
|
|
36
|
+
if (!newByUrl.has(oldItem.url)) {
|
|
37
|
+
removed.push(oldItem);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { added, removed, unchanged, updated };
|
|
42
|
+
}
|
package/src/enricher.ts
CHANGED
|
@@ -1,10 +1,29 @@
|
|
|
1
1
|
// src/enricher.ts
|
|
2
|
-
import { Octokit } from "octokit";
|
|
3
2
|
import type { Item } from "./types";
|
|
4
3
|
|
|
5
4
|
export function extractGitHubRepo(url: string): string | null {
|
|
6
5
|
const match = url.match(/github\.com\/([^\/]+\/[^\/]+)/);
|
|
7
|
-
|
|
6
|
+
if (!match) return null;
|
|
7
|
+
|
|
8
|
+
// Clean up repo name: remove .git, #readme, query params, etc.
|
|
9
|
+
let repo = match[1]
|
|
10
|
+
.replace(/\.git$/, "")
|
|
11
|
+
.replace(/#.*$/, "")
|
|
12
|
+
.replace(/\?.*$/, "");
|
|
13
|
+
|
|
14
|
+
// Skip non-repo paths like "topics/awesome", "sponsors/foo"
|
|
15
|
+
const invalidPrefixes = ["topics", "sponsors", "orgs", "settings", "marketplace"];
|
|
16
|
+
if (invalidPrefixes.some(p => repo.startsWith(p + "/"))) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return repo;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Sleep helper with jitter
|
|
24
|
+
function sleep(ms: number, jitter = 0.2): Promise<void> {
|
|
25
|
+
const jitterMs = ms * jitter * (Math.random() - 0.5) * 2;
|
|
26
|
+
return new Promise(resolve => setTimeout(resolve, ms + jitterMs));
|
|
8
27
|
}
|
|
9
28
|
|
|
10
29
|
export async function batchEnrichItems(items: Item[]): Promise<Item[]> {
|
|
@@ -14,8 +33,6 @@ export async function batchEnrichItems(items: Item[]): Promise<Item[]> {
|
|
|
14
33
|
return items;
|
|
15
34
|
}
|
|
16
35
|
|
|
17
|
-
const octokit = new Octokit({ auth: token });
|
|
18
|
-
|
|
19
36
|
// Extract unique GitHub repos
|
|
20
37
|
const repoMap = new Map<string, Item[]>();
|
|
21
38
|
for (const item of items) {
|
|
@@ -29,13 +46,26 @@ export async function batchEnrichItems(items: Item[]): Promise<Item[]> {
|
|
|
29
46
|
const repos = Array.from(repoMap.keys());
|
|
30
47
|
console.log(`Enriching ${repos.length} unique repos...`);
|
|
31
48
|
|
|
32
|
-
//
|
|
33
|
-
const batchSize =
|
|
49
|
+
// Tuned for GitHub's secondary rate limits
|
|
50
|
+
const batchSize = 50; // Smaller batches = lower query cost
|
|
51
|
+
const baseDelayMs = 500; // Base delay between batches
|
|
52
|
+
let currentDelay = baseDelayMs;
|
|
53
|
+
let consecutiveErrors = 0;
|
|
54
|
+
|
|
34
55
|
for (let i = 0; i < repos.length; i += batchSize) {
|
|
35
56
|
const batch = repos.slice(i, i + batchSize);
|
|
57
|
+
const batchNum = Math.floor(i / batchSize) + 1;
|
|
58
|
+
const totalBatches = Math.ceil(repos.length / batchSize);
|
|
59
|
+
|
|
60
|
+
// Add delay between batches (except first)
|
|
61
|
+
if (i > 0) {
|
|
62
|
+
await sleep(currentDelay);
|
|
63
|
+
}
|
|
36
64
|
|
|
65
|
+
// Include rateLimit in query to monitor usage
|
|
37
66
|
const query = `
|
|
38
67
|
query {
|
|
68
|
+
rateLimit { cost remaining resetAt }
|
|
39
69
|
${batch.map((repo, idx) => {
|
|
40
70
|
const [owner, name] = repo.split("/");
|
|
41
71
|
return `repo${idx}: repository(owner: "${owner}", name: "${name}") {
|
|
@@ -48,8 +78,55 @@ export async function batchEnrichItems(items: Item[]): Promise<Item[]> {
|
|
|
48
78
|
`;
|
|
49
79
|
|
|
50
80
|
try {
|
|
51
|
-
|
|
81
|
+
// Use fetch instead of octokit.graphql to handle partial results
|
|
82
|
+
const response = await fetch("https://api.github.com/graphql", {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: {
|
|
85
|
+
Authorization: `Bearer ${token}`,
|
|
86
|
+
"Content-Type": "application/json",
|
|
87
|
+
},
|
|
88
|
+
body: JSON.stringify({ query }),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
if (!response.ok) {
|
|
92
|
+
throw new Error(`HTTP ${response.status}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const json: any = await response.json();
|
|
96
|
+
|
|
97
|
+
// Check for complete failure (errors but no data)
|
|
98
|
+
if (json.errors && !json.data) {
|
|
99
|
+
throw new Error(json.errors[0]?.message || "GraphQL query failed");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Log any partial errors (repos that don't exist, etc.)
|
|
103
|
+
if (json.errors) {
|
|
104
|
+
const failedRepos = json.errors
|
|
105
|
+
.filter((e: any) => e.path?.[0]?.startsWith("repo"))
|
|
106
|
+
.map((e: any) => batch[parseInt(e.path[0].slice(4))])
|
|
107
|
+
.filter(Boolean);
|
|
108
|
+
if (failedRepos.length > 0) {
|
|
109
|
+
console.log(` [${batchNum}/${totalBatches}] ${failedRepos.length} repos not found`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const result = json.data;
|
|
114
|
+
|
|
115
|
+
// Log rate limit status periodically
|
|
116
|
+
const rl = result.rateLimit;
|
|
117
|
+
if (batchNum % 10 === 0 || rl?.remaining < 100) {
|
|
118
|
+
console.log(` [${batchNum}/${totalBatches}] Rate limit: ${rl?.remaining} remaining, cost: ${rl?.cost}`);
|
|
119
|
+
} else {
|
|
120
|
+
process.stdout.write(` [${batchNum}/${totalBatches}]\r`);
|
|
121
|
+
}
|
|
52
122
|
|
|
123
|
+
// If running low on points, slow down
|
|
124
|
+
if (rl?.remaining < 500) {
|
|
125
|
+
currentDelay = Math.min(currentDelay * 1.5, 5000);
|
|
126
|
+
console.log(` ⚠️ Low rate limit (${rl.remaining}), increasing delay to ${currentDelay}ms`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Process results - now handles partial data correctly
|
|
53
130
|
for (let j = 0; j < batch.length; j++) {
|
|
54
131
|
const data = result[`repo${j}`];
|
|
55
132
|
if (data) {
|
|
@@ -64,10 +141,115 @@ export async function batchEnrichItems(items: Item[]): Promise<Item[]> {
|
|
|
64
141
|
}
|
|
65
142
|
}
|
|
66
143
|
}
|
|
144
|
+
|
|
145
|
+
// Reset on success
|
|
146
|
+
consecutiveErrors = 0;
|
|
147
|
+
currentDelay = Math.max(currentDelay * 0.9, baseDelayMs); // Gradually speed up
|
|
148
|
+
|
|
67
149
|
} catch (error: any) {
|
|
68
|
-
|
|
150
|
+
consecutiveErrors++;
|
|
151
|
+
|
|
152
|
+
// Check for secondary rate limit
|
|
153
|
+
if (error.message?.includes("SecondaryRateLimit") || error.message?.includes("403")) {
|
|
154
|
+
// Exponential backoff with jitter
|
|
155
|
+
const backoffMs = Math.min(baseDelayMs * Math.pow(2, consecutiveErrors), 60000);
|
|
156
|
+
console.log(` ⚠️ Secondary rate limit hit, backing off for ${backoffMs}ms...`);
|
|
157
|
+
await sleep(backoffMs, 0.3);
|
|
158
|
+
currentDelay = backoffMs; // Keep the higher delay
|
|
159
|
+
i -= batchSize; // Retry this batch
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Log other errors but continue
|
|
164
|
+
console.error(` Error batch ${batchNum}: ${error.message?.slice(0, 100)}`);
|
|
165
|
+
|
|
166
|
+
// If too many consecutive errors, slow down
|
|
167
|
+
if (consecutiveErrors >= 3) {
|
|
168
|
+
currentDelay = Math.min(currentDelay * 2, 10000);
|
|
169
|
+
console.log(` Multiple errors, slowing to ${currentDelay}ms`);
|
|
170
|
+
}
|
|
69
171
|
}
|
|
70
172
|
}
|
|
71
173
|
|
|
174
|
+
console.log(""); // Clear the \r line
|
|
72
175
|
return items;
|
|
73
176
|
}
|
|
177
|
+
|
|
178
|
+
export async function batchQueryListRepos(
|
|
179
|
+
repos: string[]
|
|
180
|
+
): Promise<Map<string, { pushedAt: string } | null>> {
|
|
181
|
+
const results = new Map<string, { pushedAt: string } | null>();
|
|
182
|
+
|
|
183
|
+
const token = process.env.GITHUB_TOKEN;
|
|
184
|
+
if (!token) {
|
|
185
|
+
console.warn("No GITHUB_TOKEN - skipping list repo query");
|
|
186
|
+
return results;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const BATCH_SIZE = 50;
|
|
190
|
+
|
|
191
|
+
for (let i = 0; i < repos.length; i += BATCH_SIZE) {
|
|
192
|
+
const batch = repos.slice(i, i + BATCH_SIZE);
|
|
193
|
+
const batchNum = Math.floor(i / BATCH_SIZE) + 1;
|
|
194
|
+
const totalBatches = Math.ceil(repos.length / BATCH_SIZE);
|
|
195
|
+
process.stdout.write(` [${batchNum}/${totalBatches}]\r`);
|
|
196
|
+
|
|
197
|
+
// Build GraphQL query for this batch
|
|
198
|
+
const repoQueries = batch.map((repo, idx) => {
|
|
199
|
+
const [owner, name] = repo.split("/");
|
|
200
|
+
return `repo${idx}: repository(owner: "${owner}", name: "${name}") {
|
|
201
|
+
pushedAt
|
|
202
|
+
}`;
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const query = `query { ${repoQueries.join("\n")} }`;
|
|
206
|
+
|
|
207
|
+
try {
|
|
208
|
+
const response = await fetch("https://api.github.com/graphql", {
|
|
209
|
+
method: "POST",
|
|
210
|
+
headers: {
|
|
211
|
+
Authorization: `Bearer ${token}`,
|
|
212
|
+
"Content-Type": "application/json",
|
|
213
|
+
},
|
|
214
|
+
body: JSON.stringify({ query }),
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
if (!response.ok) {
|
|
218
|
+
console.error(` Error batch ${batchNum}: HTTP ${response.status}`);
|
|
219
|
+
for (const repo of batch) {
|
|
220
|
+
results.set(repo, null);
|
|
221
|
+
}
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const json = await response.json();
|
|
226
|
+
|
|
227
|
+
if (json.errors && !json.data) {
|
|
228
|
+
console.error(` Error batch ${batchNum}:`, json.errors[0]?.message);
|
|
229
|
+
// Mark all repos in batch as null
|
|
230
|
+
for (const repo of batch) {
|
|
231
|
+
results.set(repo, null);
|
|
232
|
+
}
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Extract results
|
|
237
|
+
batch.forEach((repo, idx) => {
|
|
238
|
+
const data = json.data?.[`repo${idx}`];
|
|
239
|
+
if (data?.pushedAt) {
|
|
240
|
+
results.set(repo, { pushedAt: data.pushedAt });
|
|
241
|
+
} else {
|
|
242
|
+
results.set(repo, null);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
} catch (error: any) {
|
|
246
|
+
console.error(` Error batch ${batchNum}:`, error.message);
|
|
247
|
+
for (const repo of batch) {
|
|
248
|
+
results.set(repo, null);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
console.log(); // newline after progress
|
|
254
|
+
return results;
|
|
255
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import MiniSearch from "minisearch";
|
|
5
5
|
import { z } from "zod";
|
|
6
|
-
import type { ItemsIndex } from "./types";
|
|
6
|
+
import type { ItemsIndex, Item } from "./types";
|
|
7
7
|
|
|
8
8
|
interface AwesomeList {
|
|
9
9
|
repo: string;
|
|
@@ -14,14 +14,30 @@ interface AwesomeList {
|
|
|
14
14
|
source: string;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
interface IndexedItem extends Item {
|
|
18
|
+
id: number;
|
|
19
|
+
listRepo: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// CDN URLs - jsDelivr primary (faster, global CDN), GitHub raw fallback
|
|
23
|
+
const JSDELIVR_URL = "https://cdn.jsdelivr.net/gh/arimxyer/ass@main/data";
|
|
24
|
+
const GITHUB_RAW_URL = "https://raw.githubusercontent.com/arimxyer/ass/main/data";
|
|
19
25
|
|
|
20
|
-
// Load data from
|
|
26
|
+
// Load data from CDN, fallback to local for development
|
|
21
27
|
async function loadData<T>(filename: string): Promise<T> {
|
|
22
|
-
// Try
|
|
28
|
+
// Try jsDelivr first (faster global CDN)
|
|
29
|
+
try {
|
|
30
|
+
const res = await fetch(`${JSDELIVR_URL}/${filename}`);
|
|
31
|
+
if (res.ok) {
|
|
32
|
+
return res.json();
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
// jsDelivr failed, try GitHub raw
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Try GitHub raw as fallback
|
|
23
39
|
try {
|
|
24
|
-
const res = await fetch(`${
|
|
40
|
+
const res = await fetch(`${GITHUB_RAW_URL}/${filename}`);
|
|
25
41
|
if (res.ok) {
|
|
26
42
|
return res.json();
|
|
27
43
|
}
|
|
@@ -34,11 +50,44 @@ async function loadData<T>(filename: string): Promise<T> {
|
|
|
34
50
|
return Bun.file(localPath).json();
|
|
35
51
|
}
|
|
36
52
|
|
|
53
|
+
// Load gzipped data from CDN, fallback to local
|
|
54
|
+
async function loadGzippedData<T>(filename: string): Promise<T> {
|
|
55
|
+
// Try jsDelivr first
|
|
56
|
+
try {
|
|
57
|
+
const res = await fetch(`${JSDELIVR_URL}/${filename}`);
|
|
58
|
+
if (res.ok) {
|
|
59
|
+
const compressed = new Uint8Array(await res.arrayBuffer());
|
|
60
|
+
const decompressed = Bun.gunzipSync(compressed);
|
|
61
|
+
return JSON.parse(new TextDecoder().decode(decompressed));
|
|
62
|
+
}
|
|
63
|
+
} catch {
|
|
64
|
+
// jsDelivr failed, try GitHub raw
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Try GitHub raw as fallback
|
|
68
|
+
try {
|
|
69
|
+
const res = await fetch(`${GITHUB_RAW_URL}/${filename}`);
|
|
70
|
+
if (res.ok) {
|
|
71
|
+
const compressed = new Uint8Array(await res.arrayBuffer());
|
|
72
|
+
const decompressed = Bun.gunzipSync(compressed);
|
|
73
|
+
return JSON.parse(new TextDecoder().decode(decompressed));
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
// Remote failed, try local
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Fallback to local file
|
|
80
|
+
const localPath = new URL(`../data/${filename}`, import.meta.url);
|
|
81
|
+
const compressed = new Uint8Array(await Bun.file(localPath).arrayBuffer());
|
|
82
|
+
const decompressed = Bun.gunzipSync(compressed);
|
|
83
|
+
return JSON.parse(new TextDecoder().decode(decompressed));
|
|
84
|
+
}
|
|
85
|
+
|
|
37
86
|
// Load curated data
|
|
38
87
|
const lists: AwesomeList[] = await loadData("lists.json");
|
|
39
88
|
|
|
40
|
-
// Initialize search index
|
|
41
|
-
const
|
|
89
|
+
// Initialize list search index
|
|
90
|
+
const listSearch = new MiniSearch<AwesomeList>({
|
|
42
91
|
fields: ["name", "repo", "description"],
|
|
43
92
|
storeFields: ["repo", "name", "stars", "description", "pushed_at", "source"],
|
|
44
93
|
searchOptions: {
|
|
@@ -48,235 +97,216 @@ const search = new MiniSearch<AwesomeList>({
|
|
|
48
97
|
},
|
|
49
98
|
});
|
|
50
99
|
|
|
51
|
-
// Index all lists
|
|
52
|
-
|
|
100
|
+
// Index all lists
|
|
101
|
+
listSearch.addAll(lists.map((list, i) => ({ id: i, ...list })));
|
|
53
102
|
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
});
|
|
103
|
+
// Load items index
|
|
104
|
+
let itemsIndex: ItemsIndex | null = null;
|
|
105
|
+
let allItems: IndexedItem[] = [];
|
|
106
|
+
let itemSearch: MiniSearch<IndexedItem> | null = null;
|
|
59
107
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
"search",
|
|
63
|
-
"Search curated awesome lists by keyword. Returns matching lists sorted by relevance and stars.",
|
|
64
|
-
{
|
|
65
|
-
query: z.string().describe("Search query (e.g., 'rust', 'machine learning', 'react')"),
|
|
66
|
-
limit: z.number().optional().describe("Maximum results to return (default: 10)"),
|
|
67
|
-
minStars: z.number().optional().describe("Minimum star count filter (default: 0)"),
|
|
68
|
-
},
|
|
69
|
-
async ({ query, limit = 10, minStars = 0 }) => {
|
|
70
|
-
const results = search
|
|
71
|
-
.search(query)
|
|
72
|
-
.filter((r) => r.stars >= minStars)
|
|
73
|
-
.slice(0, limit)
|
|
74
|
-
.map((r) => ({
|
|
75
|
-
repo: r.repo,
|
|
76
|
-
name: r.name,
|
|
77
|
-
stars: r.stars,
|
|
78
|
-
description: r.description,
|
|
79
|
-
lastUpdated: r.pushed_at,
|
|
80
|
-
score: r.score,
|
|
81
|
-
}));
|
|
108
|
+
try {
|
|
109
|
+
itemsIndex = await loadGzippedData<ItemsIndex>("items.json.gz");
|
|
82
110
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
],
|
|
90
|
-
};
|
|
111
|
+
// Build flat list of all items for global search
|
|
112
|
+
let itemId = 0;
|
|
113
|
+
for (const [listRepo, entry] of Object.entries(itemsIndex.lists)) {
|
|
114
|
+
for (const item of entry.items) {
|
|
115
|
+
allItems.push({ ...item, id: itemId++, listRepo });
|
|
116
|
+
}
|
|
91
117
|
}
|
|
92
|
-
);
|
|
93
118
|
|
|
94
|
-
//
|
|
119
|
+
// Initialize item search index
|
|
120
|
+
itemSearch = new MiniSearch<IndexedItem>({
|
|
121
|
+
fields: ["name", "description", "category"],
|
|
122
|
+
storeFields: ["name", "url", "description", "category", "subcategory", "github", "listRepo"],
|
|
123
|
+
searchOptions: {
|
|
124
|
+
boost: { name: 2, category: 1.5, description: 1 },
|
|
125
|
+
fuzzy: 0.2,
|
|
126
|
+
prefix: true,
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
itemSearch.addAll(allItems);
|
|
131
|
+
|
|
132
|
+
console.error(`Loaded ${itemsIndex.itemCount} items from ${itemsIndex.listCount} lists`);
|
|
133
|
+
} catch {
|
|
134
|
+
console.error("No items.json.gz found - item search will be unavailable");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Create MCP server
|
|
138
|
+
const server = new McpServer({
|
|
139
|
+
name: "awess",
|
|
140
|
+
version: "0.3.0",
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Tool: Search awesome lists (unified search, top_lists, get_list)
|
|
95
144
|
server.tool(
|
|
96
|
-
"
|
|
97
|
-
"
|
|
145
|
+
"search_lists",
|
|
146
|
+
"Search curated awesome lists. Returns lists sorted by relevance (if query provided) or stars.",
|
|
98
147
|
{
|
|
99
|
-
|
|
148
|
+
query: z.string().optional().describe("Search query (e.g., 'rust', 'machine learning')"),
|
|
149
|
+
repo: z.string().optional().describe("Exact repo lookup (e.g., 'sindresorhus/awesome')"),
|
|
150
|
+
sortBy: z.enum(["relevance", "stars", "updated"]).optional().describe("Sort order (default: 'stars', or 'relevance' if query provided)"),
|
|
151
|
+
minStars: z.number().optional().describe("Minimum star count filter"),
|
|
152
|
+
limit: z.number().optional().describe("Maximum results (default: 20)"),
|
|
100
153
|
},
|
|
101
|
-
async ({ repo }) => {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
154
|
+
async ({ query, repo, sortBy, minStars = 0, limit = 20 }) => {
|
|
155
|
+
// Exact repo lookup
|
|
156
|
+
if (repo) {
|
|
157
|
+
const list = lists.find(l => l.repo.toLowerCase() === repo.toLowerCase());
|
|
158
|
+
if (!list) {
|
|
159
|
+
return {
|
|
160
|
+
content: [{ type: "text", text: `List not found: ${repo}` }],
|
|
161
|
+
};
|
|
162
|
+
}
|
|
107
163
|
return {
|
|
108
|
-
content: [
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
164
|
+
content: [{
|
|
165
|
+
type: "text",
|
|
166
|
+
text: JSON.stringify({
|
|
167
|
+
repo: list.repo,
|
|
168
|
+
name: list.name,
|
|
169
|
+
stars: list.stars,
|
|
170
|
+
description: list.description,
|
|
171
|
+
lastUpdated: list.pushed_at,
|
|
172
|
+
source: list.source,
|
|
173
|
+
githubUrl: `https://github.com/${list.repo}`,
|
|
174
|
+
}, null, 2),
|
|
175
|
+
}],
|
|
114
176
|
};
|
|
115
177
|
}
|
|
116
178
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
{
|
|
120
|
-
type: "text",
|
|
121
|
-
text: JSON.stringify(
|
|
122
|
-
{
|
|
123
|
-
repo: list.repo,
|
|
124
|
-
name: list.name,
|
|
125
|
-
stars: list.stars,
|
|
126
|
-
description: list.description,
|
|
127
|
-
lastUpdated: list.pushed_at,
|
|
128
|
-
source: list.source,
|
|
129
|
-
githubUrl: `https://github.com/${list.repo}`,
|
|
130
|
-
},
|
|
131
|
-
null,
|
|
132
|
-
2
|
|
133
|
-
),
|
|
134
|
-
},
|
|
135
|
-
],
|
|
136
|
-
};
|
|
137
|
-
}
|
|
138
|
-
);
|
|
179
|
+
// Determine sort order
|
|
180
|
+
const effectiveSortBy = sortBy || (query ? "relevance" : "stars");
|
|
139
181
|
|
|
140
|
-
|
|
141
|
-
server.tool(
|
|
142
|
-
"top_lists",
|
|
143
|
-
"Get top awesome lists by star count",
|
|
144
|
-
{
|
|
145
|
-
limit: z.number().optional().describe("Number of lists to return (default: 20)"),
|
|
146
|
-
category: z.string().optional().describe("Optional keyword to filter by (e.g., 'python', 'web')"),
|
|
147
|
-
},
|
|
148
|
-
async ({ limit = 20, category }) => {
|
|
149
|
-
let filtered = lists;
|
|
150
|
-
|
|
151
|
-
if (category) {
|
|
152
|
-
const categoryResults = search.search(category);
|
|
153
|
-
filtered = categoryResults.map((r) => ({
|
|
154
|
-
repo: r.repo,
|
|
155
|
-
name: r.name,
|
|
156
|
-
stars: r.stars,
|
|
157
|
-
description: r.description,
|
|
158
|
-
pushed_at: r.pushed_at,
|
|
159
|
-
source: r.source,
|
|
160
|
-
}));
|
|
161
|
-
}
|
|
182
|
+
let results: any[];
|
|
162
183
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
184
|
+
if (query && effectiveSortBy === "relevance") {
|
|
185
|
+
// Search with relevance sorting
|
|
186
|
+
results = listSearch
|
|
187
|
+
.search(query)
|
|
188
|
+
.filter(r => r.stars >= minStars)
|
|
189
|
+
.slice(0, limit)
|
|
190
|
+
.map(r => ({
|
|
191
|
+
repo: r.repo,
|
|
192
|
+
name: r.name,
|
|
193
|
+
stars: r.stars,
|
|
194
|
+
description: r.description,
|
|
195
|
+
lastUpdated: r.pushed_at,
|
|
196
|
+
}));
|
|
197
|
+
} else {
|
|
198
|
+
// Filter and sort manually
|
|
199
|
+
let filtered = lists.filter(l => l.stars >= minStars);
|
|
200
|
+
|
|
201
|
+
if (query) {
|
|
202
|
+
const searchResults = new Set(listSearch.search(query).map(r => r.repo));
|
|
203
|
+
filtered = filtered.filter(l => searchResults.has(l.repo));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Sort
|
|
207
|
+
if (effectiveSortBy === "updated") {
|
|
208
|
+
filtered.sort((a, b) => (b.pushed_at || "").localeCompare(a.pushed_at || ""));
|
|
209
|
+
} else {
|
|
210
|
+
filtered.sort((a, b) => b.stars - a.stars);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
results = filtered.slice(0, limit).map(l => ({
|
|
167
214
|
repo: l.repo,
|
|
168
215
|
name: l.name,
|
|
169
216
|
stars: l.stars,
|
|
170
|
-
description: l.description
|
|
217
|
+
description: l.description,
|
|
218
|
+
lastUpdated: l.pushed_at,
|
|
171
219
|
}));
|
|
220
|
+
}
|
|
172
221
|
|
|
173
222
|
return {
|
|
174
|
-
content: [
|
|
175
|
-
{
|
|
176
|
-
type: "text",
|
|
177
|
-
text: JSON.stringify(top, null, 2),
|
|
178
|
-
},
|
|
179
|
-
],
|
|
180
|
-
};
|
|
181
|
-
}
|
|
182
|
-
);
|
|
183
|
-
|
|
184
|
-
// Tool: Stats
|
|
185
|
-
server.tool(
|
|
186
|
-
"stats",
|
|
187
|
-
"Get statistics about the curated awesome lists collection",
|
|
188
|
-
{},
|
|
189
|
-
async () => {
|
|
190
|
-
const totalLists = lists.length;
|
|
191
|
-
const totalStars = lists.reduce((sum, l) => sum + l.stars, 0);
|
|
192
|
-
const avgStars = Math.round(totalStars / totalLists);
|
|
193
|
-
|
|
194
|
-
const starBrackets = {
|
|
195
|
-
"10000+": lists.filter((l) => l.stars >= 10000).length,
|
|
196
|
-
"5000-9999": lists.filter((l) => l.stars >= 5000 && l.stars < 10000)
|
|
197
|
-
.length,
|
|
198
|
-
"1000-4999": lists.filter((l) => l.stars >= 1000 && l.stars < 5000)
|
|
199
|
-
.length,
|
|
200
|
-
"500-999": lists.filter((l) => l.stars >= 500 && l.stars < 1000).length,
|
|
201
|
-
"<500": lists.filter((l) => l.stars < 500).length,
|
|
202
|
-
};
|
|
203
|
-
|
|
204
|
-
return {
|
|
205
|
-
content: [
|
|
206
|
-
{
|
|
207
|
-
type: "text",
|
|
208
|
-
text: JSON.stringify(
|
|
209
|
-
{
|
|
210
|
-
totalLists,
|
|
211
|
-
totalStars,
|
|
212
|
-
avgStars,
|
|
213
|
-
starDistribution: starBrackets,
|
|
214
|
-
},
|
|
215
|
-
null,
|
|
216
|
-
2
|
|
217
|
-
),
|
|
218
|
-
},
|
|
219
|
-
],
|
|
223
|
+
content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
|
|
220
224
|
};
|
|
221
225
|
}
|
|
222
226
|
);
|
|
223
227
|
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
try {
|
|
228
|
-
itemsIndex = await loadData<ItemsIndex>("items.json");
|
|
229
|
-
console.error(`Loaded ${itemsIndex?.itemCount} items from ${itemsIndex?.listCount} lists`);
|
|
230
|
-
} catch {
|
|
231
|
-
console.error("No items.json found - get_items will be unavailable");
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// Tool: Get items from a list
|
|
235
|
-
if (itemsIndex) {
|
|
228
|
+
// Tool: Search items (global search with filters)
|
|
229
|
+
if (itemsIndex && itemSearch) {
|
|
236
230
|
server.tool(
|
|
237
|
-
"
|
|
238
|
-
"
|
|
231
|
+
"search_items",
|
|
232
|
+
"Search tools, libraries, and resources across all awesome lists. Supports global search or filtering within a specific list.",
|
|
239
233
|
{
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
234
|
+
query: z.string().optional().describe("Search item names/descriptions"),
|
|
235
|
+
repo: z.string().optional().describe("Limit to a specific list (e.g., 'vinta/awesome-python')"),
|
|
236
|
+
category: z.string().optional().describe("Filter by category"),
|
|
237
|
+
language: z.string().optional().describe("Filter by programming language"),
|
|
238
|
+
minStars: z.number().optional().describe("Minimum GitHub stars"),
|
|
239
|
+
sortBy: z.enum(["relevance", "stars", "updated"]).optional().describe("Sort order (default: 'stars', or 'relevance' if query provided)"),
|
|
240
|
+
limit: z.number().optional().describe("Maximum results (default: 50)"),
|
|
243
241
|
},
|
|
244
|
-
async ({ repo, category, limit = 50 }) => {
|
|
245
|
-
const
|
|
242
|
+
async ({ query, repo, category, language, minStars = 0, sortBy, limit = 50 }) => {
|
|
243
|
+
const effectiveSortBy = sortBy || (query ? "relevance" : "stars");
|
|
246
244
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
245
|
+
let results: IndexedItem[];
|
|
246
|
+
|
|
247
|
+
if (query && effectiveSortBy === "relevance") {
|
|
248
|
+
// Search with relevance
|
|
249
|
+
results = itemSearch!.search(query).map(r => ({
|
|
250
|
+
id: r.id,
|
|
251
|
+
name: r.name,
|
|
252
|
+
url: r.url,
|
|
253
|
+
description: r.description,
|
|
254
|
+
category: r.category,
|
|
255
|
+
subcategory: r.subcategory,
|
|
256
|
+
github: r.github,
|
|
257
|
+
listRepo: r.listRepo,
|
|
258
|
+
})) as IndexedItem[];
|
|
259
|
+
} else {
|
|
260
|
+
// Start with all items or query results
|
|
261
|
+
if (query) {
|
|
262
|
+
const searchResultIds = new Set(itemSearch!.search(query).map(r => r.id));
|
|
263
|
+
results = allItems.filter(i => searchResultIds.has(i.id));
|
|
264
|
+
} else {
|
|
265
|
+
results = [...allItems];
|
|
266
|
+
}
|
|
256
267
|
}
|
|
257
268
|
|
|
258
|
-
|
|
269
|
+
// Apply filters
|
|
270
|
+
if (repo) {
|
|
271
|
+
const repoLower = repo.toLowerCase();
|
|
272
|
+
results = results.filter(i => i.listRepo.toLowerCase() === repoLower);
|
|
273
|
+
}
|
|
259
274
|
|
|
260
|
-
// Filter by category if provided
|
|
261
275
|
if (category) {
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
(
|
|
265
|
-
|
|
266
|
-
i.subcategory?.toLowerCase().includes(categoryLower)
|
|
276
|
+
const catLower = category.toLowerCase();
|
|
277
|
+
results = results.filter(i =>
|
|
278
|
+
i.category?.toLowerCase().includes(catLower) ||
|
|
279
|
+
i.subcategory?.toLowerCase().includes(catLower)
|
|
267
280
|
);
|
|
268
281
|
}
|
|
269
282
|
|
|
270
|
-
|
|
271
|
-
|
|
283
|
+
if (language) {
|
|
284
|
+
const langLower = language.toLowerCase();
|
|
285
|
+
results = results.filter(i =>
|
|
286
|
+
i.github?.language?.toLowerCase() === langLower
|
|
287
|
+
);
|
|
288
|
+
}
|
|
272
289
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
290
|
+
if (minStars > 0) {
|
|
291
|
+
results = results.filter(i => (i.github?.stars || 0) >= minStars);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Sort (if not already sorted by relevance)
|
|
295
|
+
if (effectiveSortBy === "stars") {
|
|
296
|
+
results.sort((a, b) => (b.github?.stars || 0) - (a.github?.stars || 0));
|
|
297
|
+
} else if (effectiveSortBy === "updated") {
|
|
298
|
+
results.sort((a, b) =>
|
|
299
|
+
(b.github?.pushedAt || "").localeCompare(a.github?.pushedAt || "")
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Apply limit and format
|
|
304
|
+
const limited = results.slice(0, limit);
|
|
305
|
+
|
|
306
|
+
const output = {
|
|
307
|
+
totalMatches: results.length,
|
|
308
|
+
returned: limited.length,
|
|
309
|
+
items: limited.map(i => ({
|
|
280
310
|
name: i.name,
|
|
281
311
|
url: i.url,
|
|
282
312
|
description: i.description,
|
|
@@ -284,21 +314,92 @@ if (itemsIndex) {
|
|
|
284
314
|
subcategory: i.subcategory,
|
|
285
315
|
stars: i.github?.stars,
|
|
286
316
|
language: i.github?.language,
|
|
317
|
+
lastUpdated: i.github?.pushedAt,
|
|
318
|
+
list: i.listRepo,
|
|
287
319
|
})),
|
|
288
320
|
};
|
|
289
321
|
|
|
290
322
|
return {
|
|
291
|
-
content: [
|
|
292
|
-
{
|
|
293
|
-
type: "text",
|
|
294
|
-
text: JSON.stringify(result, null, 2),
|
|
295
|
-
},
|
|
296
|
-
],
|
|
323
|
+
content: [{ type: "text", text: JSON.stringify(output, null, 2) }],
|
|
297
324
|
};
|
|
298
325
|
}
|
|
299
326
|
);
|
|
300
327
|
}
|
|
301
328
|
|
|
329
|
+
// Tool: Stats (enhanced with item statistics)
|
|
330
|
+
server.tool(
|
|
331
|
+
"stats",
|
|
332
|
+
"Get statistics about the curated awesome lists collection and items",
|
|
333
|
+
{},
|
|
334
|
+
async () => {
|
|
335
|
+
const totalLists = lists.length;
|
|
336
|
+
const totalStars = lists.reduce((sum, l) => sum + l.stars, 0);
|
|
337
|
+
const avgStars = Math.round(totalStars / totalLists);
|
|
338
|
+
|
|
339
|
+
const listStarBrackets = {
|
|
340
|
+
"10000+": lists.filter(l => l.stars >= 10000).length,
|
|
341
|
+
"5000-9999": lists.filter(l => l.stars >= 5000 && l.stars < 10000).length,
|
|
342
|
+
"1000-4999": lists.filter(l => l.stars >= 1000 && l.stars < 5000).length,
|
|
343
|
+
"500-999": lists.filter(l => l.stars >= 500 && l.stars < 1000).length,
|
|
344
|
+
"<500": lists.filter(l => l.stars < 500).length,
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
// Item statistics
|
|
348
|
+
let itemStats: any = null;
|
|
349
|
+
if (itemsIndex && allItems.length > 0) {
|
|
350
|
+
const enrichedItems = allItems.filter(i => i.github);
|
|
351
|
+
|
|
352
|
+
// Count languages
|
|
353
|
+
const languageCounts = new Map<string, number>();
|
|
354
|
+
for (const item of enrichedItems) {
|
|
355
|
+
const lang = item.github?.language;
|
|
356
|
+
if (lang) {
|
|
357
|
+
languageCounts.set(lang, (languageCounts.get(lang) || 0) + 1);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
const topLanguages = [...languageCounts.entries()]
|
|
361
|
+
.sort((a, b) => b[1] - a[1])
|
|
362
|
+
.slice(0, 10)
|
|
363
|
+
.reduce((acc, [lang, count]) => ({ ...acc, [lang]: count }), {});
|
|
364
|
+
|
|
365
|
+
// Count categories
|
|
366
|
+
const categoryCounts = new Map<string, number>();
|
|
367
|
+
for (const item of allItems) {
|
|
368
|
+
if (item.category) {
|
|
369
|
+
categoryCounts.set(item.category, (categoryCounts.get(item.category) || 0) + 1);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
const topCategories = [...categoryCounts.entries()]
|
|
373
|
+
.sort((a, b) => b[1] - a[1])
|
|
374
|
+
.slice(0, 10)
|
|
375
|
+
.reduce((acc, [cat, count]) => ({ ...acc, [cat]: count }), {});
|
|
376
|
+
|
|
377
|
+
itemStats = {
|
|
378
|
+
totalItems: allItems.length,
|
|
379
|
+
enrichedItems: enrichedItems.length,
|
|
380
|
+
listsWithItems: itemsIndex.listCount,
|
|
381
|
+
topLanguages,
|
|
382
|
+
topCategories,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
content: [{
|
|
388
|
+
type: "text",
|
|
389
|
+
text: JSON.stringify({
|
|
390
|
+
lists: {
|
|
391
|
+
total: totalLists,
|
|
392
|
+
totalStars,
|
|
393
|
+
avgStars,
|
|
394
|
+
starDistribution: listStarBrackets,
|
|
395
|
+
},
|
|
396
|
+
items: itemStats,
|
|
397
|
+
}, null, 2),
|
|
398
|
+
}],
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
);
|
|
402
|
+
|
|
302
403
|
// Start server
|
|
303
404
|
const transport = new StdioServerTransport();
|
|
304
405
|
await server.connect(transport);
|