@llmindset/hf-mcp 0.3.2 → 0.3.4

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 (137) hide show
  1. package/dist/docs-search/doc-fetch.d.ts +1 -0
  2. package/dist/docs-search/doc-fetch.d.ts.map +1 -1
  3. package/dist/docs-search/doc-fetch.js +9 -12
  4. package/dist/docs-search/doc-fetch.js.map +1 -1
  5. package/dist/docs-search/doc-fetch.test.js +56 -11
  6. package/dist/docs-search/doc-fetch.test.js.map +1 -1
  7. package/dist/file-icons.d.ts +3 -0
  8. package/dist/file-icons.d.ts.map +1 -0
  9. package/dist/file-icons.js +38 -0
  10. package/dist/file-icons.js.map +1 -0
  11. package/dist/gradio-files.d.ts +0 -1
  12. package/dist/gradio-files.d.ts.map +1 -1
  13. package/dist/gradio-files.js +2 -35
  14. package/dist/gradio-files.js.map +1 -1
  15. package/dist/hf-api-call.d.ts.map +1 -1
  16. package/dist/hf-api-call.js +7 -7
  17. package/dist/hf-api-call.js.map +1 -1
  18. package/dist/index.browser.d.ts +48 -0
  19. package/dist/index.browser.d.ts.map +1 -0
  20. package/dist/index.browser.js +153 -0
  21. package/dist/index.browser.js.map +1 -0
  22. package/dist/index.d.ts +1 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +1 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/jobs/commands/uv-utils.d.ts +0 -3
  27. package/dist/jobs/commands/uv-utils.d.ts.map +1 -1
  28. package/dist/jobs/commands/uv-utils.js +2 -2
  29. package/dist/jobs/commands/uv-utils.js.map +1 -1
  30. package/dist/jobs/jobs-tool.d.ts.map +1 -1
  31. package/dist/jobs/jobs-tool.js +11 -12
  32. package/dist/jobs/jobs-tool.js.map +1 -1
  33. package/dist/jobs/schema-help.d.ts +2 -9
  34. package/dist/jobs/schema-help.d.ts.map +1 -1
  35. package/dist/jobs/schema-help.js +3 -3
  36. package/dist/jobs/schema-help.js.map +1 -1
  37. package/dist/jobs/sse-handler.d.ts +3 -2
  38. package/dist/jobs/sse-handler.d.ts.map +1 -1
  39. package/dist/jobs/sse-handler.js +8 -4
  40. package/dist/jobs/sse-handler.js.map +1 -1
  41. package/dist/jobs/types.d.ts +1 -1
  42. package/dist/logger.d.ts +2 -2
  43. package/dist/logger.d.ts.map +1 -1
  44. package/dist/network/fetch-profile.d.ts +24 -0
  45. package/dist/network/fetch-profile.d.ts.map +1 -0
  46. package/dist/network/fetch-profile.js +80 -0
  47. package/dist/network/fetch-profile.js.map +1 -0
  48. package/dist/network/index.d.ts +5 -0
  49. package/dist/network/index.d.ts.map +1 -0
  50. package/dist/network/index.js +5 -0
  51. package/dist/network/index.js.map +1 -0
  52. package/dist/network/ip-policy.d.ts +6 -0
  53. package/dist/network/ip-policy.d.ts.map +1 -0
  54. package/dist/network/ip-policy.js +202 -0
  55. package/dist/network/ip-policy.js.map +1 -0
  56. package/dist/network/ip-policy.test.d.ts +2 -0
  57. package/dist/network/ip-policy.test.d.ts.map +1 -0
  58. package/dist/network/ip-policy.test.js +46 -0
  59. package/dist/network/ip-policy.test.js.map +1 -0
  60. package/dist/network/safe-fetch.d.ts +16 -0
  61. package/dist/network/safe-fetch.d.ts.map +1 -0
  62. package/dist/network/safe-fetch.js +124 -0
  63. package/dist/network/safe-fetch.js.map +1 -0
  64. package/dist/network/safe-fetch.test.d.ts +2 -0
  65. package/dist/network/safe-fetch.test.d.ts.map +1 -0
  66. package/dist/network/safe-fetch.test.js +136 -0
  67. package/dist/network/safe-fetch.test.js.map +1 -0
  68. package/dist/network/url-policy.d.ts +32 -0
  69. package/dist/network/url-policy.d.ts.map +1 -0
  70. package/dist/network/url-policy.js +230 -0
  71. package/dist/network/url-policy.js.map +1 -0
  72. package/dist/network/url-policy.test.d.ts +2 -0
  73. package/dist/network/url-policy.test.d.ts.map +1 -0
  74. package/dist/network/url-policy.test.js +57 -0
  75. package/dist/network/url-policy.test.js.map +1 -0
  76. package/dist/readme-utils.d.ts.map +1 -1
  77. package/dist/readme-utils.js +3 -4
  78. package/dist/readme-utils.js.map +1 -1
  79. package/dist/space/commands/discover.d.ts +0 -5
  80. package/dist/space/commands/discover.d.ts.map +1 -1
  81. package/dist/space/commands/discover.js +9 -2
  82. package/dist/space/commands/discover.js.map +1 -1
  83. package/dist/space/commands/invoke.js +1 -59
  84. package/dist/space/commands/invoke.js.map +1 -1
  85. package/dist/space/commands/view-parameters.d.ts.map +1 -1
  86. package/dist/space/commands/view-parameters.js +3 -98
  87. package/dist/space/commands/view-parameters.js.map +1 -1
  88. package/dist/space/dynamic-space-tool.d.ts.map +1 -1
  89. package/dist/space/dynamic-space-tool.js +5 -2
  90. package/dist/space/dynamic-space-tool.js.map +1 -1
  91. package/dist/space/utils/gradio-caller.d.ts.map +1 -1
  92. package/dist/space/utils/gradio-caller.js +13 -6
  93. package/dist/space/utils/gradio-caller.js.map +1 -1
  94. package/dist/space/utils/space-http.d.ts +8 -0
  95. package/dist/space/utils/space-http.d.ts.map +1 -0
  96. package/dist/space/utils/space-http.js +49 -0
  97. package/dist/space/utils/space-http.js.map +1 -0
  98. package/dist/space-files.d.ts +0 -1
  99. package/dist/space-files.d.ts.map +1 -1
  100. package/dist/space-files.js +3 -36
  101. package/dist/space-files.js.map +1 -1
  102. package/package.json +6 -2
  103. package/src/docs-search/doc-fetch.test.ts +98 -28
  104. package/src/docs-search/doc-fetch.ts +9 -16
  105. package/src/file-icons.ts +39 -0
  106. package/src/gradio-files.ts +2 -40
  107. package/src/hf-api-call.ts +8 -10
  108. package/src/index.browser.ts +183 -0
  109. package/src/index.ts +1 -0
  110. package/src/jobs/commands/uv-utils.ts +2 -2
  111. package/src/jobs/jobs-tool.ts +13 -12
  112. package/src/jobs/schema-help.ts +4 -4
  113. package/src/jobs/sse-handler.ts +12 -7
  114. package/src/logger.ts +2 -2
  115. package/src/network/fetch-profile.ts +112 -0
  116. package/src/network/index.ts +4 -0
  117. package/src/network/ip-policy.test.ts +58 -0
  118. package/src/network/ip-policy.ts +252 -0
  119. package/src/network/safe-fetch.test.ts +181 -0
  120. package/src/network/safe-fetch.ts +174 -0
  121. package/src/network/url-policy.test.ts +100 -0
  122. package/src/network/url-policy.ts +304 -0
  123. package/src/readme-utils.ts +11 -10
  124. package/src/space/commands/discover.ts +10 -2
  125. package/src/space/commands/invoke.ts +1 -88
  126. package/src/space/commands/view-parameters.ts +3 -136
  127. package/src/space/dynamic-space-tool.ts +6 -2
  128. package/src/space/utils/gradio-caller.ts +25 -12
  129. package/src/space/utils/space-http.ts +75 -0
  130. package/src/space-files.ts +3 -41
  131. package/test/fetch-guard.spec.ts +70 -0
  132. package/test/jobs/sse-handler.spec.ts +60 -0
  133. package/dist/space/utils/result-formatter.d.ts +0 -4
  134. package/dist/space/utils/result-formatter.d.ts.map +0 -1
  135. package/dist/space/utils/result-formatter.js +0 -146
  136. package/dist/space/utils/result-formatter.js.map +0 -1
  137. package/src/space/utils/result-formatter.ts +0 -226
