registryx-server 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.
Files changed (49) hide show
  1. package/README.md +203 -0
  2. package/coverage/lcov-report/base.css +224 -0
  3. package/coverage/lcov-report/block-navigation.js +87 -0
  4. package/coverage/lcov-report/favicon.png +0 -0
  5. package/coverage/lcov-report/index.html +146 -0
  6. package/coverage/lcov-report/prettify.css +1 -0
  7. package/coverage/lcov-report/prettify.js +2 -0
  8. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  9. package/coverage/lcov-report/sorter.js +210 -0
  10. package/coverage/lcov-report/src/adapters/crates.ts.html +847 -0
  11. package/coverage/lcov-report/src/adapters/index.html +176 -0
  12. package/coverage/lcov-report/src/adapters/index.ts.html +97 -0
  13. package/coverage/lcov-report/src/adapters/maven.ts.html +637 -0
  14. package/coverage/lcov-report/src/adapters/npm.ts.html +817 -0
  15. package/coverage/lcov-report/src/adapters/pypi.ts.html +730 -0
  16. package/coverage/lcov-report/src/cache.ts.html +202 -0
  17. package/coverage/lcov-report/src/config.ts.html +208 -0
  18. package/coverage/lcov-report/src/index.html +161 -0
  19. package/coverage/lcov-report/src/server.ts.html +1339 -0
  20. package/coverage/lcov-report/src/types.ts.html +373 -0
  21. package/coverage/lcov-report/src/utils/fetch.ts.html +220 -0
  22. package/coverage/lcov-report/src/utils/format.ts.html +130 -0
  23. package/coverage/lcov-report/src/utils/index.html +131 -0
  24. package/coverage/lcov.info +1686 -0
  25. package/dist/index.d.ts +1 -0
  26. package/dist/index.js +1210 -0
  27. package/eslint.config.mjs +16 -0
  28. package/package.json +41 -0
  29. package/src/adapters/crates.ts +254 -0
  30. package/src/adapters/index.ts +4 -0
  31. package/src/adapters/maven.ts +184 -0
  32. package/src/adapters/npm.ts +244 -0
  33. package/src/adapters/pypi.ts +215 -0
  34. package/src/cache.ts +39 -0
  35. package/src/config.ts +41 -0
  36. package/src/index.ts +25 -0
  37. package/src/server.ts +418 -0
  38. package/src/types.ts +96 -0
  39. package/src/utils/fetch.ts +45 -0
  40. package/src/utils/format.ts +15 -0
  41. package/test/adapters.test.ts +575 -0
  42. package/test/cache.test.ts +47 -0
  43. package/test/config.test.ts +69 -0
  44. package/test/fetch.test.ts +51 -0
  45. package/test/format.test.ts +35 -0
  46. package/test/server.test.ts +23 -0
  47. package/tsconfig.json +18 -0
  48. package/tsup.config.ts +11 -0
  49. package/vitest.config.ts +19 -0
