pub-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.
Files changed (63) hide show
  1. package/.dockerignore +13 -0
  2. package/.editorconfig +14 -0
  3. package/.env.example +15 -0
  4. package/.eslintrc.cjs +21 -0
  5. package/.github/workflows/ci.yml +63 -0
  6. package/.prettierrc +7 -0
  7. package/CHANGELOG.md +84 -0
  8. package/CODE_OF_CONDUCT.md +34 -0
  9. package/CONTRIBUTING.md +55 -0
  10. package/Dockerfile +13 -0
  11. package/LICENSE +21 -0
  12. package/README.md +287 -0
  13. package/dist/cache/cache.d.ts +27 -0
  14. package/dist/cache/cache.d.ts.map +1 -0
  15. package/dist/cache/cache.js +94 -0
  16. package/dist/cache/cache.js.map +1 -0
  17. package/dist/cli.d.ts +3 -0
  18. package/dist/cli.d.ts.map +1 -0
  19. package/dist/cli.js +286 -0
  20. package/dist/cli.js.map +1 -0
  21. package/dist/clients/githubClient.d.ts +33 -0
  22. package/dist/clients/githubClient.d.ts.map +1 -0
  23. package/dist/clients/githubClient.js +126 -0
  24. package/dist/clients/githubClient.js.map +1 -0
  25. package/dist/clients/pubClient.d.ts +28 -0
  26. package/dist/clients/pubClient.d.ts.map +1 -0
  27. package/dist/clients/pubClient.js +223 -0
  28. package/dist/clients/pubClient.js.map +1 -0
  29. package/dist/server-http.d.ts +7 -0
  30. package/dist/server-http.d.ts.map +1 -0
  31. package/dist/server-http.js +139 -0
  32. package/dist/server-http.js.map +1 -0
  33. package/dist/server.d.ts +2 -0
  34. package/dist/server.d.ts.map +1 -0
  35. package/dist/server.js +69 -0
  36. package/dist/server.js.map +1 -0
  37. package/dist/toolRegistry.d.ts +74 -0
  38. package/dist/toolRegistry.d.ts.map +1 -0
  39. package/dist/toolRegistry.js +80 -0
  40. package/dist/toolRegistry.js.map +1 -0
  41. package/dist/tools/index.d.ts +142 -0
  42. package/dist/tools/index.d.ts.map +1 -0
  43. package/dist/tools/index.js +214 -0
  44. package/dist/tools/index.js.map +1 -0
  45. package/dist/types/index.d.ts +55 -0
  46. package/dist/types/index.d.ts.map +1 -0
  47. package/dist/types/index.js +2 -0
  48. package/dist/types/index.js.map +1 -0
  49. package/dist/utils/rateLimiter.d.ts +13 -0
  50. package/dist/utils/rateLimiter.d.ts.map +1 -0
  51. package/dist/utils/rateLimiter.js +42 -0
  52. package/dist/utils/rateLimiter.js.map +1 -0
  53. package/package.json +72 -0
  54. package/skills-lock.json +25 -0
  55. package/src/cache/cache.ts +124 -0
  56. package/src/cli.ts +350 -0
  57. package/src/clients/githubClient.ts +169 -0
  58. package/src/clients/pubClient.ts +312 -0
  59. package/src/tools/index.ts +266 -0
  60. package/src/types/index.ts +61 -0
  61. package/src/utils/rateLimiter.ts +56 -0
  62. package/tsconfig.json +24 -0
  63. package/vitest.config.ts +14 -0