@@ -19,13 +19,13 @@ const createMockResponse = ({
19
19
  });
20
20
 
21
21
  const stubFetch = (factory: () => Response) => {
22
- const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(factory()));
22
+ const fetchMock = vi.fn<typeof fetch>().mockImplementation(() => Promise.resolve(factory()));
23
23
  vi.stubGlobal('fetch', fetchMock);
24
24
  return fetchMock;
25
25
  };
26
26
 
27
27
  describe('DocFetchTool', () => {
28
- const tool = new DocFetchTool();
28
+ const tool = new DocFetchTool();
29
29
 
30
30
  afterEach(() => {
31
31
  vi.clearAllMocks();
@@ -72,27 +72,28 @@ describe('DocFetchTool', () => {
72
72
  createMockResponse({
73
73
  content: markdown,
74
74
  contentType: 'text/markdown',
75
- }),
75
+ })
76
76
  );
77
77
 
78
78
  const result = await tool.fetch({ doc_url: 'https://huggingface.co/docs/test' });
79
- expect(fetchMock).toHaveBeenCalledWith('https://huggingface.co/docs/test', {
80
- headers: { accept: 'text/markdown' },
81
- });
79
+ expect(fetchMock).toHaveBeenCalledTimes(1);
80
+ const [calledUrl, calledInit] = fetchMock.mock.calls[0] ?? [];
81
+ expect(calledUrl).toBe('https://huggingface.co/docs/test');
82
+ expect(calledInit?.redirect).toBe('manual');
83
+ expect(new Headers(calledInit?.headers).get('accept')).toBe('text/markdown');
82
84
  expect(result).toBe(markdown);
83
85
  });
84
86
 
85
87
  it('should return small documents without chunking', async () => {
86
-
87
88
  // Mock fetch to return HTML that converts to short markdown
88
89
  stubFetch(() =>
89
90
  createMockResponse({
90
91
  content: '<h1>Short Document</h1><p>This is a short document.</p>',
91
- }),
92
+ })
92
93
  );
93
94
 
94
95
  const result = await tool.fetch({ doc_url: 'https://huggingface.co/docs/test' });
95
-
96
+
96
97
  expect(result).toContain('# Short Document');
97
98
  expect(result).toContain('This is a short document');
98
99
  expect(result).not.toContain('DOCUMENT TRUNCATED');
@@ -100,16 +101,20 @@ describe('DocFetchTool', () => {
100
101
 
101
102
  it('should chunk large documents and show truncation message', async () => {
102
103
  // Mock fetch to return HTML that converts to long markdown
103
- const longHtml = '<h1>Long Document</h1>' + '<p>This is a very long sentence that will be repeated many times to create a document that exceeds the 7500 token limit for testing chunking functionality.</p>'.repeat(200);
104
-
104
+ const longHtml =
105
+ '<h1>Long Document</h1>' +
106
+ '<p>This is a very long sentence that will be repeated many times to create a document that exceeds the 7500 token limit for testing chunking functionality.</p>'.repeat(
107
+ 200
108
+ );
109
+
105
110
  stubFetch(() =>
106
111
  createMockResponse({
107
112
  content: longHtml,
108
- }),
113
+ })
109
114
  );
110
115
 
111
116
  const result = await tool.fetch({ doc_url: 'https://huggingface.co/docs/test' });
112
-
117
+
113
118
  expect(result).toContain('# Long Document');
114
119
  expect(result).toContain('DOCUMENT TRUNCATED');
115
120
  expect(result).toContain('CALL hf_doc_fetch WITH AN OFFSET OF');
@@ -133,13 +138,15 @@ describe('DocFetchTool', () => {
133
138
  const fetchMock = stubFetch(() =>
134
139
  createMockResponse({
135
140
  content: '<h1>Title</h1><p>Body</p>',
136
- }),
141
+ })
137
142
  );
138
143
 
139
144
  const result = await tool.fetch({ doc_url: '/docs/test' });
140
- expect(fetchMock).toHaveBeenCalledWith('https://huggingface.co/docs/test', {
141
- headers: { accept: 'text/markdown' },
142
- });
145
+ expect(fetchMock).toHaveBeenCalledTimes(1);
146
+ const [calledUrl, calledInit] = fetchMock.mock.calls[0] ?? [];
147
+ expect(calledUrl).toBe('https://huggingface.co/docs/test');
148
+ expect(calledInit?.redirect).toBe('manual');
149
+ expect(new Headers(calledInit?.headers).get('accept')).toBe('text/markdown');
143
150
  expect(result).toContain('# Title');
144
151
  });
145
152
 
@@ -147,28 +154,34 @@ describe('DocFetchTool', () => {
147
154
  const fetchMock = stubFetch(() =>
148
155
  createMockResponse({
149
156
  content: '<h1>Another Title</h1><p>Body</p>',
150
- }),
157
+ })
151
158
  );
152
159
 
153
160
  await tool.fetch({ doc_url: './docs/another' });
154
- expect(fetchMock).toHaveBeenCalledWith('https://huggingface.co/docs/another', {
155
- headers: { accept: 'text/markdown' },
156
- });
161
+ expect(fetchMock).toHaveBeenCalledTimes(1);
162
+ const [calledUrl, calledInit] = fetchMock.mock.calls[0] ?? [];
163
+ expect(calledUrl).toBe('https://huggingface.co/docs/another');
164
+ expect(calledInit?.redirect).toBe('manual');
165
+ expect(new Headers(calledInit?.headers).get('accept')).toBe('text/markdown');
157
166
  });
