@pi-unipi/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.
@@ -0,0 +1,114 @@
1
+ /**
2
+ * @pi-unipi/mcp — Config schema defaults and validation
3
+ */
4
+
5
+ import type { McpConfig, McpMetadata, SyncConfig } from "../types.js";
6
+
7
+ /** Default empty MCP config */
8
+ export const DEFAULT_MCP_CONFIG: McpConfig = {
9
+ mcpServers: {},
10
+ };
11
+
12
+ /** Default sync configuration */
13
+ export const DEFAULT_SYNC_CONFIG: SyncConfig = {
14
+ enabled: true,
15
+ lastSyncAt: null,
16
+ syncIntervalMs: 86400000, // 24 hours
17
+ };
18
+
19
+ /** Default metadata config */
20
+ export const DEFAULT_METADATA: McpMetadata = {
21
+ servers: {},
22
+ sync: DEFAULT_SYNC_CONFIG,
23
+ };
24
+
25
+ /** Validation result */
26
+ export interface ValidationResult {
27
+ valid: boolean;
28
+ errors: string[];
29
+ }
30
+
31
+ /**
32
+ * Validate an MCP config structure.
33
+ * Checks that each server has required fields (command, args).
34
+ */
35
+ export function validateMcpConfig(config: unknown): ValidationResult {
36
+ const errors: string[] = [];
37
+
38
+ if (!config || typeof config !== "object") {
39
+ return { valid: false, errors: ["Config must be a non-null object"] };
40
+ }
41
+
42
+ const obj = config as Record<string, unknown>;
43
+
44
+ if (!obj.mcpServers || typeof obj.mcpServers !== "object") {
45
+ return { valid: false, errors: ["Missing or invalid 'mcpServers' field"] };
46
+ }
47
+
48
+ const servers = obj.mcpServers as Record<string, unknown>;
49
+
50
+ for (const [name, server] of Object.entries(servers)) {
51
+ if (!server || typeof server !== "object") {
52
+ errors.push(`Server '${name}': must be a non-null object`);
53
+ continue;
54
+ }
55
+
56
+ const s = server as Record<string, unknown>;
57
+
58
+ if (typeof s.command !== "string" || s.command.trim() === "") {
59
+ errors.push(`Server '${name}': 'command' must be a non-empty string`);
60
+ }
61
+
62
+ if (!Array.isArray(s.args)) {
63
+ errors.push(`Server '${name}': 'args' must be an array`);
64
+ } else {
65
+ for (let i = 0; i < s.args.length; i++) {
66
+ if (typeof s.args[i] !== "string") {
67
+ errors.push(`Server '${name}': args[${i}] must be a string`);
68
+ }
69
+ }
70
+ }
71
+
72
+ if (s.env !== undefined) {
73
+ if (typeof s.env !== "object" || s.env === null) {
74
+ errors.push(`Server '${name}': 'env' must be an object if present`);
75
+ } else {
76
+ const env = s.env as Record<string, unknown>;
77
+ for (const [key, val] of Object.entries(env)) {
78
+ if (typeof val !== "string") {
79
+ errors.push(`Server '${name}': env.${key} must be a string`);
80
+ }
81
+ }
82
+ }
83
+ }
84
+ }
85
+
86
+ return { valid: errors.length === 0, errors };
87
+ }
88
+
89
+ /**
90
+ * Create a minimal server definition template for a new server.
91
+ */
92
+ export function createServerTemplate(
93
+ name: string,
94
+ command: string,
95
+ args: string[],
96
+ envVars?: string[],
97
+ ): McpConfig {
98
+ const env: Record<string, string> = {};
99
+ if (envVars) {
100
+ for (const v of envVars) {
101
+ env[v] = "";
102
+ }
103
+ }
104
+
105
+ return {
106
+ mcpServers: {
107
+ [name]: {
108
+ command,
109
+ args,
110
+ env: Object.keys(env).length > 0 ? env : undefined,
111
+ },
112
+ },
113
+ };
114
+ }
@@ -0,0 +1,416 @@
1
+ /**
2
+ * @pi-unipi/mcp — Catalog sync
3
+ *
4
+ * Fetches MCP server catalog from GitHub's punkpeye/awesome-mcp-servers README,
5
+ * parses markdown into structured entries, caches to servers.json.
6
+ * Falls back to bundled seed-servers.json.
7
+ */
8
+
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+ import * as os from "node:os";
12
+ import type { CatalogEntry, CatalogData } from "../types.js";
13
+ import { loadMetadata, saveMetadata, getGlobalConfigDir } from "./manager.js";
14
+
15
+ /** GitHub raw URL for awesome-mcp-servers README */
16
+ const GITHUB_RAW_URL =
17
+ "https://raw.githubusercontent.com/punkpeye/awesome-mcp-servers/main/README.md";
18
+
19
+ /** Path to seed data bundled with the package */
20
+ function getSeedPath(): string {
21
+ // Resolve relative to this file's location
22
+ return path.join(import.meta.dirname ?? __dirname, "..", "..", "data", "seed-servers.json");
23
+ }
24
+
25
+ /** Path to cached catalog */
26
+ function getCatalogPath(): string {
27
+ return path.join(getGlobalConfigDir(), "servers.json");
28
+ }
29
+
30
+ /**
31
+ * Fetch the awesome-mcp-servers README from GitHub.
32
+ */
33
+ async function fetchCatalogFromGitHub(): Promise<string> {
34
+ const response = await fetch(GITHUB_RAW_URL, {
35
+ headers: { "User-Agent": "@pi-unipi/mcp" },
36
+ });
37
+
38
+ if (!response.ok) {
39
+ throw new Error(
40
+ `Failed to fetch catalog: HTTP ${response.status} ${response.statusText}`,
41
+ );
42
+ }
43
+
44
+ return response.text();
45
+ }
46
+
47
+ /**
48
+ * Parse markdown from awesome-mcp-servers into structured catalog entries.
49
+ *
50
+ * The README has sections like:
51
+ * ```
52
+ * ## Category Name
53
+ * - [Server Name](github-url) - Description
54
+ * - [Server Name](github-url) ⭐ - Description
55
+ * ```
56
+ */
57
+ function parseMarkdownServers(markdown: string): CatalogEntry[] {
58
+ const entries: CatalogEntry[] = [];
59
+ const lines = markdown.split("\n");
60
+ let currentCategory = "uncategorized";
61
+ const seen = new Set<string>();
62
+
63
+ for (const line of lines) {
64
+ // Track current category (## headers)
65
+ const categoryMatch = line.match(/^##\s+(.+)/);
66
+ if (categoryMatch) {
67
+ currentCategory = categoryMatch[1].trim().toLowerCase();
68
+ continue;
69
+ }
70
+
71
+ // Parse server entries: - [Name](url) - Description or - [Name](url) ⭐ - Description
72
+ const serverMatch = line.match(
73
+ /^-\s+\[([^\]]+)\]\(([^)]+)\)\s*(⭐)?\s*[-–—]?\s*(.*)/,
74
+ );
75
+ if (!serverMatch) continue;
76
+
77
+ const [, name, githubUrl, officialStar, rawDesc] = serverMatch;
78
+
79
+ // Only include entries that link to GitHub
80
+ if (!githubUrl.includes("github.com")) continue;
81
+
82
+ // Deduplicate by URL
83
+ if (seen.has(githubUrl)) continue;
84
+ seen.add(githubUrl);
85
+
86
+ // Extract repo path for ID
87
+ const repoMatch = githubUrl.match(/github\.com\/([^/]+\/[^/]+)/);
88
+ const id = repoMatch ? repoMatch[1] : githubUrl;
89
+
90
+ // Clean description
91
+ const description = rawDesc
92
+ .replace(/\s*\(.*?\)\s*/g, "") // Remove parenthetical
93
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") // Extract link text
94
+ .trim()
95
+ .slice(0, 200);
96
+
97
+ // Detect scope from description/name
98
+ const isLocal =
99
+ /\b(local|filesystem|sqlite|postgres|docker)\b/i.test(
100
+ name + " " + description,
101
+ );
102
+
103
+ entries.push({
104
+ id,
105
+ name,
106
+ description: description || `MCP server: ${name}`,
107
+ github: githubUrl,
108
+ categories: [currentCategory],
109
+ language: guessLanguage(name, description),
110
+ scope: isLocal ? "local" : "cloud",
111
+ official: !!officialStar,
112
+ });
113
+ }
114
+
115
+ return entries;
116
+ }
117
+
118
+ /**
119
+ * Guess the primary language from name/description heuristics.
120
+ */
121
+ function guessLanguage(name: string, desc: string): string {
122
+ const text = `${name} ${desc}`.toLowerCase();
123
+ if (/\bpython\b|\buvx\b|\bpip\b/.test(text)) return "python";
124
+ if (/\bgo\b|\bgolang\b/.test(text)) return "go";
125
+ if (/\brust\b|\bcargo\b/.test(text)) return "rust";
126
+ if (/\bdocker\b|\bcontainer\b/.test(text)) return "docker";
127
+ return "typescript"; // Most MCP servers are TypeScript/Node
128
+ }
129
+
130
+ /**
131
+ * Known install patterns for popular MCP servers.
132
+ * Maps server name patterns to install configs.
133
+ */
134
+ const KNOWN_INSTALLS: Record<
135
+ string,
136
+ { command: string; args: string[]; envVars?: string[] }
137
+ > = {
138
+ github: {
139
+ command: "docker",
140
+ args: [
141
+ "run", "-i", "--rm",
142
+ "-e", "GITHUB_PERSONAL_ACCESS_TOKEN",
143
+ "ghcr.io/github/github-mcp-server",
144
+ ],
145
+ envVars: ["GITHUB_PERSONAL_ACCESS_TOKEN"],
146
+ },
147
+ playwright: {
148
+ command: "npx",
149
+ args: ["-y", "@playwright/mcp"],
150
+ },
151
+ "brave-search": {
152
+ command: "npx",
153
+ args: ["-y", "@modelcontextprotocol/server-brave-search"],
154
+ envVars: ["BRAVE_API_KEY"],
155
+ },
156
+ filesystem: {
157
+ command: "npx",
158
+ args: ["-y", "@modelcontextprotocol/server-filesystem"],
159
+ },
160
+ postgres: {
161
+ command: "npx",
162
+ args: ["-y", "@modelcontextprotocol/server-postgres"],
163
+ envVars: ["POSTGRES_CONNECTION_STRING"],
164
+ },
165
+ sqlite: {
166
+ command: "npx",
167
+ args: ["-y", "@modelcontextprotocol/server-sqlite"],
168
+ },
169
+ memory: {
170
+ command: "npx",
171
+ args: ["-y", "@modelcontextprotocol/server-memory"],
172
+ },
173
+ fetch: {
174
+ command: "npx",
175
+ args: ["-y", "@modelcontextprotocol/server-fetch"],
176
+ },
177
+ puppeteer: {
178
+ command: "npx",
179
+ args: ["-y", "@modelcontextprotocol/server-puppeteer"],
180
+ },
181
+ supabase: {
182
+ command: "npx",
183
+ args: ["-y", "@supabase/mcp-server-supabase"],
184
+ envVars: ["SUPABASE_ACCESS_TOKEN"],
185
+ },
186
+ docker: {
187
+ command: "npx",
188
+ args: ["-y", "@modelcontextprotocol/server-docker"],
189
+ },
190
+ gitlab: {
191
+ command: "npx",
192
+ args: ["-y", "@modelcontextprotocol/server-gitlab"],
193
+ envVars: ["GITLAB_PERSONAL_ACCESS_TOKEN"],
194
+ },
195
+ linear: {
196
+ command: "npx",
197
+ args: ["-y", "mcp-linear"],
198
+ envVars: ["LINEAR_API_KEY"],
199
+ },
200
+ notion: {
201
+ command: "npx",
202
+ args: ["-y", "@notionhq/notion-mcp-server"],
203
+ envVars: ["NOTION_API_KEY"],
204
+ },
205
+ slack: {
206
+ command: "npx",
207
+ args: ["-y", "@modelcontextprotocol/server-slack"],
208
+ envVars: ["SLACK_BOT_TOKEN", "SLACK_TEAM_ID"],
209
+ },
210
+ sentry: {
211
+ command: "npx",
212
+ args: ["-y", "@sentry/mcp-server"],
213
+ envVars: ["SENTRY_AUTH_TOKEN"],
214
+ },
215
+ "google-maps": {
216
+ command: "npx",
217
+ args: ["-y", "@modelcontextprotocol/server-google-maps"],
218
+ envVars: ["GOOGLE_MAPS_API_KEY"],
219
+ },
220
+ "google-drive": {
221
+ command: "npx",
222
+ args: ["-y", "@modelcontextprotocol/server-gdrive"],
223
+ envVars: ["GOOGLE_DRIVE_CREDENTIALS"],
224
+ },
225
+ "aws-kb-retrieval": {
226
+ command: "npx",
227
+ args: ["-y", "@modelcontextprotocol/server-aws-kb-retrieval"],
228
+ envVars: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_REGION"],
229
+ },
230
+ "everything": {
231
+ command: "npx",
232
+ args: ["-y", "@modelcontextprotocol/server-everything"],
233
+ },
234
+ "sequential-thinking": {
235
+ command: "npx",
236
+ args: ["-y", "@modelcontextprotocol/server-sequential-thinking"],
237
+ },
238
+ "computer-use": {
239
+ command: "npx",
240
+ args: ["-y", "@anthropic/mcp-computer-use"],
241
+ },
242
+ figma: {
243
+ command: "npx",
244
+ args: ["-y", "mcp-figma"],
245
+ envVars: ["FIGMA_ACCESS_TOKEN"],
246
+ },
247
+ discord: {
248
+ command: "npx",
249
+ args: ["-y", "mcp-discord"],
250
+ envVars: ["DISCORD_BOT_TOKEN"],
251
+ },
252
+ tavily: {
253
+ command: "npx",
254
+ args: ["-y", "mcp-tavily"],
255
+ envVars: ["TAVILY_API_KEY"],
256
+ },
257
+ firecrawl: {
258
+ command: "npx",
259
+ args: ["-y", "mcp-server-firecrawl"],
260
+ envVars: ["FIRECRAWL_API_KEY"],
261
+ },
262
+ cloudflare: {
263
+ command: "npx",
264
+ args: ["-y", "@cloudflare/mcp-server-cloudflare"],
265
+ envVars: ["CLOUDFLARE_API_TOKEN"],
266
+ },
267
+ vercel: {
268
+ command: "npx",
269
+ args: ["-y", "mcp-vercel"],
270
+ envVars: ["VERCEL_API_TOKEN"],
271
+ },
272
+ neon: {
273
+ command: "npx",
274
+ args: ["-y", "mcp-server-neon"],
275
+ envVars: ["NEON_API_KEY"],
276
+ },
277
+ railway: {
278
+ command: "npx",
279
+ args: ["-y", "mcp-server-railway"],
280
+ envVars: ["RAILWAY_API_TOKEN"],
281
+ },
282
+ "time-mcp": {
283
+ command: "npx",
284
+ args: ["-y", "time-mcp"],
285
+ },
286
+ "weather-mcp": {
287
+ command: "npx",
288
+ args: ["-y", "weather-mcp"],
289
+ },
290
+ exa: {
291
+ command: "npx",
292
+ args: ["-y", "mcp-server-exa"],
293
+ envVars: ["EXA_API_KEY"],
294
+ },
295
+ };
296
+
297
+ /**
298
+ * Enrich parsed entries with install info for known servers.
299
+ */
300
+ function enrichWithInstallInfo(entries: CatalogEntry[]): CatalogEntry[] {
301
+ return entries.map((entry) => {
302
+ if (entry.install) return entry; // Already has install info
303
+
304
+ // Try to match by ID or name
305
+ const lowerName = entry.name.toLowerCase().replace(/\s+/g, "-");
306
+ const lowerId = entry.id.toLowerCase();
307
+
308
+ for (const [pattern, install] of Object.entries(KNOWN_INSTALLS)) {
309
+ if (
310
+ lowerName.includes(pattern) ||
311
+ lowerId.includes(pattern) ||
312
+ lowerName.replace(/-/g, "").includes(pattern.replace(/-/g, ""))
313
+ ) {
314
+ return { ...entry, install };
315
+ }
316
+ }
317
+
318
+ return entry;
319
+ });
320
+ }
321
+
322
+ /**
323
+ * Check if enough time has passed since last sync.
324
+ */
325
+ function shouldSync(lastSyncAt: string | null, intervalMs: number): boolean {
326
+ if (!lastSyncAt) return true;
327
+ const elapsed = Date.now() - new Date(lastSyncAt).getTime();
328
+ return elapsed >= intervalMs;
329
+ }
330
+
331
+ /**
332
+ * Sync the MCP server catalog from GitHub.
333
+ * Parses the awesome-mcp-servers README, enriches with install info,
334
+ * and caches to servers.json.
335
+ */
336
+ export async function syncCatalog(): Promise<CatalogData> {
337
+ const markdown = await fetchCatalogFromGitHub();
338
+ const rawEntries = parseMarkdownServers(markdown);
339
+ const entries = enrichWithInstallInfo(rawEntries);
340
+
341
+ const catalog: CatalogData = {
342
+ lastUpdated: new Date().toISOString(),
343
+ source: "github:punkpeye/awesome-mcp-servers",
344
+ totalServers: entries.length,
345
+ servers: entries,
346
+ };
347
+
348
+ // Write to cache
349
+ const catalogPath = getCatalogPath();
350
+ const dir = path.dirname(catalogPath);
351
+ if (!fs.existsSync(dir)) {
352
+ fs.mkdirSync(dir, { recursive: true });
353
+ }
354
+ fs.writeFileSync(catalogPath, JSON.stringify(catalog, null, 2) + "\n", "utf-8");
355
+
356
+ // Update metadata with sync timestamp
357
+ const configDir = getGlobalConfigDir();
358
+ const meta = loadMetadata(configDir);
359
+ meta.sync.lastSyncAt = catalog.lastUpdated;
360
+ saveMetadata(configDir, meta);
361
+
362
+ return catalog;
363
+ }
364
+
365
+ /**
366
+ * Load the cached catalog, falling back to seed data.
367
+ */
368
+ export function loadCatalog(): CatalogData {
369
+ const catalogPath = getCatalogPath();
370
+
371
+ // Try cached version first
372
+ try {
373
+ const content = fs.readFileSync(catalogPath, "utf-8");
374
+ const data = JSON.parse(content) as CatalogData;
375
+ if (data.servers && data.servers.length > 0) {
376
+ return data;
377
+ }
378
+ } catch {
379
+ // Cache doesn't exist or is invalid — fall through to seed
380
+ }
381
+
382
+ // Fall back to seed data
383
+ const seedPath = getSeedPath();
384
+ try {
385
+ const content = fs.readFileSync(seedPath, "utf-8");
386
+ return JSON.parse(content) as CatalogData;
387
+ } catch {
388
+ // Return empty catalog if seed is also missing
389
+ return {
390
+ lastUpdated: new Date().toISOString(),
391
+ source: "seed",
392
+ totalServers: 0,
393
+ servers: [],
394
+ };
395
+ }
396
+ }
397
+
398
+ /**
399
+ * Sync if enough time has passed since last sync.
400
+ * Returns null if no sync was needed, or the catalog data if synced.
401
+ */
402
+ export async function syncIfNeeded(): Promise<CatalogData | null> {
403
+ const configDir = getGlobalConfigDir();
404
+ const meta = loadMetadata(configDir);
405
+
406
+ if (!shouldSync(meta.sync.lastSyncAt, meta.sync.syncIntervalMs)) {
407
+ return null;
408
+ }
409
+
410
+ try {
411
+ return await syncCatalog();
412
+ } catch {
413
+ // Sync failed — return null, will use cached/seed data
414
+ return null;
415
+ }
416
+ }