@llmindset/hf-mcp 0.1.16

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 (93) hide show
  1. package/LICENSE +21 -0
  2. package/dist/dataset-detail.d.ts +26 -0
  3. package/dist/dataset-detail.d.ts.map +1 -0
  4. package/dist/dataset-detail.js +157 -0
  5. package/dist/dataset-detail.js.map +1 -0
  6. package/dist/dataset-search.d.ts +62 -0
  7. package/dist/dataset-search.d.ts.map +1 -0
  8. package/dist/dataset-search.js +158 -0
  9. package/dist/dataset-search.js.map +1 -0
  10. package/dist/duplicate-space.d.ts +75 -0
  11. package/dist/duplicate-space.d.ts.map +1 -0
  12. package/dist/duplicate-space.js +189 -0
  13. package/dist/duplicate-space.js.map +1 -0
  14. package/dist/error-messages.d.ts +4 -0
  15. package/dist/error-messages.d.ts.map +1 -0
  16. package/dist/error-messages.js +30 -0
  17. package/dist/error-messages.js.map +1 -0
  18. package/dist/hf-api-call.d.ts +18 -0
  19. package/dist/hf-api-call.d.ts.map +1 -0
  20. package/dist/hf-api-call.js +105 -0
  21. package/dist/hf-api-call.js.map +1 -0
  22. package/dist/index.d.ts +16 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +16 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/model-detail.d.ts +26 -0
  27. package/dist/model-detail.d.ts.map +1 -0
  28. package/dist/model-detail.js +224 -0
  29. package/dist/model-detail.js.map +1 -0
  30. package/dist/model-search.d.ts +64 -0
  31. package/dist/model-search.d.ts.map +1 -0
  32. package/dist/model-search.js +161 -0
  33. package/dist/model-search.js.map +1 -0
  34. package/dist/paper-search.d.ts +58 -0
  35. package/dist/paper-search.d.ts.map +1 -0
  36. package/dist/paper-search.js +114 -0
  37. package/dist/paper-search.js.map +1 -0
  38. package/dist/paper-summary.d.ts +35 -0
  39. package/dist/paper-summary.d.ts.map +1 -0
  40. package/dist/paper-summary.js +187 -0
  41. package/dist/paper-summary.js.map +1 -0
  42. package/dist/space-files.d.ts +44 -0
  43. package/dist/space-files.d.ts.map +1 -0
  44. package/dist/space-files.js +242 -0
  45. package/dist/space-files.js.map +1 -0
  46. package/dist/space-info.d.ts +56 -0
  47. package/dist/space-info.d.ts.map +1 -0
  48. package/dist/space-info.js +135 -0
  49. package/dist/space-info.js.map +1 -0
  50. package/dist/space-search.d.ts +71 -0
  51. package/dist/space-search.d.ts.map +1 -0
  52. package/dist/space-search.js +95 -0
  53. package/dist/space-search.js.map +1 -0
  54. package/dist/tool-ids.d.ts +23 -0
  55. package/dist/tool-ids.d.ts.map +1 -0
  56. package/dist/tool-ids.js +55 -0
  57. package/dist/tool-ids.js.map +1 -0
  58. package/dist/user-summary.d.ts +56 -0
  59. package/dist/user-summary.d.ts.map +1 -0
  60. package/dist/user-summary.js +271 -0
  61. package/dist/user-summary.js.map +1 -0
  62. package/dist/utilities.d.ts +8 -0
  63. package/dist/utilities.d.ts.map +1 -0
  64. package/dist/utilities.js +53 -0
  65. package/dist/utilities.js.map +1 -0
  66. package/eslint.config.js +43 -0
  67. package/package.json +47 -0
  68. package/src/dataset-detail.ts +257 -0
  69. package/src/dataset-search.ts +237 -0
  70. package/src/duplicate-space.ts +263 -0
  71. package/src/error-messages.ts +57 -0
  72. package/src/hf-api-call.ts +182 -0
  73. package/src/index.ts +18 -0
  74. package/src/model-detail.ts +359 -0
  75. package/src/model-search.ts +231 -0
  76. package/src/paper-search.ts +188 -0
  77. package/src/paper-summary.ts +303 -0
  78. package/src/space-files.ts +325 -0
  79. package/src/space-info.ts +190 -0
  80. package/src/space-search.ts +177 -0
  81. package/src/tool-ids.ts +84 -0
  82. package/src/user-summary.ts +421 -0
  83. package/src/utilities.ts +64 -0
  84. package/test/duplicate-space.spec.ts +41 -0
  85. package/test/fixtures/paper_result_kazakh.json +854 -0
  86. package/test/fixtures/space-result.json +263 -0
  87. package/test/paper-search.spec.ts +57 -0
  88. package/test/paper-summary.spec.ts +113 -0
  89. package/test/space-files.spec.ts +232 -0
  90. package/test/space-search.spec.ts +29 -0
  91. package/test/user-summary.spec.ts +131 -0
  92. package/tsconfig.json +31 -0
  93. package/vitest.config.ts +11 -0