158
167
 
159
168
  it('should return subsequent chunks with offset', async () => {
160
169
  // Mock fetch to return the same long HTML
161
- const longHtml = '<h1>Long Document</h1>' + '<p>This is a very long sentence that will be repeated many times to create a document that exceeds the 7500 token limit for testing chunking functionality.</p>'.repeat(200);
162
-
170
+ const longHtml =
171
+ '<h1>Long Document</h1>' +
172
+ '<p>This is a very long sentence that will be repeated many times to create a document that exceeds the 7500 token limit for testing chunking functionality.</p>'.repeat(
173
+ 200
174
+ );
175
+
163
176
  stubFetch(() =>
164
177
  createMockResponse({
165
178
  content: longHtml,
166
- }),
179
+ })
167
180
  );
168
181
 
169
182
  // Get first chunk
170
183
  const firstChunk = await tool.fetch({ doc_url: 'https://huggingface.co/docs/test' });
171
-
184
+
172
185
  // Extract offset from truncation message
173
186
  const offsetMatch = firstChunk.match(/OFFSET OF (\d+)/);
174
187
  expect(offsetMatch).toBeTruthy();
@@ -176,7 +189,7 @@ describe('DocFetchTool', () => {
176
189
 
177
190
  // Get second chunk
178
191
  const secondChunk = await tool.fetch({ doc_url: 'https://huggingface.co/docs/test', offset });
179
-
192
+
180
193
  expect(secondChunk).not.toEqual(firstChunk);
181
194
  expect(secondChunk.length).toBeGreaterThan(0);
182
195
  });