package/src/server.ts ADDED
@@ -0,0 +1,418 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { z } from 'zod';
3
+ import type { Config } from './config.js';
4
+ import type { RegistryAdapter, RegistryName } from './types.js';
5
+ import { Cache } from './cache.js';
6
+ import { NpmAdapter, PypiAdapter, MavenAdapter, CratesAdapter } from './adapters/index.js';
7
+ import { formatNumber, padRight, truncate } from './utils/format.js';
8
+
9
+ function buildAdapters(config: Config, cache: Cache): Map<RegistryName, RegistryAdapter> {
10
+ const map = new Map<RegistryName, RegistryAdapter>();
11
+ const factories: Record<RegistryName, () => RegistryAdapter> = {
12
+ npm: () => new NpmAdapter(config, cache),
13
+ pypi: () => new PypiAdapter(config, cache),
14
+ maven: () => new MavenAdapter(config, cache),
15
+ crates: () => new CratesAdapter(config, cache),
16
+ };
17
+ for (const reg of config.registries) {
18
+ map.set(reg, factories[reg]());
19
+ }
20
+ return map;
21
+ }
22
+
23
+ function getAdapter(
24
+ adapters: Map<RegistryName, RegistryAdapter>,
25
+ name: RegistryName
26
+ ): RegistryAdapter {
27
+ const adapter = adapters.get(name);
28
+ if (!adapter)
29
+ throw new Error(
30
+ `Registry "${name}" is not enabled. Enabled: ${[...adapters.keys()].join(', ')}`
31
+ );
32
+ return adapter;
33
+ }
34
+
35
+ function getAdaptersForRegistry(
36
+ adapters: Map<RegistryName, RegistryAdapter>,
37
+ registry: string
38
+ ): RegistryAdapter[] {
39
+ if (registry === 'all') return [...adapters.values()];
40
+ return [getAdapter(adapters, registry as RegistryName)];
41
+ }
42
+
43
+ const registryEnum = z.enum(['npm', 'pypi', 'maven', 'crates']);
44
+ const registryOrAll = z.enum(['npm', 'pypi', 'maven', 'crates', 'all']).default('all');
45
+
46
+ export function createServer(config: Config): McpServer {
47
+ const cache = new Cache(config.cacheTtlMs);
48
+ const adapters = buildAdapters(config, cache);
49
+
50
+ const server = new McpServer({
51
+ name: 'registryx',
52
+ version: '0.1.0',
53
+ });
54
+
55
+ // ── 1. registryx_search ──
56
+ server.tool(
57
+ 'registryx_search',
58
+ 'Search packages across npm, PyPI, Maven Central, and crates.io registries',
59
+ {
60
+ query: z.string().describe('Package search text'),
61
+ registry: registryOrAll.describe('Registry to search (default: all)'),
62
+ limit: z.number().min(1).max(50).default(10).describe('Max results per registry'),
63
+ },
64
+ async ({ query, registry, limit }) => {
65
+ const targets = getAdaptersForRegistry(adapters, registry);
66
+ const sections: string[] = [`RegistryX Search — "${query}"\n`];
67
+
68
+ const results = await Promise.allSettled(
69
+ targets.map(async (a) => ({ name: a.name, results: await a.search(query, limit) }))
70
+ );
71
+
72
+ for (const r of results) {
73
+ if (r.status === 'fulfilled') {
74
+ const { name, results: pkgs } = r.value;
75
+ sections.push(`📦 ${name} (${pkgs.length} results):`);
76
+ for (const p of pkgs) {
77
+ sections.push(
78
+ ` ${padRight(p.name, 30)} ${padRight(p.version, 12)} ${p.downloads ? padRight(p.downloads, 14) : ''}${truncate(p.description, 60)}`
79
+ );
80
+ }
81
+ sections.push('');
82
+ } else {
83
+ sections.push(`❌ Error: ${r.reason}`);
84
+ }
85
+ }
86
+
87
+ return { content: [{ type: 'text', text: sections.join('\n') }] };
88
+ }
89
+ );
90
+
91
+ // ── 2. registryx_search_alternatives ──
92
+ server.tool(
93
+ 'registryx_search_alternatives',
94
+ 'Find equivalent packages across different registries',
95
+ {
96
+ packageName: z.string().describe('Package name to find alternatives for'),
97
+ sourceRegistry: registryEnum.describe('Registry the package is from'),
98
+ targetRegistries: z.array(registryEnum).optional().describe('Registries to search in'),
99
+ },
100
+ async ({ packageName, sourceRegistry, targetRegistries }) => {
101
+ const source = getAdapter(adapters, sourceRegistry);
102
+ const pkg = await source.getPackage(packageName);
103
+ const searchTerms = [pkg.name.replace(/[^a-zA-Z0-9 ]/g, ' '), ...pkg.keywords.slice(0, 3)];
104
+ const query = searchTerms.join(' ');
105
+
106
+ const targets = targetRegistries
107
+ ? targetRegistries.filter((r) => r !== sourceRegistry).map((r) => getAdapter(adapters, r))
108
+ : [...adapters.values()].filter((a) => a.name !== sourceRegistry);
109
+
110
+ const sections: string[] = [
111
+ `Alternatives for "${packageName}" (${sourceRegistry}): ${pkg.description}\n`,
112
+ ];
113
+
114
+ const results = await Promise.allSettled(
115
+ targets.map(async (a) => ({ name: a.name, results: await a.search(query, 5) }))
116
+ );
117
+
118
+ for (const r of results) {
119
+ if (r.status === 'fulfilled') {
120
+ const { name, results: pkgs } = r.value;
121
+ sections.push(`📦 ${name}:`);
122
+ for (const p of pkgs) {
123
+ sections.push(
124
+ ` ${padRight(p.name, 30)} ${padRight(p.version, 12)} ${truncate(p.description, 60)}`
125
+ );
126
+ }
127
+ sections.push('');
128
+ }
129
+ }
130
+
131
+ return { content: [{ type: 'text', text: sections.join('\n') }] };
132
+ }
133
+ );
134
+
135
+ // ── 3. registryx_get_package ──
136
+ server.tool(
137
+ 'registryx_get_package',
138
+ 'Get detailed package metadata from a registry',
139
+ {
140
+ name: z.string().describe('Package name (Maven: groupId:artifactId)'),
141
+ registry: registryEnum.describe('Package registry'),
142
+ },
143
+ async ({ name, registry }) => {
144
+ const adapter = getAdapter(adapters, registry);
145
+ const pkg = await adapter.getPackage(name);
146
+ const lines = [
147
+ `📦 ${pkg.name} v${pkg.version} (${pkg.registry})`,
148
+ `Description: ${pkg.description}`,
149
+ `License: ${pkg.license}`,
150
+ `Homepage: ${pkg.homepage || 'N/A'}`,
151
+ `Repository: ${pkg.repository || 'N/A'}`,
152
+ `Keywords: ${pkg.keywords.join(', ') || 'N/A'}`,
153
+ ];
154
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
155
+ }
156
+ );
157
+
158
+ // ── 4. registryx_get_readme ──
159
+ server.tool(
160
+ 'registryx_get_readme',
161
+ 'Get README content for a package',
162
+ {
163
+ name: z.string().describe('Package name'),
164
+ registry: registryEnum.describe('Package registry'),
165
+ },
166
+ async ({ name, registry }) => {
167
+ const adapter = getAdapter(adapters, registry);
168
+ const readme = await adapter.getReadme(name);
169
+ return { content: [{ type: 'text', text: truncate(readme, 8000) }] };
170
+ }
171
+ );
172
+
173
+ // ── 5. registryx_get_maintainers ──
174
+ server.tool(
175
+ 'registryx_get_maintainers',
176
+ 'Get package maintainers/authors',
177
+ {
178
+ name: z.string().describe('Package name'),
179
+ registry: registryEnum.describe('Package registry'),
180
+ },
181
+ async ({ name, registry }) => {
182
+ const adapter = getAdapter(adapters, registry);
183
+ const maintainers = await adapter.getMaintainers(name);
184
+ if (maintainers.length === 0)
185
+ return { content: [{ type: 'text', text: 'No maintainer info available.' }] };
186
+ const lines = [`Maintainers for ${name} (${registry}):`];
187
+ for (const m of maintainers) {
188
+ lines.push(` 👤 ${m.name}${m.email ? ` <${m.email}>` : ''}${m.url ? ` (${m.url})` : ''}`);
189
+ }
190
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
191
+ }
192
+ );
193
+
194
+ // ── 6. registryx_list_versions ──
195
+ server.tool(
196
+ 'registryx_list_versions',
197
+ 'List available versions for a package',
198
+ {
199
+ name: z.string().describe('Package name'),
200
+ registry: registryEnum.describe('Package registry'),
201
+ limit: z.number().min(1).max(100).default(20).describe('Max versions to return'),
202
+ stable: z.boolean().default(true).describe('Exclude pre-release versions'),
203
+ },
204
+ async ({ name, registry, limit, stable }) => {
205
+ const adapter = getAdapter(adapters, registry);
206
+ const versions = await adapter.listVersions(name, limit, stable);
207
+ const lines = [`Versions for ${name} (${registry}) — ${stable ? 'stable only' : 'all'}:`];
208
+ for (const v of versions) {
209
+ lines.push(
210
+ ` ${padRight(v.version, 20)} ${v.date ? v.date.split('T')[0] : ''}${v.prerelease ? ' [pre]' : ''}`
211
+ );
212
+ }
213
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
214
+ }
215
+ );
216
+
217
+ // ── 7. registryx_get_version ──
218
+ server.tool(
219
+ 'registryx_get_version',
220
+ 'Get details for a specific package version',
221
+ {
222
+ name: z.string().describe('Package name'),
223
+ registry: registryEnum.describe('Package registry'),
224
+ version: z.string().describe('Version string'),
225
+ },
226
+ async ({ name, registry, version }) => {
227
+ const adapter = getAdapter(adapters, registry);
228
+ const vd = await adapter.getVersion(name, version);
229
+ const lines = [
230
+ `📦 ${vd.name} v${vd.version} (${vd.registry})`,
231
+ `Date: ${vd.date || 'Unknown'}`,
232
+ `License: ${vd.license}`,
233
+ `Size: ${vd.size}`,
234
+ `Dependencies: ${vd.dependencies.length}`,
235
+ ];
236
+ if (vd.dependencies.length > 0) {
237
+ lines.push('');
238
+ for (const d of vd.dependencies.slice(0, 20)) {
239
+ lines.push(` ${d.type === 'dev' ? '[dev] ' : ''}${d.name} ${d.version}`);
240
+ }
241
+ }
242
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
243
+ }
244
+ );
245
+
246
+ // ── 8. registryx_compare_versions ──
247
+ server.tool(
248
+ 'registryx_compare_versions',
249
+ 'Compare two versions of a package',
250
+ {
251
+ name: z.string().describe('Package name'),
252
+ registry: registryEnum.describe('Package registry'),
253
+ version1: z.string().describe('First version'),
254
+ version2: z.string().describe('Second version'),
255
+ },
256
+ async ({ name, registry, version1, version2 }) => {
257
+ const adapter = getAdapter(adapters, registry);
258
+ const [v1, v2] = await Promise.all([
259
+ adapter.getVersion(name, version1),
260
+ adapter.getVersion(name, version2),
261
+ ]);
262
+
263
+ const deps1 = new Set(v1.dependencies.map((d) => d.name));
264
+ const deps2 = new Set(v2.dependencies.map((d) => d.name));
265
+ const added = [...deps2].filter((d) => !deps1.has(d));
266
+ const removed = [...deps1].filter((d) => !deps2.has(d));
267
+
268
+ const lines = [
269
+ `Comparing ${name} v${version1} ↔ v${version2} (${registry})`,
270
+ '',
271
+ ` v${version1}: ${v1.date || 'Unknown'} | ${v1.license} | ${v1.size} | ${v1.dependencies.length} deps`,
272
+ ` v${version2}: ${v2.date || 'Unknown'} | ${v2.license} | ${v2.size} | ${v2.dependencies.length} deps`,
273
+ ];
274
+
275
+ if (added.length > 0) lines.push(`\n ➕ Added: ${added.join(', ')}`);
276
+ if (removed.length > 0) lines.push(` ➖ Removed: ${removed.join(', ')}`);
277
+
278
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
279
+ }
280
+ );
281
+
282
+ // ── 9. registryx_get_dependencies ──
283
+ server.tool(
284
+ 'registryx_get_dependencies',
285
+ 'Get package dependencies',
286
+ {
287
+ name: z.string().describe('Package name'),
288
+ registry: registryEnum.describe('Package registry'),
289
+ version: z.string().optional().describe('Version (latest if omitted)'),
290
+ type: z.enum(['runtime', 'dev', 'all']).default('runtime').describe('Dependency type'),
291
+ },
292
+ async ({ name, registry, version, type }) => {
293
+ const adapter = getAdapter(adapters, registry);
294
+ const ver = version ?? (await adapter.getPackage(name)).version;
295
+ const deps = await adapter.getDependencies(name, ver, type);
296
+ if (deps.length === 0)
297
+ return {
298
+ content: [{ type: 'text', text: `No ${type} dependencies found for ${name}@${ver}.` }],
299
+ };
300
+
301
+ const lines = [`Dependencies for ${name}@${ver} (${registry}, ${type}):`];
302
+ for (const d of deps) {
303
+ lines.push(` ${d.type === 'dev' ? '[dev] ' : ''}${padRight(d.name, 30)} ${d.version}`);
304
+ }
305
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
306
+ }
307
+ );
308
+
309
+ // ── 10. registryx_reverse_dependencies ──
310
+ server.tool(
311
+ 'registryx_reverse_dependencies',
312
+ 'Find packages that depend on this one',
313
+ {
314
+ name: z.string().describe('Package name'),
315
+ registry: registryEnum.describe('Package registry'),
316
+ limit: z.number().min(1).max(100).default(20).describe('Max results'),
317
+ },
318
+ async ({ name, registry, limit }) => {
319
+ const adapter = getAdapter(adapters, registry);
320
+ const rdeps = await adapter.getReverseDependencies(name, limit);
321
+ if (rdeps.length === 0)
322
+ return {
323
+ content: [
324
+ {
325
+ type: 'text',
326
+ text: `No reverse dependency data available for ${name} on ${registry}.`,
327
+ },
328
+ ],
329
+ };
330
+
331
+ const lines = [`Packages that depend on ${name} (${registry}):`];
332
+ for (const p of rdeps) {
333
+ lines.push(` ${padRight(p.name, 30)} ${p.version}`);
334
+ }
335
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
336
+ }
337
+ );
338
+
339
+ // ── 11. registryx_download_stats ──
340
+ server.tool(
341
+ 'registryx_download_stats',
342
+ 'Get download statistics for a package',
343
+ {
344
+ name: z.string().describe('Package name'),
345
+ registry: registryEnum.describe('Package registry'),
346
+ period: z
347
+ .enum(['last-day', 'last-week', 'last-month', 'last-year'])
348
+ .default('last-month')
349
+ .describe('Time period'),
350
+ },
351
+ async ({ name, registry, period }) => {
352
+ const adapter = getAdapter(adapters, registry);
353
+ const stats = await adapter.getDownloadStats(name, period);
354
+ const lines = [
355
+ `📊 Download stats for ${name} (${registry})`,
356
+ `Period: ${stats.period}`,
357
+ `Total: ${formatNumber(stats.total)}`,
358
+ ];
359
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
360
+ }
361
+ );
362
+
363
+ // ── 12. registryx_package_health ──
364
+ server.tool(
365
+ 'registryx_package_health',
366
+ 'Get package health score and maintenance signals',
367
+ {
368
+ name: z.string().describe('Package name'),
369
+ registry: registryEnum.describe('Package registry'),
370
+ },
371
+ async ({ name, registry }) => {
372
+ const adapter = getAdapter(adapters, registry);
373
+ const health = await adapter.getPackageHealth(name);
374
+ const lines = [
375
+ `🏥 Health for ${name} (${registry})`,
376
+ `Score: ${health.score}/10`,
377
+ `Last publish: ${health.lastPublish || 'Unknown'}`,
378
+ `Dependencies: ${health.dependencyCount}`,
379
+ `Has typings: ${health.hasTypings ? '✅' : '❌'}`,
380
+ `Has tests: ${health.hasTests ? '✅' : '❌'}`,
381
+ '',
382
+ 'Signals:',
383
+ ...health.signals.map((s) => ` ✅ ${s}`),
384
+ ];
385
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
386
+ }
387
+ );
388
+
389
+ // ── 13. registryx_security_advisories ──
390
+ server.tool(
391
+ 'registryx_security_advisories',
392
+ 'Check for security advisories (via OSV.dev)',
393
+ {
394
+ name: z.string().describe('Package name'),
395
+ registry: registryEnum.describe('Package registry'),
396
+ version: z.string().optional().describe('Specific version to check'),
397
+ },
398
+ async ({ name, registry, version }) => {
399
+ const adapter = getAdapter(adapters, registry);
400
+ const advisories = await adapter.getSecurityAdvisories(name, version);
401
+ if (advisories.length === 0) {
402
+ return {
403
+ content: [
404
+ { type: 'text', text: `✅ No known security advisories for ${name} (${registry}).` },
405
+ ],
406
+ };
407
+ }
408
+ const lines = [`🔒 Security advisories for ${name} (${registry}):`];
409
+ for (const a of advisories) {
410
+ lines.push(` ⚠️ ${a.id}: ${a.title}`);
411
+ lines.push(` Severity: ${a.severity} | ${a.url}`);
412
+ }
413
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
414
+ }
415
+ );
416
+
417
+ return server;
418
+ }
package/src/types.ts ADDED
@@ -0,0 +1,96 @@
1
+ export type RegistryName = 'npm' | 'pypi' | 'maven' | 'crates';
2
+
3
+ export interface PackageSearchResult {
4
+ name: string;
5
+ version: string;
6
+ description: string;
7
+ downloads: string;
8
+ registry: RegistryName;
9
+ }
10
+
11
+ export interface PackageMetadata {
12
+ name: string;
13
+ version: string;
14
+ description: string;
15
+ license: string;
16
+ homepage: string;
17
+ repository: string;
18
+ keywords: string[];
19
+ registry: RegistryName;
20
+ }
21
+
22
+ export interface VersionInfo {
23
+ version: string;
24
+ date: string;
25
+ prerelease: boolean;
26
+ }
27
+
28
+ export interface VersionDetail {
29
+ name: string;
30
+ version: string;
31
+ date: string;
32
+ license: string;
33
+ size: string;
34
+ dependencies: Dependency[];
35
+ registry: RegistryName;
36
+ }
37
+
38
+ export interface Dependency {
39
+ name: string;
40
+ version: string;
41
+ type: 'runtime' | 'dev' | 'optional' | 'build';
42
+ }
43
+
44
+ export interface DownloadStats {
45
+ name: string;
46
+ registry: RegistryName;
47
+ period: string;
48
+ total: number;
49
+ breakdown: { date: string; downloads: number }[];
50
+ }
51
+
52
+ export interface MaintainerInfo {
53
+ name: string;
54
+ email?: string;
55
+ url?: string;
56
+ }
57
+
58
+ export interface SecurityAdvisory {
59
+ id: string;
60
+ title: string;
61
+ severity: string;
62
+ url: string;
63
+ affectedVersions: string;
64
+ }
65
+
66
+ export interface PackageHealth {
67
+ name: string;
68
+ registry: RegistryName;
69
+ score: number;
70
+ lastPublish: string;
71
+ openIssues?: number;
72
+ weeklyDownloads?: number;
73
+ dependencyCount: number;
74
+ hasTests: boolean;
75
+ hasTypings: boolean;
76
+ signals: string[];
77
+ }
78
+
79
+ export interface RegistryAdapter {
80
+ readonly name: RegistryName;
81
+ search(query: string, limit: number): Promise<PackageSearchResult[]>;
82
+ getPackage(name: string): Promise<PackageMetadata>;
83
+ getReadme(name: string): Promise<string>;
84
+ getMaintainers(name: string): Promise<MaintainerInfo[]>;
85
+ listVersions(name: string, limit: number, stableOnly: boolean): Promise<VersionInfo[]>;
86
+ getVersion(name: string, version: string): Promise<VersionDetail>;
87
+ getDependencies(
88
+ name: string,
89
+ version: string,
90
+ type: 'runtime' | 'dev' | 'all'
91
+ ): Promise<Dependency[]>;
92
+ getReverseDependencies(name: string, limit: number): Promise<PackageSearchResult[]>;
93
+ getDownloadStats(name: string, period: string): Promise<DownloadStats>;
94
+ getPackageHealth(name: string): Promise<PackageHealth>;
95
+ getSecurityAdvisories(name: string, version?: string): Promise<SecurityAdvisory[]>;
96
+ }
@@ -0,0 +1,45 @@
1
+ import type { Config } from '../config.js';
2
+
3
+ const ALLOWED_HOSTS = new Set([
4
+ 'registry.npmjs.org',
5
+ 'api.npmjs.org',
6
+ 'pypi.org',
7
+ 'search.maven.org',
8
+ 'crates.io',
9
+ 'osv.dev',
10
+ ]);
11
+
12
+ export async function registryFetch(
13
+ url: string,
14
+ config: Config,
15
+ extraHeaders?: Record<string, string>
16
+ ): Promise<Response> {
17
+ const parsed = new URL(url);
18
+ if (parsed.protocol !== 'https:') {
19
+ throw new Error(`Only HTTPS URLs allowed, got: ${parsed.protocol}`);
20
+ }
21
+ if (!ALLOWED_HOSTS.has(parsed.hostname)) {
22
+ throw new Error(`Host not allowed: ${parsed.hostname}`);
23
+ }
24
+
25
+ const headers: Record<string, string> = {
26
+ Accept: 'application/json',
27
+ 'User-Agent': 'registryx-mcp-server/0.1.0',
28
+ ...extraHeaders,
29
+ };
30
+
31
+ if (parsed.hostname === 'registry.npmjs.org' && config.npmToken) {
32
+ headers['Authorization'] = `Bearer ${config.npmToken}`;
33
+ }
34
+
35
+ const response = await fetch(url, {
36
+ headers,
37
+ signal: AbortSignal.timeout(config.timeoutMs),
38
+ });
39
+
40
+ if (!response.ok) {
41
+ throw new Error(`HTTP ${response.status} from ${parsed.hostname}${parsed.pathname}`);
42
+ }
43
+
44
+ return response;
45
+ }
@@ -0,0 +1,15 @@
1
+ export function formatNumber(n: number): string {
2
+ if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B`;
3
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
4
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
5
+ return n.toString();
6
+ }
7
+
8
+ export function padRight(str: string, len: number): string {
9
+ return str.length >= len ? str : str + ' '.repeat(len - str.length);
10
+ }
11
+
12
+ export function truncate(str: string, maxLen: number): string {
13
+ if (str.length <= maxLen) return str;
14
+ return str.slice(0, maxLen - 3) + '...';
15
+ }