@@ -0,0 +1,325 @@
1
+ import { z } from 'zod';
2
+ import { listFiles, spaceInfo } from '@huggingface/hub';
3
+ import { formatBytes, escapeMarkdown } from './utilities.js';
4
+ import { HfApiError } from './hf-api-call.js';
5
+ import { explain } from './error-messages.js';
6
+
7
+ // Define the FileWithUrl interface
8
+ export interface FileWithUrl {
9
+ path: string;
10
+ size: number;
11
+ type: 'file' | 'directory' | 'unknown';
12
+ url: string;
13
+ sizeFormatted: string;
14
+ lastModified?: string;
15
+ lfs: boolean;
16
+ }
17
+
18
+ // File type detection helpers
19
+ const IMAGE_EXTENSIONS = new Set([
20
+ '.jpg',
21
+ '.jpeg',
22
+ '.png',
23
+ '.gif',
24
+ '.bmp',
25
+ '.tiff',
26
+ '.tif',
27
+ '.webp',
28
+ '.svg',
29
+ '.ico',
30
+ '.heic',
31
+ '.heif',
32
+ ]);
33
+
34
+ const AUDIO_EXTENSIONS = new Set([
35
+ '.mp3',
36
+ '.wav',
37
+ '.flac',
38
+ '.aac',
39
+ '.ogg',
40
+ '.m4a',
41
+ '.wma',
42
+ '.opus',
43
+ '.aiff',
44
+ '.au',
45
+ '.ra',
46
+ ]);
47
+
48
+ function getFileExtension(path: string): string {
49
+ const lastDot = path.lastIndexOf('.');
50
+ return lastDot === -1 ? '' : path.substring(lastDot).toLowerCase();
51
+ }
52
+
53
+ function isImageFile(path: string): boolean {
54
+ return IMAGE_EXTENSIONS.has(getFileExtension(path));
55
+ }
56
+
57
+ function isAudioFile(path: string): boolean {
58
+ return AUDIO_EXTENSIONS.has(getFileExtension(path));
59
+ }
60
+
61
+ function matchesFileType(file: FileWithUrl, fileType: 'all' | 'image' | 'audio'): boolean {
62
+ switch (fileType) {
63
+ case 'all':
64
+ return true;
65
+ case 'image':
66
+ return isImageFile(file.path);
67
+ case 'audio':
68
+ return isAudioFile(file.path);
69
+ default:
70
+ return true;
71
+ }
72
+ }
73
+
74
+ // Tool configuration
75
+ export const SPACE_FILES_TOOL_CONFIG = {
76
+ name: 'space_files',
77
+ description: '', // This will be dynamically set with username
78
+ schema: z.object({
79
+ spaceName: z.string().optional().describe('Space identifier in format "username/spacename"'),
80
+ fileType: z
81
+ .enum(['all', 'image', 'audio'])
82
+ .optional()
83
+ .default('all')
84
+ .describe('Filter files by type: all (default), image, or audio files only'),
85
+ }),
86
+ annotations: {
87
+ title: 'Space Files List',
88
+ destructiveHint: false,
89
+ readOnlyHint: true,
90
+ openWorldHint: true,
91
+ },
92
+ } as const;
93
+
94
+ // Define parameter types
95
+ export type SpaceFilesParams = z.infer<typeof SPACE_FILES_TOOL_CONFIG.schema>;
96
+
97
+ /**
98
+ * Service for listing files in Hugging Face Spaces
99
+ */
100
+ export class SpaceFilesTool {
101
+ private readonly accessToken?: string;
102
+ private readonly username?: string;
103
+
104
+ constructor(hfToken?: string, username?: string) {
105
+ this.accessToken = hfToken;
106
+ this.username = username;
107
+ }
108
+
109
+ static createToolConfig(username?: string): typeof SPACE_FILES_TOOL_CONFIG {
110
+ const description = username
111
+ ? `Defaults to ${username}/filedrop. List files in a static Hugging Face Space. Use the URL for specifying Files inputs to Gradio endpoints or downloading files`
112
+ : `List all files in a static Hugging Face Space. Use the URL for specifying Files inputs to Gradio endpoints or downloading files.`;
113
+ return {
114
+ ...SPACE_FILES_TOOL_CONFIG,
115
+ description: description as '',
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Get all files in a space with their URLs
121
+ */
122
+ async getSpaceFilesWithUrls(spaceName: string): Promise<FileWithUrl[]> {
123
+ try {
124
+ // Get space info to determine subdomain
125
+ const space = await spaceInfo({
126
+ name: spaceName,
127
+ additionalFields: ['subdomain'],
128
+ ...(this.accessToken && { accessToken: this.accessToken }),
129
+ });
130
+
131
+ // Check if it's a static space
132
+ if (space.sdk !== 'static') {
133
+ throw new Error(
134
+ `Space "${spaceName}" is not a static space (found: ${space.sdk}). This tool only works with static spaces.`
135
+ );
136
+ }
137
+
138
+ const files: FileWithUrl[] = [];
139
+
140
+ // List all files recursively
141
+ for await (const file of listFiles({
142
+ repo: { type: 'space', name: spaceName },
143
+ recursive: true,
144
+ expand: true, // Get last commit info
145
+ ...(this.accessToken && { credentials: { accessToken: this.accessToken } }),
146
+ })) {
147
+ if (file.type === 'file') {
148
+ files.push({
149
+ path: file.path,
150
+ size: file.size,
151
+ type: file.type,
152
+ url: this.constructFileUrl(spaceName, file.path),
153
+ sizeFormatted: formatBytes(file.size),
154
+ lastModified: file.lastCommit?.date,
155
+ lfs: !!file.lfs,
156
+ });
157
+ }
158
+ }
159
+
160
+ return files.sort((a, b) => a.path.localeCompare(b.path));
161
+ } catch (error) {
162
+ if (error instanceof HfApiError) {
163
+ throw explain(error, `Failed to list files for space "${spaceName}"`);
164
+ }
165
+ throw error;
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Construct the URL for a file
171
+ */
172
+ private constructFileUrl(spaceName: string, filePath: string): string {
173
+ return `https://huggingface.co/spaces/${spaceName}/resolve/main/${filePath}`;
174
+ }
175
+
176
+ /**
177
+ * Generate detailed markdown report with files grouped by directory
178
+ */
179
+ async generateDetailedMarkdown(spaceName: string, fileType: 'all' | 'image' | 'audio' = 'all'): Promise<string> {
180
+ const allFiles = await this.getSpaceFilesWithUrls(spaceName);
181
+ const files = allFiles.filter((file) => matchesFileType(file, fileType));
182
+
183
+ let markdown = `# Files in Space: ${spaceName}\n\n`;
184
+ if (fileType !== 'all') {
185
+ markdown += `**Filter**: ${fileType} files only\n`;
186
+ }
187
+ markdown += `**Total Files**: ${files.length}\n`;
188
+ markdown += `**Total Size**: ${formatBytes(files.reduce((sum, f) => sum + f.size, 0))}\n\n`;
189
+
190
+ // Handle empty results
191
+ if (files.length === 0) {
192
+ if (fileType !== 'all') {
193
+ markdown += `No ${fileType} files found in this space.\n`;
194
+ } else {
195
+ markdown += `No files found in this space.\n`;
196
+ }
197
+ return markdown;
198
+ }
199
+
200
+ // Group files by directory
201
+ const byDirectory = files.reduce(
202
+ (acc, file) => {
203
+ const dir = file.path.includes('/') ? file.path.substring(0, file.path.lastIndexOf('/')) : '/';
204
+ if (!acc[dir]) acc[dir] = [];
205
+ acc[dir].push(file);
206
+ return acc;
207
+ },
208
+ {} as Record<string, FileWithUrl[]>
209
+ );
210
+
211
+ // Generate table
212
+ markdown += `## All Files\n\n`;
213
+ markdown += `| File Path | Size | Type | Last Modified | URL |\n`;
214
+ markdown += `|-----------|------|------|---------------|-----|\n`;
215
+
216
+ // Sort directories and output files
217
+ const sortedDirs = Object.keys(byDirectory).sort();
218
+ for (const dir of sortedDirs) {
219
+ const dirFiles = byDirectory[dir];
220
+ if (!dirFiles) continue;
221
+
222
+ if (dir !== '/' && dirFiles.length > 0) {
223
+ markdown += `| **📁 ${escapeMarkdown(dir)}/** | | | | |\n`;
224
+ }
225
+
226
+ for (const file of dirFiles) {
227
+ const fileName = file.path.split('/').pop() || file.path;
228
+ const indent = dir === '/' ? '' : '&nbsp;&nbsp;&nbsp;&nbsp;';
229
+ const icon = this.getFileIcon(fileName);
230
+ const lastMod = file.lastModified ? new Date(file.lastModified).toLocaleDateString() : '-';
231
+
232
+ markdown += `| ${indent}${icon} ${escapeMarkdown(fileName)} | ${file.sizeFormatted} | ${file.lfs ? 'LFS' : 'Regular'} | ${lastMod} | ${file.url} |\n`;
233
+ }
234
+ }
235
+
236
+ // Add direct access examples
237
+ markdown += `\n## Direct Access Examples\n\n`;
238
+ markdown += `\`\`\`bash\n`;
239
+
240
+ // Show a few example URLs
241
+ const examples = files.slice(0, 2);
242
+ for (const file of examples) {
243
+ markdown += `# Download ${file.path}\n`;
244
+ markdown += `curl -L -O ${file.url}\n\n`;
245
+ }
246
+ markdown += `\`\`\`\n`;
247
+ markdown += '## Use the URL when specifying Files inputs for Gradio endpoints.\n\n';
248
+ markdown += 'This space is accessible via `git` with `git clone https://huggingface.co/spaces/' + spaceName + '`\n';
249
+ return markdown;
250
+ }
251
+
252
+ /**
253
+ * Generate simple markdown table without grouping
254
+ */
255
+ async generateSimpleMarkdown(spaceName: string, fileType: 'all' | 'image' | 'audio' = 'all'): Promise<string> {
256
+ const allFiles = await this.getSpaceFilesWithUrls(spaceName);
257
+ const files = allFiles.filter((file) => matchesFileType(file, fileType));
258
+
259
+ let markdown = `# Files in ${spaceName}\n\n`;
260
+ if (fileType !== 'all') {
261
+ markdown += `**Filter**: ${fileType} files only\n\n`;
262
+ }
263
+ markdown += `| File Name | Path | Size | URL |\n`;
264
+ markdown += `|-----------|------|------|-----|\n`;
265
+
266
+ for (const file of files) {
267
+ const fileName = file.path.split('/').pop() || file.path;
268
+ const icon = this.getFileIcon(fileName);
269
+ markdown += `| ${icon} ${escapeMarkdown(fileName)} | ${escapeMarkdown(file.path)} | ${file.sizeFormatted} | [Link](${file.url}) |\n`;
270
+ }
271
+
272
+ return markdown;
273
+ }
274
+
275
+ /**
276
+ * List files with the specified format
277
+ */
278
+ async listFiles(params: SpaceFilesParams): Promise<string> {
279
+ const { fileType = 'all' } = params;
280
+
281
+ // Use provided spaceName or default to username/filedrop
282
+ const spaceName = params.spaceName || (this.username ? `${this.username}/filedrop` : 'filedrop');
283
+
284
+ return this.generateDetailedMarkdown(spaceName, fileType);
285
+ }
286
+
287
+ /**
288
+ * Get file icon based on extension
289
+ */
290
+ private getFileIcon(filename: string): string {
291
+ const ext = filename.split('.').pop()?.toLowerCase();
292
+ const iconMap: Record<string, string> = {
293
+ py: '🐍',
294
+ js: '📜',
295
+ ts: '📘',
296
+ md: '📝',
297
+ txt: '📄',
298
+ json: '📊',
299
+ yaml: '⚙️',
300
+ yml: '⚙️',
301
+ png: '🖼️',
302
+ jpg: '🖼️',
303
+ jpeg: '🖼️',
304
+ gif: '🖼️',
305
+ svg: '🎨',
306
+ mp4: '🎬',
307
+ mp3: '🎵',
308
+ pdf: '📕',
309
+ zip: '📦',
310
+ tar: '📦',
311
+ gz: '📦',
312
+ html: '🌐',
313
+ css: '🎨',
314
+ ipynb: '📓',
315
+ csv: '📊',
316
+ parquet: '🗄️',
317
+ safetensors: '🤖',
318
+ bin: '💾',
319
+ pkl: '🥒',
320
+ h5: '🗃️',
321
+ };
322
+
323
+ return iconMap[ext || ''] || '📄';
324
+ }
325
+ }
@@ -0,0 +1,190 @@
1
+ import { z } from 'zod';
2
+ import { HfApiCall } from './hf-api-call.js';
3
+ import { listSpaces } from '@huggingface/hub';
4
+ import type { SpaceEntry, SpaceRuntime, SpaceSdk } from '@huggingface/hub';
5
+ import { formatDate, formatNumber, escapeMarkdown, NO_TOKEN_INSTRUCTIONS } from './utilities.js';
6
+
7
+ interface SpaceReport {
8
+ user: string;
9
+ generatedAt: Date;
10
+ totalSpaces: number;
11
+ publicSpaces: number;
12
+ privateSpaces: number;
13
+ runningSpaces: number;
14
+ totalLikes: number;
15
+ sdkCounts: Record<string, number>;
16
+ spaces: SpaceDetails[];
17
+ }
18
+
19
+ interface SpaceDetails {
20
+ name: string;
21
+ url: string;
22
+ sdk: SpaceSdk | 'unknown'; // Allow 'unknown' in our internal type
23
+ status: SpaceRuntime['stage'] | 'UNKNOWN';
24
+ statusEmoji: string;
25
+ hardware: string;
26
+ storage: string;
27
+ visibility: string;
28
+ likes: number;
29
+ lastModified: string;
30
+ }
31
+
32
+ const STATUS_EMOJI: Record<string, string> = {
33
+ RUNNING: '🟢 Running',
34
+ RUNNING_BUILDING: '🔄 Building',
35
+ SLEEPING: '😴 Sleeping',
36
+ PAUSED: '⏸️ Paused',
37
+ STOPPED: '⏹️ Stopped',
38
+ RUNTIME_ERROR: '❌ Error',
39
+ BUILD_ERROR: '🚫 Build Error',
40
+ BUILDING: '🔨 Building',
41
+ NO_APP_FILE: '📄 No App',
42
+ CONFIG_ERROR: '⚙️ Config Error',
43
+ DELETING: '🗑️ Deleting',
44
+ };
45
+
46
+ export interface SpaceInfoParams {
47
+ username?: string;
48
+ }
49
+
50
+ export const SPACE_INFO_TOOL_CONFIG = {
51
+ name: 'space_info',
52
+ description: '', // This will be dynamically set based on auth
53
+ schema: z.object({
54
+ username: z.string().optional().describe('Username to get spaces for (defaults to authenticated user)'),
55
+ }),
56
+ annotations: {
57
+ title: 'Hugging Face Spaces Information',
58
+ destructiveHint: false,
59
+ readOnlyHint: true,
60
+ openWorldHint: false,
61
+ },
62
+ } as const;
63
+
64
+ export class SpaceInfoTool extends HfApiCall<SpaceInfoParams, SpaceReport> {
65
+ private authenticatedUsername?: string;
66
+
67
+ constructor(hfToken?: string, authenticatedUsername?: string) {
68
+ super('https://huggingface.co/api', hfToken);
69
+ this.authenticatedUsername = authenticatedUsername;
70
+ }
71
+
72
+ static createToolConfig(username?: string): typeof SPACE_INFO_TOOL_CONFIG {
73
+ const description = username
74
+ ? `Tabluate Hugging Face Spaces information for ${username}, or another User.`
75
+ : `Tabluate public Hugging Face Spaces for a specific User. Supply a Hugging Face token or go to https://hf.co/join to create an account to view your own private Spaces.`;
76
+ return {
77
+ ...SPACE_INFO_TOOL_CONFIG,
78
+ description: description as '',
79
+ };
80
+ }
81
+
82
+ async getSpacesReport(targetUsername?: string): Promise<string> {
83
+ // TODO -- think a bit more about this exact condition
84
+ if (!this.authenticatedUsername && !targetUsername) throw new Error(NO_TOKEN_INSTRUCTIONS);
85
+
86
+ const username = targetUsername || this.authenticatedUsername || 'error';
87
+ const spaces: SpaceEntry[] = [];
88
+ const spacesWithRuntime: SpaceDetails[] = [];
89
+
90
+ try {
91
+ // Fetch all spaces for the user with runtime info in a single request
92
+ for await (const space of listSpaces({
93
+ search: { owner: username },
94
+ additionalFields: ['runtime', 'subdomain'] as const,
95
+ accessToken: this.hfToken,
96
+ })) {
97
+ spaces.push(space);
98
+
99
+ // Use runtime data directly from listSpaces response
100
+ const runtime = space.runtime;
101
+ const hardware = runtime?.hardware?.current || 'cpu-basic';
102
+
103
+ spacesWithRuntime.push({
104
+ name: space.name.split('/')[1] || space.name,
105
+ url: `https://huggingface.co/spaces/${space.name}`,
106
+ sdk: space.sdk || 'unknown',
107
+ status: runtime?.stage || 'UNKNOWN',
108
+ statusEmoji: STATUS_EMOJI[runtime?.stage || 'STOPPED'] || '❓ Unknown',
109
+ hardware: hardware,
110
+ storage: runtime?.resources?.requests?.ephemeral || 'None',
111
+ visibility: space.private ? '🔒 Private' : '🌍 Public',
112
+ likes: space.likes,
113
+ lastModified: space.updatedAt ? formatDate(space.updatedAt) : 'Unknown',
114
+ });
115
+ }
116
+
117
+ // Calculate statistics
118
+ const report: SpaceReport = {
119
+ user: username,
120
+ generatedAt: new Date(),
121
+ totalSpaces: spaces.length,
122
+ publicSpaces: spaces.filter((s) => !s.private).length,
123
+ privateSpaces: spaces.filter((s) => s.private).length,
124
+ runningSpaces: spacesWithRuntime.filter((s) => s.status === 'RUNNING').length,
125
+ totalLikes: spaces.reduce((sum, s) => sum + s.likes, 0),
126
+ sdkCounts: spaces.reduce(
127
+ (acc, s) => {
128
+ const sdk = s.sdk || 'unknown';
129
+ acc[sdk] = (acc[sdk] || 0) + 1;
130
+ return acc;
131
+ },
132
+ {} as Record<string, number>
133
+ ),
134
+ spaces: spacesWithRuntime.sort((a, b) => b.lastModified.localeCompare(a.lastModified)),
135
+ };
136
+
137
+ // Generate markdown report
138
+ return this.generateMarkdown(report);
139
+ } catch (error) {
140
+ if (error instanceof Error) {
141
+ throw new Error(`Failed to get spaces information: ${error.message}`);
142
+ }
143
+ throw error;
144
+ }
145
+ }
146
+
147
+ private generateMarkdown(report: SpaceReport): string {
148
+ // Handle case where user has no spaces
149
+ if (report.totalSpaces === 0) {
150
+ return `# Hugging Face Spaces Report
151
+ **User**: ${report.user}
152
+ **Generated**: ${formatDate(report.generatedAt)}
153
+
154
+ No spaces were found for user '${report.user}'.
155
+
156
+ You can create your first Space at [https://huggingface.co/new-space](https://huggingface.co/new-space).`;
157
+ }
158
+
159
+ const sdkSummary = Object.entries(report.sdkCounts)
160
+ .filter(([_, count]) => count > 0)
161
+ .map(([sdk, count]) => `${sdk} (${count})`)
162
+ .join(', ');
163
+
164
+ let markdown = `# Hugging Face Spaces Report
165
+ **User**: ${report.user}
166
+ **Generated**: ${formatDate(report.generatedAt)}
167
+
168
+ ## Summary
169
+ - **Total Spaces**: ${report.totalSpaces}
170
+ - **Public**: ${report.publicSpaces}${report.privateSpaces > 0 ? ` | **Private**: ${report.privateSpaces}` : ''}
171
+ - **Currently Running**: ${report.runningSpaces}
172
+ - **Total Likes**: ${formatNumber(report.totalLikes)}
173
+ - **SDKs**: ${sdkSummary || 'None'}
174
+
175
+ ## All Spaces
176
+ | Space | SDK | Status | Hardware | Storage | Visibility | Likes ❤️ | Last Modified |
177
+ |-------|-----|--------|----------|---------|------------|----------|--------------|\n`;
178
+
179
+ // Add each space as a table row
180
+ for (const space of report.spaces) {
181
+ markdown += `| [${escapeMarkdown(space.name)}](${space.url}) | ${escapeMarkdown(space.sdk)} | ${space.statusEmoji} | ${escapeMarkdown(space.hardware)} | ${escapeMarkdown(space.storage)} | ${space.visibility} | ${space.likes} | ${space.lastModified} |\n`;
182
+ }
183
+
184
+ return markdown;
185
+ }
186
+ }
187
+
188
+ export const formatSpaceInfoResult = async (tool: SpaceInfoTool, params: SpaceInfoParams): Promise<string> => {
189
+ return tool.getSpacesReport(params.username);
190
+ };
@@ -0,0 +1,177 @@
1
+ import { z } from 'zod';
2
+ import { HfApiCall } from './hf-api-call.js';
3
+ import { escapeMarkdown } from './utilities.js';
4
+
5
+ // Define the SearchResult interface
6
+ export interface SpaceSearchResult {
7
+ id: string;
8
+ emoji?: string; // Emoji for the space
9
+ likes?: number;
10
+ title?: string;
11
+ author: string;
12
+ runtime: {
13
+ stage?: string; // always seems to be "RUNNING"
14
+ };
15
+ ai_category?: string;
16
+ ai_short_description?: string;
17
+ shortDescription?: string;
18
+ semanticRelevancyScore?: number; // Score from semantic search API
19
+ trendingScore?: number;
20
+ lastModified?: Date;
21
+ }
22
+
23
+ // Define input types for space search
24
+ interface SpaceSearchParams {
25
+ q: string;
26
+ sdk: string;
27
+ filter?: string;
28
+ }
29
+
30
+ // Default number of results to return
31
+ const RESULTS_TO_RETURN = 10;
32
+
33
+ export const SEMANTIC_SEARCH_TOOL_CONFIG = {
34
+ name: 'space_search',
35
+ description:
36
+ 'Find Hugging Face Spaces using semantic search. ' + 'Include links to the Space when presenting the results.',
37
+ schema: z.object({
38
+ query: z.string().min(1, 'Query is required').max(100, 'Query too long').describe('Semantic Search Query'),
39
+ limit: z.number().optional().default(RESULTS_TO_RETURN).describe('Number of results to return'),
40
+ mcp: z.boolean().optional().default(false).describe('Only return MCP Server enabled Spaces'),
41
+ }),
42
+ annotations: {
43
+ title: 'Hugging Face Space Search',
44
+ destructiveHint: false,
45
+ readOnlyHint: true,
46
+ openWorldHint: true,
47
+ },
48
+ } as const;
49
+
50
+ /**
51
+ * Service for searching Hugging Face Spaces semantically
52
+ */
53
+ export class SpaceSearchTool extends HfApiCall<SpaceSearchParams, SpaceSearchResult[]> {
54
+ /**
55
+ * Creates a new semantic search service
56
+ * @param apiUrl The URL of the Hugging Face semantic search API
57
+ * @param hfToken Optional Hugging Face token for API access
58
+ */
59
+ constructor(hfToken?: string, apiUrl = 'https://huggingface.co/api/spaces/semantic-search') {
60
+ super(apiUrl, hfToken);
61
+ }
62
+
63
+ /**
64
+ * Performs a semantic search on Hugging Face Spaces
65
+ * @param query The search query
66
+ * @param limit Maximum number of results to return
67
+ * @returns An array of search results
68
+ */
69
+ async search(
70
+ query: string,
71
+ limit: number = RESULTS_TO_RETURN,
72
+ mcp: boolean = false
73
+ ): Promise<{ results: SpaceSearchResult[]; totalCount: number }> {
74
+ try {
75
+ // Validate input before making API call
76
+ if (!query) {
77
+ return { results: [], totalCount: 0 };
78
+ }
79
+
80
+ // Prepare API parameters, adding the filter if mcp is true
81
+ const params: SpaceSearchParams = { q: query, sdk: 'gradio' };
82
+
83
+ if (mcp) {
84
+ params.filter = 'mcp-server';
85
+ }
86
+
87
+ const results = await this.callApi<SpaceSearchResult[]>(params);
88
+
89
+ return { results: results.slice(0, limit), totalCount: results.length };
90
+ } catch (error) {
91
+ if (error instanceof Error) {
92
+ throw new Error(`Failed to search for spaces: ${error.message}`);
93
+ }
94
+ throw error;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Search for spaces with a specific filter (e.g., arxiv:XXXX.XXXXX)
100
+ * Note: For spaces, we need to use the regular API endpoint with filter parameter
101
+ */
102
+ async searchWithFilter(filter: string, limit: number = 10, headerLevel: number = 1): Promise<string> {
103
+ try {
104
+ // For spaces, we need to use the regular spaces API endpoint with filter
105
+ const url = new URL('https://huggingface.co/api/spaces');
106
+ url.searchParams.append('filter', filter);
107
+ url.searchParams.append('limit', limit.toString());
108
+ url.searchParams.append('sort', 'likes');
109
+ url.searchParams.append('direction', '-1');
110
+
111
+ const results = await this.fetchFromApi<SpaceSearchResult[]>(url);
112
+
113
+ if (results.length === 0) {
114
+ return `No matching Hugging Face Spaces found referencing ${filter}.`;
115
+ }
116
+
117
+ // Format results using the existing formatter
118
+ return formatSearchResults(filter, results, results.length, headerLevel);
119
+ } catch (error) {
120
+ if (error instanceof Error) {
121
+ throw new Error(`Failed to search for spaces: ${error.message}`);
122
+ }
123
+ throw error;
124
+ }
125
+ }
126
+ }
127
+
128
+ // Create a schema validator for search parameters
129
+ export const SearchParamsSchema = SEMANTIC_SEARCH_TOOL_CONFIG.schema;
130
+
131
+ export type SearchParams = z.infer<typeof SEMANTIC_SEARCH_TOOL_CONFIG.schema>;
132
+
133
+ /**
134
+ * Formats search results as a markdown table for MCP friendly output
135
+ * @param results The search results to format
136
+ * @returns A markdown formatted string with the search results
137
+ */
138
+ export const formatSearchResults = (
139
+ query: string,
140
+ results: SpaceSearchResult[],
141
+ totalCount: number,
142
+ headerLevel: number = 1
143
+ ): string => {
144
+ if (results.length === 0) {
145
+ return `No matching Hugging Face Spaces found for the query '${query}'. Try a different query.`;
146
+ }
147
+
148
+ const showingText =
149
+ results.length < totalCount
150
+ ? `Showing ${results.length.toString()} of ${totalCount.toString()} results`
151
+ : `All ${results.length.toString()} results`;
152
+ const headerPrefix = '#'.repeat(headerLevel);
153
+ let markdown = `${headerPrefix} Space Search Results for the query '${query}' (${showingText})\n\n`;
154
+ markdown += '| Space | Description | Author | ID | Category | Likes | Trending Score | Relevance |\n';
155
+ markdown += '|-------|-------------|--------|----|----------|--------|----------------|-----------|\n';
156
+
157
+ for (const result of results) {
158
+ const title = result.title || 'Untitled';
159
+ const description = result.shortDescription || result.ai_short_description || 'No description';
160
+ const author = result.author || 'Unknown';
161
+ const id = result.id || '';
162
+ const emoji = result.emoji ? escapeMarkdown(result.emoji) + ' ' : '';
163
+ const relevance = result.semanticRelevancyScore ? (result.semanticRelevancyScore * 100).toFixed(1) + '%' : 'N/A';
164
+
165
+ markdown +=
166
+ `| ${emoji}[${escapeMarkdown(title)}](https://hf.co/spaces/${id}) ` +
167
+ `| ${escapeMarkdown(description)} ` +
168
+ `| ${escapeMarkdown(author)} ` +
169
+ `| \`${escapeMarkdown(id)}\` ` +
170
+ `| \`${escapeMarkdown(result.ai_category ?? '-')}\` ` +
171
+ `| ${escapeMarkdown(result.likes?.toString() ?? '-')} ` +
172
+ `| ${escapeMarkdown(result.trendingScore?.toString() ?? '-')} ` +
173
+ `| ${relevance} |\n`;
174
+ }
175
+
176
+ return markdown;
177
+ };