@@ -185,12 +198,69 @@ describe('DocFetchTool', () => {
185
198
  stubFetch(() =>
186
199
  createMockResponse({
187
200
  content: '<h1>Short Document</h1><p>This is short.</p>',
188
- }),
201
+ })
189
202
  );
190
203
 
191
204
  const result = await tool.fetch({ doc_url: 'https://huggingface.co/docs/test', offset: 10000 });
192
-
205
+
193
206
  expect(result).toContain('Error: Offset 10000 is beyond');
194
207
  });
195
208
  });
209
+
210
+ describe('security hardening', () => {
211
+ it('rejects traversal payload variants', async () => {
212
+ const traversalUrls = [
213
+ 'https://huggingface.co/docs/../x',
214
+ 'https://huggingface.co/docs/%2e%2e/x',
215
+ 'https://huggingface.co/docs/%2e%2e%2fx',
216
+ 'https://huggingface.co/docs/..%2fx',
217
+ 'https://huggingface.co/docs/%2e%2e%5cx',
218
+ 'https://huggingface.co/docs/%252e%252e%252fx',
219
+ ];
220
+
221
+ const fetchMock = stubFetch(() =>
222
+ createMockResponse({
223
+ content: 'should never be fetched',
224
+ })
225
+ );
226
+
227
+ for (const docUrl of traversalUrls) {
228
+ await expect(tool.fetch({ doc_url: docUrl })).rejects.toThrow('Failed to fetch document');
229
+ }
230
+
231
+ expect(fetchMock).not.toHaveBeenCalled();
232
+ });
233
+
234
+ it('rejects redirect to non-allowlisted host', async () => {
235
+ const fetchMock = vi.fn().mockResolvedValueOnce(
236
+ new Response('', {
237
+ status: 302,
238
+ headers: { location: 'https://example.com/evil' },
239
+ })
240
+ );
241
+ vi.stubGlobal('fetch', fetchMock);
242
+
243
+ await expect(tool.fetch({ doc_url: 'https://huggingface.co/docs/transformers' })).rejects.toThrow(
244
+ 'Failed to fetch document'
245
+ );
246
+
247
+ expect(fetchMock).toHaveBeenCalledTimes(1);
248
+ });
249
+
250
+ it('rejects redirect to http downgrade', async () => {
251
+ const fetchMock = vi.fn().mockResolvedValueOnce(
252
+ new Response('', {
253
+ status: 302,
254
+ headers: { location: 'http://huggingface.co/docs/transformers' },
255
+ })
256
+ );
257
+ vi.stubGlobal('fetch', fetchMock);
258
+
259
+ await expect(tool.fetch({ doc_url: 'https://huggingface.co/docs/transformers' })).rejects.toThrow(
260
+ 'Failed to fetch document'
261
+ );
262
+
263
+ expect(fetchMock).toHaveBeenCalledTimes(1);
264
+ });
265
+ });
196
266
  });
@@ -1,6 +1,8 @@
1
1
  import { z } from 'zod';
2
2
  import TurndownService from 'turndown';
3
3
  import { estimateTokens } from '../utilities.js';
4
+ import { createHfDocsPolicy, parseAndValidateUrl } from '../network/url-policy.js';
5
+ import { fetchWithProfile, NETWORK_FETCH_PROFILES } from '../network/fetch-profile.js';
4
6
 
