@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.
- package/README.md +109 -0
- package/data/seed-servers.json +727 -0
- package/package.json +47 -0
- package/skills/mcp/SKILL.md +104 -0
- package/src/bridge/client.ts +365 -0
- package/src/bridge/registry.ts +281 -0
- package/src/bridge/translator.ts +100 -0
- package/src/config/manager.ts +267 -0
- package/src/config/schema.ts +114 -0
- package/src/config/sync.ts +416 -0
- package/src/index.ts +297 -0
- package/src/tui/add-overlay.ts +436 -0
- package/src/tui/settings-overlay.ts +369 -0
- package/src/types.ts +162 -0
|
@@ -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
|
+
}
|