@@ -0,0 +1,124 @@
1
+ export interface CacheOptions {
2
+ ttl: number;
3
+ maxItems: number;
4
+ }
5
+
6
+ interface CacheEntry<T> {
7
+ value: T;
8
+ expiry: number;
9
+ }
10
+
11
+ export class LRUCache<T> {
12
+ private cache: Map<string, CacheEntry<T>>;
13
+ private ttl: number;
14
+ private maxItems: number;
15
+
16
+ constructor(options: CacheOptions) {
17
+ this.cache = new Map();
18
+ this.ttl = options.ttl * 1000;
19
+ this.maxItems = options.maxItems;
20
+ }
21
+
22
+ get(key: string): T | undefined {
23
+ const entry = this.cache.get(key);
24
+
25
+ if (!entry) {
26
+ return undefined;
27
+ }
28
+
29
+ if (Date.now() > entry.expiry) {
30
+ this.cache.delete(key);
31
+ return undefined;
32
+ }
33
+
34
+ this.cache.delete(key);
35
+ this.cache.set(key, entry);
36
+
37
+ return entry.value;
38
+ }
39
+
40
+ set(key: string, value: T, ttl?: number): void {
41
+ if (this.cache.size >= this.maxItems) {
42
+ const firstKey = this.cache.keys().next().value;
43
+ if (firstKey !== undefined) {
44
+ this.cache.delete(firstKey);
45
+ }
46
+ }
47
+
48
+ const expiryTime = ttl ? Date.now() + ttl * 1000 : Date.now() + this.ttl;
49
+ this.cache.set(key, { value, expiry: expiryTime });
50
+ }
51
+
52
+ has(key: string): boolean {
53
+ const entry = this.cache.get(key);
54
+ if (!entry) return false;
55
+
56
+ if (Date.now() > entry.expiry) {
57
+ this.cache.delete(key);
58
+ return false;
59
+ }
60
+
61
+ return true;
62
+ }
63
+
64
+ delete(key: string): void {
65
+ this.cache.delete(key);
66
+ }
67
+
68
+ clear(): void {
69
+ this.cache.clear();
70
+ }
71
+
72
+ size(): number {
73
+ this.cleanup();
74
+ return this.cache.size;
75
+ }
76
+
77
+ private cleanup(): void {
78
+ const now = Date.now();
79
+ for (const [key, entry] of this.cache.entries()) {
80
+ if (now > entry.expiry) {
81
+ this.cache.delete(key);
82
+ }
83
+ }
84
+ }
85
+
86
+ stats(): { size: number; hits: number; misses: number } {
87
+ return {
88
+ size: this.cache.size,
89
+ hits: 0,
90
+ misses: 0,
91
+ };
92
+ }
93
+ }
94
+
95
+ export function createCache<T>(options: CacheOptions): LRUCache<T> {
96
+ return new LRUCache<T>(options);
97
+ }
98
+
99
+ const defaultCacheOptions: CacheOptions = {
100
+ ttl: parseInt(process.env.CACHE_TTL || '3600', 10),
101
+ maxItems: parseInt(process.env.CACHE_MAX_ITEMS || '500', 10),
102
+ };
103
+
104
+ let packageCache: LRUCache<unknown> | null = null;
105
+ let searchCache: LRUCache<unknown> | null = null;
106
+
107
+ export function getPackageCache(): LRUCache<unknown> {
108
+ if (!packageCache) {
109
+ packageCache = new LRUCache(defaultCacheOptions);
110
+ }
111
+ return packageCache;
112
+ }
113
+
114
+ export function getSearchCache(): LRUCache<unknown> {
115
+ if (!searchCache) {
116
+ searchCache = new LRUCache({ ...defaultCacheOptions, ttl: 600 });
117
+ }
118
+ return searchCache;
119
+ }
120
+
121
+ export function clearAllCaches(): void {
122
+ packageCache?.clear();
123
+ searchCache?.clear();
124
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,350 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { parseArgs } from 'util';
4
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
5
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
6
+ import {
7
+ CallToolRequestSchema,
8
+ ListToolsRequestSchema,
9
+ ListResourcesRequestSchema,
10
+ ReadResourceRequestSchema,
11
+ } from '@modelcontextprotocol/sdk/types.js';
12
+ import { PubClient } from './clients/pubClient.js';
13
+ import { GitHubClient } from './clients/githubClient.js';
14
+ import {
15
+ tools,
16
+ handleSearchPackages,
17
+ handleGetPackageInfo,
18
+ handleGetPackageVersions,
19
+ handleGetReadme,
20
+ handleGetDependencies,
21
+ handleGetPackageScore,
22
+ handleGetChangelog,
23
+ handleGetPackageMetrics,
24
+ } from './tools/index.js';
25
+ import pino from 'pino';
26
+ import express from 'express';
27
+ import cors from 'cors';
28
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
29
+
30
+ const logger = pino({ level: process.env.LOG_LEVEL || 'info' });
31
+
32
+ const VERSION = '0.1.0';
33
+
34
+ const client = new PubClient();
35
+ const githubClient = new GitHubClient();
36
+
37
+ function createServer(): Server {
38
+ const server = new Server(
39
+ {
40
+ name: 'pub-mcp',
41
+ version: VERSION,
42
+ },
43
+ {
44
+ capabilities: {
45
+ tools: {},
46
+ resources: {},
47
+ },
48
+ }
49
+ );
50
+
51
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
52
+ return { tools };
53
+ });
54
+
55
+ server.setRequestHandler(ListResourcesRequestSchema, async () => {
56
+ return {
57
+ resources: [
58
+ {
59
+ uri: 'pub://popular-packages',
60
+ name: 'popular_packages',
61
+ description: 'List of popular Dart/Flutter packages from pub.dev',
62
+ mimeType: 'application/json',
63
+ },
64
+ {
65
+ uri: 'pub://package/{name}',
66
+ name: 'package_docs',
67
+ description: 'Get package documentation and metrics',
68
+ mimeType: 'application/json',
69
+ },
70
+ ],
71
+ };
72
+ });
73
+
74
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
75
+ const { uri } = request.params;
76
+
77
+ try {
78
+ if (uri === 'pub://popular-packages') {
79
+ const result = await client.searchPackages('', 20);
80
+ return {
81
+ contents: [
82
+ {
83
+ uri: 'pub://popular-packages',
84
+ mimeType: 'application/json',
85
+ text: JSON.stringify(result.packages),
86
+ },
87
+ ],
88
+ };
89
+ }
90
+
91
+ if (uri.startsWith('pub://package/')) {
92
+ const name = uri.replace('pub://package/', '');
93
+ const [info, score, versions] = await Promise.all([
94
+ client.getPackage(name),
95
+ client.getPackageScore(name),
96
+ client.getPackageVersions(name),
97
+ ]);
98
+ return {
99
+ contents: [
100
+ {
101
+ uri,
102
+ mimeType: 'application/json',
103
+ text: JSON.stringify({ info, score, versionCount: versions.length }),
104
+ },
105
+ ],
106
+ };
107
+ }
108
+
109
+ throw new Error(`Unknown resource: ${uri}`);
110
+ } catch (error) {
111
+ const message = error instanceof Error ? error.message : String(error);
112
+ return {
113
+ contents: [
114
+ {
115
+ uri,
116
+ mimeType: 'text/plain',
117
+ text: JSON.stringify({ error: message }),
118
+ },
119
+ ],
120
+ isError: true,
121
+ };
122
+ }
123
+ });
124
+
125
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
126
+ const { name, arguments: args } = request.params;
127
+
128
+ logger.info({ tool: name, args }, 'Tool called');
129
+
130
+ try {
131
+ let result: unknown;
132
+
133
+ switch (name) {
134
+ case 'ping':
135
+ return {
136
+ content: [{ type: 'text', text: 'pong' }],
137
+ };
138
+
139
+ case 'health':
140
+ return {
141
+ content: [{ type: 'text', text: JSON.stringify({ status: 'ok', version: VERSION }) }],
142
+ };
143
+
144
+ case 'search_packages':
145
+ result = await handleSearchPackages(client, args as { query: string; limit?: number });
146
+ break;
147
+
148
+ case 'get_package_info':
149
+ result = await handleGetPackageInfo(client, args as { name: string });
150
+ break;
151
+
152
+ case 'get_package_versions':
153
+ result = await handleGetPackageVersions(client, args as { name: string });
154
+ break;
155
+
156
+ case 'get_readme':
157
+ result = await handleGetReadme(
158
+ client,
159
+ githubClient,
160
+ args as { name: string; version?: string; format?: 'markdown' | 'text' | 'html' }
161
+ );
162
+ break;
163
+
164
+ case 'get_dependencies':
165
+ result = await handleGetDependencies(client, args as { name: string; version?: string });
166
+ break;
167
+
168
+ case 'get_package_score':
169
+ result = await handleGetPackageScore(client, args as { name: string });
170
+ break;
171
+
172
+ case 'get_changelog':
173
+ result = await handleGetChangelog(client, args as { name: string; version?: string });
174
+ break;
175
+
176
+ case 'get_package_metrics':
177
+ result = await handleGetPackageMetrics(client, args as { name: string });
178
+ break;
179
+
180
+ default:
181
+ throw new Error(`Unknown tool: ${name}`);
182
+ }
183
+
184
+ return {
185
+ content: [{ type: 'text', text: JSON.stringify(result) }],
186
+ };
187
+ } catch (error) {
188
+ const message = error instanceof Error ? error.message : String(error);
189
+ logger.error({ tool: name, error: message }, 'Tool error');
190
+ return {
191
+ content: [{ type: 'text', text: JSON.stringify({ error: message }) }],
192
+ isError: true,
193
+ };
194
+ }
195
+ });
196
+
197
+ return server;
198
+ }
199
+
200
+ async function startStdio() {
201
+ const server = createServer();
202
+ const transport = new StdioServerTransport();
203
+ await server.connect(transport);
204
+ logger.info('pub-mcp server running on stdio');
205
+ }
206
+
207
+ async function startHttp(port: number) {
208
+ const app = express();
209
+ app.use(express.json());
210
+
211
+ const transports: Map<string, StreamableHTTPServerTransport> = new Map();
212
+
213
+ app.use(
214
+ cors({
215
+ exposedHeaders: ['WWW-Authenticate', 'Mcp-Session-Id', 'Mcp-Protocol-Version'],
216
+ origin: '*',
217
+ })
218
+ );
219
+
220
+ app.post('/mcp', async (req, res) => {
221
+ const sessionId = req.headers['mcp-session-id'] as string | undefined;
222
+
223
+ if (sessionId && transports.has(sessionId)) {
224
+ await transports.get(sessionId)!.handleRequest(req, res, req.body);
225
+ } else {
226
+ const transport = new StreamableHTTPServerTransport({
227
+ sessionIdGenerator: () => crypto.randomUUID(),
228
+ onsessioninitialized: (sid: string) => {
229
+ transports.set(sid, transport);
230
+ },
231
+ });
232
+ transport.onclose = () => {
233
+ if (transport.sessionId) {
234
+ transports.delete(transport.sessionId);
235
+ }
236
+ };
237
+ const server = createServer();
238
+ await server.connect(transport);
239
+ await transport.handleRequest(req, res, req.body);
240
+ }
241
+ });
242
+
243
+ app.get('/health', (_req, res) => {
244
+ res.json({ status: 'ok', version: VERSION });
245
+ });
246
+
247
+ app.get('/', (_req, res) => {
248
+ res.json({
249
+ name: 'pub-mcp',
250
+ version: VERSION,
251
+ description: 'MCP server for pub.dev',
252
+ endpoints: {
253
+ mcp: 'POST /mcp',
254
+ health: 'GET /health',
255
+ },
256
+ });
257
+ });
258
+
259
+ return new Promise<void>((resolve) => {
260
+ app.listen(port, () => {
261
+ logger.info(`pub-mcp HTTP server listening on port ${port}`);
262
+ resolve();
263
+ });
264
+ });
265
+ }
266
+
267
+ async function runHealthCheck() {
268
+ console.log('Checking pub.dev API...');
269
+ try {
270
+ await client.getPackage('http');
271
+ console.log('✓ pub.dev API is reachable');
272
+ console.log(JSON.stringify({ status: 'ok', version: VERSION }));
273
+ } catch (error) {
274
+ console.error('✗ pub.dev API is not reachable');
275
+ console.error(error);
276
+ process.exit(1);
277
+ }
278
+ }
279
+
280
+ function showVersion() {
281
+ console.log(`pub-mcp version ${VERSION}`);
282
+ }
283
+
284
+ async function main() {
285
+ const { values, positionals } = parseArgs({
286
+ options: {
287
+ help: { type: 'boolean', short: 'h' },
288
+ version: { type: 'boolean', short: 'v' },
289
+ health: { type: 'boolean' },
290
+ http: { type: 'boolean', default: false },
291
+ stdio: { type: 'boolean', default: false },
292
+ port: { type: 'string', default: '3000' },
293
+ },
294
+ allowPositionals: true,
295
+ });
296
+
297
+ const subcommand = positionals[0]?.toLowerCase();
298
+ const isMcpCommand = subcommand === 'mcp' || subcommand === 'server';
299
+
300
+ if (values.help || (!isMcpCommand && positionals.length > 0)) {
301
+ showHelp();
302
+ return;
303
+ }
304
+
305
+ if (values.version) {
306
+ showVersion();
307
+ return;
308
+ }
309
+
310
+ if (values.health) {
311
+ await runHealthCheck();
312
+ return;
313
+ }
314
+
315
+ const useHttp = values.http || (!values.stdio && positionals.length === 0);
316
+ const useStdio = values.stdio || (!values.http && positionals.length === 0);
317
+ const port = parseInt(values.port as string, 10);
318
+
319
+ if (useHttp && useStdio) {
320
+ logger.info('Starting both stdio and HTTP servers');
321
+ await Promise.all([startStdio(), startHttp(port)]);
322
+ } else if (useHttp) {
323
+ await startHttp(port);
324
+ } else if (useStdio) {
325
+ await startStdio();
326
+ }
327
+ }
328
+
329
+ function showHelp() {
330
+ console.log(`pub-mcp - MCP server for pub.dev
331
+
332
+ Usage:
333
+ pub-mcp Start MCP server with both transports
334
+ pub-mcp --stdio Start MCP server (stdio only)
335
+ pub-mcp --http Start MCP server (HTTP only)
336
+ pub-mcp --http --port 8080 Start HTTP on port 8080
337
+ pub-mcp --version Show version
338
+ pub-mcp --health Check pub.dev API connectivity
339
+ pub-mcp --help Show this help
340
+
341
+ HTTP Endpoints:
342
+ POST /mcp MCP protocol endpoint
343
+ GET /health Health check
344
+ GET / Server info`);
345
+ }
346
+
347
+ main().catch((error) => {
348
+ console.error('Fatal error:', error);
349
+ process.exit(1);
350
+ });
@@ -0,0 +1,169 @@
1
+ export interface GitHubClientOptions {
2
+ baseUrl?: string;
3
+ token?: string;
4
+ timeout?: number;
5
+ }
6
+
7
+ export class GitHubClientError extends Error {
8
+ constructor(message: string, public statusCode?: number) {
9
+ super(message);
10
+ this.name = 'GitHubClientError';
11
+ }
12
+ }
13
+
14
+ export class GitHubClient {
15
+ private baseUrl: string;
16
+ private token?: string;
17
+ private timeout: number;
18
+
19
+ constructor(options: GitHubClientOptions = {}) {
20
+ this.baseUrl = options.baseUrl || process.env.GITHUB_API_URL || 'https://api.github.com';
21
+ this.token = options.token || process.env.GITHUB_TOKEN;
22
+ this.timeout = options.timeout || 10000;
23
+ }
24
+
25
+ private async fetchWithTimeout(url: string, options: RequestInit = {}): Promise<Response> {
26
+ const controller = new AbortController();
27
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
28
+
29
+ const headers: Record<string, string> = {
30
+ Accept: 'application/vnd.github.v3+json',
31
+ ...(options.headers as Record<string, string> || {}),
32
+ };
33
+
34
+ if (this.token) {
35
+ headers.Authorization = `Bearer ${this.token}`;
36
+ }
37
+
38
+ try {
39
+ const response = await fetch(url, {
40
+ ...options,
41
+ headers,
42
+ signal: controller.signal,
43
+ });
44
+ return response;
45
+ } finally {
46
+ clearTimeout(timeoutId);
47
+ }
48
+ }
49
+
50
+ async getRepositoryReadme(owner: string, repo: string): Promise<string> {
51
+ const url = `${this.baseUrl}/repos/${owner}/${repo}/readme`;
52
+ const response = await this.fetchWithTimeout(url);
53
+
54
+ if (response.status === 404) {
55
+ return '';
56
+ }
57
+
58
+ if (!response.ok) {
59
+ throw new GitHubClientError(`Failed to fetch README: ${response.statusText}`, response.status);
60
+ }
61
+
62
+ const data = await response.json() as { content?: string };
63
+
64
+ if (data.content) {
65
+ return Buffer.from(data.content, 'base64').toString('utf-8');
66
+ }
67
+
68
+ return '';
69
+ }
70
+
71
+ async getChangelog(owner: string, repo: string): Promise<string> {
72
+ const url = `${this.baseUrl}/repos/${owner}/${repo}/contents/CHANGELOG.md`;
73
+ const response = await this.fetchWithTimeout(url);
74
+
75
+ if (response.status === 404) {
76
+ const altUrls = ['CHANGELOG.md', 'changelog.md', 'HISTORY.md', 'history.md'];
77
+ for (const altUrl of altUrls) {
78
+ const altResponse = await this.fetchWithTimeout(
79
+ `${this.baseUrl}/repos/${owner}/${repo}/contents/${altUrl}`
80
+ );
81
+ if (altResponse.ok) {
82
+ const data = await altResponse.json() as { content?: string };
83
+ if (data.content) {
84
+ return Buffer.from(data.content, 'base64').toString('utf-8');
85
+ }
86
+ }
87
+ }
88
+ return '';
89
+ }
90
+
91
+ if (!response.ok) {
92
+ throw new GitHubClientError(`Failed to fetch changelog: ${response.statusText}`, response.status);
93
+ }
94
+
95
+ const data = await response.json() as { content?: string };
96
+ if (data.content) {
97
+ return Buffer.from(data.content, 'base64').toString('utf-8');
98
+ }
99
+
100
+ return '';
101
+ }
102
+
103
+ async getLatestRelease(owner: string, repo: string): Promise<{ tag: string; body: string } | null> {
104
+ const url = `${this.baseUrl}/repos/${owner}/${repo}/releases/latest`;
105
+ const response = await this.fetchWithTimeout(url);
106
+
107
+ if (response.status === 404) {
108
+ return null;
109
+ }
110
+
111
+ if (!response.ok) {
112
+ throw new GitHubClientError(`Failed to fetch release: ${response.statusText}`, response.status);
113
+ }
114
+
115
+ const data = await response.json() as { tag_name?: string; body?: string };
116
+ return {
117
+ tag: data.tag_name || '',
118
+ body: data.body || '',
119
+ };
120
+ }
121
+
122
+ async getRepositoryInfo(owner: string, repo: string): Promise<{
123
+ description: string;
124
+ stars: number;
125
+ forks: number;
126
+ url: string;
127
+ } | null> {
128
+ const url = `${this.baseUrl}/repos/${owner}/${repo}`;
129
+ const response = await this.fetchWithTimeout(url);
130
+
131
+ if (response.status === 404) {
132
+ return null;
133
+ }
134
+
135
+ if (!response.ok) {
136
+ throw new GitHubClientError(`Failed to fetch repo info: ${response.statusText}`, response.status);
137
+ }
138
+
139
+ const data = await response.json() as {
140
+ description?: string;
141
+ stargazers_count?: number;
142
+ forks_count?: number;
143
+ html_url?: string;
144
+ };
145
+
146
+ return {
147
+ description: data.description || '',
148
+ stars: data.stargazers_count || 0,
149
+ forks: data.forks_count || 0,
150
+ url: data.html_url || '',
151
+ };
152
+ }
153
+ }
154
+
155
+ export function parseGitHubUrl(url: string): { owner: string; repo: string } | null {
156
+ const patterns = [
157
+ /github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/,
158
+ /github\.com\/([^/]+)\/([^/]+)/,
159
+ ];
160
+
161
+ for (const pattern of patterns) {
162
+ const match = url.match(pattern);
163
+ if (match) {
164
+ return { owner: match[1], repo: match[2] };
165
+ }
166
+ }
167
+
168
+ return null;
169
+ }