5
7
  export const DOC_FETCH_CONFIG = {
6
8
  name: 'hf_doc_fetch',
@@ -26,6 +28,7 @@ export type DocFetchParams = z.infer<typeof DOC_FETCH_CONFIG.schema>;
26
28
 
27
29
  export class DocFetchTool {
28
30
  private turndownService: TurndownService;
31
+ private readonly docsPolicy = createHfDocsPolicy();
29
32
 
30
33
  constructor() {
31
34
  this.turndownService = new TurndownService({
@@ -120,19 +123,7 @@ export class DocFetchTool {
120
123
  */
121
124
  validateUrl(hfUrl: string): void {
122
125
  try {
123
- const url = new URL(hfUrl);
124
- if (url.protocol !== 'https:') {
125
- throw new Error('That was not a valid documentation URL');
126
- }
127
-
128
- const hostname = url.hostname.toLowerCase();
129
- const isHfDocs =
130
- (hostname === 'huggingface.co' || hostname === 'www.huggingface.co') && url.pathname.startsWith('/docs/');
131
- const isGradio = hostname === 'gradio.app' || hostname === 'www.gradio.app';
132
-
133
- if (!isHfDocs && !isGradio) {
134
- throw new Error('That was not a valid documentation URL');
135
- }
126
+ parseAndValidateUrl(hfUrl, this.docsPolicy);
136
127
  } catch {
137
128
  throw new Error('That was not a valid documentation URL');
138
129
  }
@@ -144,9 +135,11 @@ export class DocFetchTool {
144
135
  async fetch(params: DocFetchParams): Promise<string> {
145
136
  try {
146
137
  const normalizedUrl = normalizeDocUrl(params.doc_url);
147
- this.validateUrl(normalizedUrl);
148
-
149
- const response = await fetch(normalizedUrl, { headers: { accept: 'text/markdown' } });
138
+ const { response } = await fetchWithProfile(normalizedUrl, NETWORK_FETCH_PROFILES.hfDocs(), {
139
+ requestInit: {
140
+ headers: { accept: 'text/markdown' },
141
+ },
142
+ });
150
143
  if (!response.ok) {
151
144
  throw new Error(`Failed to fetch document: ${response.status} ${response.statusText}`);
152
145
  }
@@ -0,0 +1,39 @@
1
+ export const FILE_ICON_BY_EXTENSION: Readonly<Record<string, string>> = {
2
+ py: '🐍',
3
+ js: '📜',
4
+ ts: '📘',
5
+ md: '📝',
6
+ txt: '📄',
7
+ json: '📊',
8
+ yaml: '⚙️',
9
+ yml: '⚙️',
10
+ png: '🖼️',
11
+ jpg: '🖼️',
12
+ jpeg: '🖼️',
13
+ gif: '🖼️',
14
+ svg: '🎨',
15
+ mp4: '🎬',
16
+ mp3: '🎵',
17
+ pdf: '📕',
18
+ zip: '📦',
19
+ tar: '📦',
20
+ gz: '📦',
21
+ html: '🌐',
22
+ css: '🎨',
23
+ ipynb: '📓',
24
+ csv: '📊',
25
+ parquet: '🗄️',
26
+ safetensors: '🤖',
27
+ bin: '💾',
28
+ pkl: '🥒',
29
+ h5: '🗃️',
30
+ };
31
+
32
+ export function getFileIcon(filename: string): string {
33
+ const extension = filename.split('.').pop()?.toLowerCase();
34
+ if (!extension) {
35
+ return '📄';
36
+ }
37
+
38
+ return FILE_ICON_BY_EXTENSION[extension] ?? '📄';
39
+ }
@@ -3,6 +3,7 @@ import { listFiles } from '@huggingface/hub';
3
3
  import { formatBytes, escapeMarkdown } from './utilities.js';
4
4
  import { HfApiError } from './hf-api-call.js';
5
5
  import { explain } from './error-messages.js';
6
+ import { getFileIcon } from './file-icons.js';
6
7
 
7
8
  // Define the FileWithUrl interface
8
9
  interface FileWithUrl {
@@ -207,7 +208,7 @@ export class GradioFilesTool {
207
208
 
208
209
  for (const file of files) {
209
210
  const fileName = file.path.split('/').pop() || file.path;
210
- const icon = this.getFileIcon(fileName);
211
+ const icon = getFileIcon(fileName);
211
212
  const lastMod = file.lastModified ? new Date(file.lastModified).toLocaleDateString() : '-';
212
213
 
213
214
  markdown += `| ${escapeMarkdown(fileName)} | ${file.sizeFormatted} | ${icon} ${file.type} | ${lastMod} | ${file.url} |\n`;
@@ -215,43 +216,4 @@ export class GradioFilesTool {
215
216
 
216
217
  return markdown;
217
218
  }
218
-
219
- /**
220
- * Get file icon based on extension
221
- */
222
- private getFileIcon(filename: string): string {
223
- const ext = filename.split('.').pop()?.toLowerCase();
224
- const iconMap: Record<string, string> = {
225
- py: '🐍',
226
- js: '📜',
227
- ts: '📘',
228
- md: '📝',
229
- txt: '📄',
230
- json: '📊',
231
- yaml: '⚙️',
232
- yml: '⚙️',
233
- png: '🖼️',
234
- jpg: '🖼️',
235
- jpeg: '🖼️',
236
- gif: '🖼️',
237
- svg: '🎨',
238
- mp4: '🎬',
239
- mp3: '🎵',
240
- pdf: '📕',
241
- zip: '📦',
242
- tar: '📦',
243
- gz: '📦',
244
- html: '🌐',
245
- css: '🎨',
246
- ipynb: '📓',
247
- csv: '📊',
248
- parquet: '🗄️',
249
- safetensors: '🤖',
250
- bin: '💾',
251
- pkl: '🥒',
252
- h5: '🗃️',
253
- };
254
-
255
- return iconMap[ext || ''] || '📄';
256
- }
257
219
  }
@@ -1,3 +1,5 @@
1
+ import { fetchWithProfile, NETWORK_FETCH_PROFILES } from './network/fetch-profile.js';
2
+
1
3
  /**
2
4
  * Custom error class that includes HTTP status information
3
5
  */
@@ -99,18 +101,14 @@ export class HfApiCall<TParams = Record<string, string | undefined>, TResponse =
99
101
  headers['Authorization'] = `Bearer ${this.hfToken}`;
100
102
  }
101
103
 
102
- // Add timeout using AbortController
103
- const controller = new AbortController();
104
- const timeoutId = setTimeout(() => controller.abort(), this.apiTimeout);
105
-
106
- const response = await fetch(url.toString(), {
107
- ...options,
108
- headers,
109
- signal: controller.signal,
104
+ const { response } = await fetchWithProfile(url.toString(), NETWORK_FETCH_PROFILES.externalHttps(), {
105
+ timeoutMs: this.apiTimeout,
106
+ requestInit: {
107
+ ...options,
108
+ headers,
109
+ },
110
110
  });
111
111
 
112
- clearTimeout(timeoutId);
113
-
114
112
  const responseBodyText = await response.text();
115
113
 
116
114
  if (!response.ok) {
@@ -0,0 +1,183 @@
1
+ const KNOWLEDGE_DATE = new Intl.DateTimeFormat('en-GB', {
2
+ day: 'numeric',
3
+ month: 'long',
4
+ year: 'numeric',
5
+ timeZone: 'UTC',
6
+ }).format(new Date());
7
+
8
+ interface BrowserToolConfig {
9
+ name: string;
10
+ description: string;
11
+ annotations: {
12
+ title: string;
13
+ destructiveHint: boolean;
14
+ readOnlyHint: boolean;
15
+ openWorldHint: boolean;
16
+ };
17
+ }
18
+
19
+ export const SEMANTIC_SEARCH_TOOL_CONFIG: BrowserToolConfig = {
20
+ name: 'space_search',
21
+ description:
22
+ 'Find Hugging Face Spaces using semantic search. IMPORTANT Only MCP Servers can be used with the dynamic_space tool' +
23
+ 'Include links to the Space when presenting the results.',
24
+ annotations: {
25
+ title: 'Hugging Face Space Search',
26
+ destructiveHint: false,
27
+ readOnlyHint: true,
28
+ openWorldHint: true,
29
+ },
30
+ };
31
+
32
+ export const REPO_SEARCH_TOOL_CONFIG: BrowserToolConfig = {
33
+ name: 'hub_repo_search',
34
+ description:
35
+ 'Search Hugging Face repositories with a shared query interface. ' +
36
+ 'You can target models, datasets, spaces, or aggregate across multiple repo types in one call. ' +
37
+ 'Use space_search for semantic-first discovery of Spaces. ' +
38
+ 'Include links to repositories in your response.',
39
+ annotations: {
40
+ title: 'Repo Search',
41
+ destructiveHint: false,
42
+ readOnlyHint: true,
43
+ openWorldHint: true,
44
+ },
45
+ };
46
+
47
+ export const PAPER_SEARCH_TOOL_CONFIG: BrowserToolConfig = {
48
+ name: 'paper_search',
49
+ description:
50
+ 'Find Machine Learning research papers on the Hugging Face hub. ' +
51
+ "Include 'Link to paper' When presenting the results. " +
52
+ 'Consider whether tabulating results matches user intent.',
53
+ annotations: {
54
+ title: 'Paper Search',
55
+ destructiveHint: false,
56
+ readOnlyHint: true,
57
+ openWorldHint: true,
58
+ },
59
+ };
60
+
61
+ export const HUB_REPO_DETAILS_TOOL_CONFIG: BrowserToolConfig = {
62
+ name: 'hub_repo_details',
63
+ description:
64
+ 'Get details for one or more Hugging Face repos (model, dataset, or space). ' +
65
+ 'Auto-detects type unless specified.',
66
+ annotations: {
67
+ title: 'Hub Repo Details',
68
+ destructiveHint: false,
69
+ readOnlyHint: true,
70
+ openWorldHint: false,
71
+ },
72
+ };
73
+
74
+ export const DUPLICATE_SPACE_TOOL_CONFIG: BrowserToolConfig = {
75
+ name: 'duplicate_space',
76
+ description: '',
77
+ annotations: {
78
+ title: 'Duplicate Hugging Face Space',
79
+ destructiveHint: false,
80
+ readOnlyHint: false,
81
+ openWorldHint: true,
82
+ },
83
+ };
84
+
85
+ export const SPACE_FILES_TOOL_CONFIG: BrowserToolConfig = {
86
+ name: 'space_files',
87
+ description: '',
88
+ annotations: {
89
+ title: 'Space Files List',
90
+ destructiveHint: false,
91
+ readOnlyHint: true,
92
+ openWorldHint: true,
93
+ },
94
+ };
95
+
96
+ export const DOCS_SEMANTIC_SEARCH_CONFIG: BrowserToolConfig = {
97
+ name: 'hf_doc_search',
98
+ description:
99
+ 'Search and Discover Hugging Face Product and Library documentation. Send an empty query to discover structure and navigation instructions. ' +
100
+ `Knowledge up-to-date as at ${KNOWLEDGE_DATE}. Combine with the Product filter to focus results.`,
101
+ annotations: {
102
+ title: 'Hugging Face Documentation Search',
103
+ destructiveHint: false,
104
+ readOnlyHint: true,
105
+ openWorldHint: true,
106
+ },
107
+ };
108
+
109
+ export const DOC_FETCH_CONFIG: BrowserToolConfig = {
110
+ name: 'hf_doc_fetch',
111
+ description:
112
+ 'Fetch a document from the Hugging Face or Gradio documentation library. For large documents, use offset to get subsequent chunks.',
113
+ annotations: {
114
+ title: 'Fetch a document from the Hugging Face documentation library',
115
+ destructiveHint: false,
116
+ readOnlyHint: true,
117
+ openWorldHint: true,
118
+ },
119
+ };
120
+
121
+ export const SPACE_SEARCH_TOOL_ID = SEMANTIC_SEARCH_TOOL_CONFIG.name;
122
+ export const MODEL_SEARCH_TOOL_ID = 'model_search';
123
+ export const REPO_SEARCH_TOOL_ID = REPO_SEARCH_TOOL_CONFIG.name;
124
+ export const MODEL_DETAIL_TOOL_ID = 'model_details';
125
+ export const PAPER_SEARCH_TOOL_ID = PAPER_SEARCH_TOOL_CONFIG.name;
126
+ export const DATASET_SEARCH_TOOL_ID = 'dataset_search';
127
+ export const DATASET_DETAIL_TOOL_ID = 'dataset_details';
128
+ export const HUB_REPO_DETAILS_TOOL_ID = HUB_REPO_DETAILS_TOOL_CONFIG.name;
129
+ export const DUPLICATE_SPACE_TOOL_ID = DUPLICATE_SPACE_TOOL_CONFIG.name;
130
+ export const SPACE_INFO_TOOL_ID = 'space_info';
131
+ export const SPACE_FILES_TOOL_ID = SPACE_FILES_TOOL_CONFIG.name;
132
+ export const USE_SPACE_TOOL_ID = 'use_space';
133
+ export const DOCS_SEMANTIC_SEARCH_TOOL_ID = DOCS_SEMANTIC_SEARCH_CONFIG.name;
134
+ export const DOC_FETCH_TOOL_ID = DOC_FETCH_CONFIG.name;
135
+ export const HF_JOBS_TOOL_ID = 'hf_jobs';
136
+ export const DYNAMIC_SPACE_TOOL_ID = 'dynamic_space';
137
+
138
+ export const ALL_BUILTIN_TOOL_IDS = [
139
+ SPACE_SEARCH_TOOL_ID,
140
+ MODEL_SEARCH_TOOL_ID,
141
+ REPO_SEARCH_TOOL_ID,
142
+ MODEL_DETAIL_TOOL_ID,
143
+ PAPER_SEARCH_TOOL_ID,
144
+ DATASET_SEARCH_TOOL_ID,
145
+ DATASET_DETAIL_TOOL_ID,
146
+ HUB_REPO_DETAILS_TOOL_ID,
147
+ DUPLICATE_SPACE_TOOL_ID,
148
+ SPACE_INFO_TOOL_ID,
149
+ SPACE_FILES_TOOL_ID,
150
+ DOCS_SEMANTIC_SEARCH_TOOL_ID,
151
+ DOC_FETCH_TOOL_ID,
152
+ USE_SPACE_TOOL_ID,
153
+ HF_JOBS_TOOL_ID,
154
+ DYNAMIC_SPACE_TOOL_ID,
155
+ ] as const;
156
+
157
+ export const TOOL_ID_GROUPS = {
158
+ search: [SPACE_SEARCH_TOOL_ID, REPO_SEARCH_TOOL_ID, PAPER_SEARCH_TOOL_ID, DOCS_SEMANTIC_SEARCH_TOOL_ID] as const,
159
+ spaces: [
160
+ SPACE_SEARCH_TOOL_ID,
161
+ DUPLICATE_SPACE_TOOL_ID,
162
+ SPACE_INFO_TOOL_ID,
163
+ SPACE_FILES_TOOL_ID,
164
+ USE_SPACE_TOOL_ID,
165
+ ] as const,
166
+ detail: [MODEL_DETAIL_TOOL_ID, DATASET_DETAIL_TOOL_ID, HUB_REPO_DETAILS_TOOL_ID] as const,
167
+ docs: [DOCS_SEMANTIC_SEARCH_TOOL_ID, DOC_FETCH_TOOL_ID] as const,
168
+ hf_api: [
169
+ SPACE_SEARCH_TOOL_ID,
170
+ REPO_SEARCH_TOOL_ID,
171
+ PAPER_SEARCH_TOOL_ID,
172
+ HUB_REPO_DETAILS_TOOL_ID,
173
+ DOCS_SEMANTIC_SEARCH_TOOL_ID,
174
+ ] as const,
175
+ dynamic_space: [DYNAMIC_SPACE_TOOL_ID] as const,
176
+ all: [...ALL_BUILTIN_TOOL_IDS] as const,
177
+ } as const;
178
+
179
+ export type BuiltinToolId = (typeof ALL_BUILTIN_TOOL_IDS)[number];
180
+
181
+ export function isValidBuiltinToolId(toolId: string): toolId is BuiltinToolId {
182
+ return (ALL_BUILTIN_TOOL_IDS as readonly string[]).includes(toolId);
183
+ }
package/src/index.ts CHANGED
@@ -24,6 +24,7 @@ export * from './jobs/jobs-tool.js';
24
24
  export * from './space/dynamic-space-tool.js';
25
25
  export * from './space/utils/gradio-caller.js';
26
26
  export * from './space/utils/gradio-schema.js';
27
+ export * from './network/url-policy.js';
27
28
 
28
29
  // Export shared types
29
30
  export * from './types/tool-result.js';
@@ -6,7 +6,7 @@ export const UV_DEFAULT_IMAGE = 'ghcr.io/astral-sh/uv:python3.12-bookworm';
6
6
  type UvCommandOptions = Pick<UvArgs, 'with_deps' | 'python' | 'script_args'>;
7
7
  type UvCommandLikeArgs = Pick<UvArgs, 'script' | 'with_deps' | 'python' | 'script_args'>;
8
8
 
9
- export function buildUvCommand(script: string, args: UvCommandOptions): string[] {
9
+ function buildUvCommand(script: string, args: UvCommandOptions): string[] {
10
10
  const parts: string[] = ['uv', 'run'];
11
11
 
12
12
  if (args.with_deps && args.with_deps.length > 0) {
@@ -28,7 +28,7 @@ export function buildUvCommand(script: string, args: UvCommandOptions): string[]
28
28
  return parts;
29
29
  }
30
30
 
31
- export function wrapInlineScript(script: string, args: UvCommandOptions): string {
31
+ function wrapInlineScript(script: string, args: UvCommandOptions): string {
32
32
  const encoded = Buffer.from(script, 'utf-8').toString('base64');
33
33
  const baseCommand = shellQuote(buildUvCommand('-', args));
34
34
  return `echo "${encoded}" | base64 -d | ${baseCommand}`;
@@ -162,6 +162,17 @@ const HARDWARE_FLAVORS_SECTION = [
162
162
  .filter((line): line is string => Boolean(line))
163
163
  .join('\n');
164
164
 
165
+ const UNKNOWN_OPERATION_INSTRUCTIONS = `Available operations:
166
+ - run, uv, ps, logs, inspect, cancel
167
+ - scheduled run, scheduled uv, scheduled ps, scheduled inspect, scheduled delete, scheduled suspend, scheduled resume
168
+
169
+ Call this tool with no operation for full usage instructions.`;
170
+
171
+ function formatUnknownOperationMessage(requestedOperation?: string): string {
172
+ return `Unknown operation: "${requestedOperation ?? 'unknown'}"
173
+ ${UNKNOWN_OPERATION_INSTRUCTIONS}`;
174
+ }
175
+
165
176
  function isHelpRequested(args: Record<string, unknown> | undefined): boolean {
166
177
  if (!args) {
167
178
  return false;
@@ -426,12 +437,7 @@ export class HfJobsTool {
426
437
  const normalizedOperation = requestedOperation.toLowerCase();
427
438
  if (!isOperationName(normalizedOperation)) {
428
439
  return {
429
- formatted: `Unknown operation: "${requestedOperation}"
430
- Available operations:
431
- - run, uv, ps, logs, inspect, cancel
432
- - scheduled run, scheduled uv, scheduled ps, scheduled inspect, scheduled delete, scheduled suspend, scheduled resume
433
-
434
- Call this tool with no operation for full usage instructions.`,
440
+ formatted: formatUnknownOperationMessage(requestedOperation),
435
441
  totalResults: 0,
436
442
  resultsShared: 0,
437
443
  };
@@ -540,12 +546,7 @@ Call this tool with no operation for full usage instructions.`,
540
546
 
541
547
  default:
542
548
  return {
543
- formatted: `Unknown operation: "${requestedOperation ?? 'unknown'}"
544
- Available operations:
545
- - run, uv, ps, logs, inspect, cancel
546
- - scheduled run, scheduled uv, scheduled ps, scheduled inspect, scheduled delete, scheduled suspend, scheduled resume
547
-
548
- Call this tool with no operation for full usage instructions.`,
549
+ formatted: formatUnknownOperationMessage(requestedOperation),
549
550
  totalResults: 0,
550
551
  resultsShared: 0,
551
552
  };