@llmindset/hf-mcp 0.3.2 → 0.3.3

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 +166 -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 +26 -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 +29 -0
  118. package/src/network/ip-policy.ts +206 -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
@@ -5,7 +5,7 @@ import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/proto
5
5
  import { analyzeSchemaComplexity, validateParameters, applyDefaults } from '../utils/schema-validator.js';
6
6
  import { formatComplexSchemaError, formatValidationError } from '../utils/parameter-formatter.js';
7
7
  import { callGradioToolWithHeaders } from '../utils/gradio-caller.js';
8
- import { parseGradioSchemaResponse, normalizeParsedTools } from '../utils/gradio-schema.js';
8
+ import { fetchGradioSchema, fetchSpaceMetadata } from '../utils/space-http.js';
9
9
 
10
10
  /**
11
11
  * Invokes a Gradio space with provided parameters
@@ -112,90 +112,3 @@ export async function invokeSpace(
112
112
  };
113
113
  }
114
114
  }
115
-
116
- /**
117
- * Fetches space metadata from HuggingFace API
118
- */
119
- async function fetchSpaceMetadata(
120
- spaceName: string,
121
- hfToken?: string
122
- ): Promise<{ subdomain: string; private: boolean }> {
123
- const url = `https://huggingface.co/api/spaces/${spaceName}`;
124
- const headers: Record<string, string> = {};
125
-
126
- if (hfToken) {
127
- headers['Authorization'] = `Bearer ${hfToken}`;
128
- }
129
-
130
- const controller = new AbortController();
131
- const timeoutId = setTimeout(() => controller.abort(), 10000);
132
-
133
- try {
134
- const response = await fetch(url, {
135
- headers,
136
- signal: controller.signal,
137
- });
138
-
139
- clearTimeout(timeoutId);
140
-
141
- if (!response.ok) {
142
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
143
- }
144
-
145
- const info = (await response.json()) as {
146
- subdomain?: string;
147
- private?: boolean;
148
- };
149
-
150
- if (!info.subdomain) {
151
- throw new Error('Space does not have a subdomain');
152
- }
153
-
154
- return {
155
- subdomain: info.subdomain,
156
- private: info.private || false,
157
- };
158
- } finally {
159
- clearTimeout(timeoutId);
160
- }
161
- }
162
-
163
- /**
164
- * Fetches schema from Gradio endpoint
165
- */
166
- async function fetchGradioSchema(subdomain: string, isPrivate: boolean, hfToken?: string): Promise<Tool[]> {
167
- const schemaUrl = `https://${subdomain}.hf.space/gradio_api/mcp/schema`;
168
-
169
- const headers: Record<string, string> = {
170
- 'Content-Type': 'application/json',
171
- };
172
-
173
- if (isPrivate && hfToken) {
174
- headers['X-HF-Authorization'] = `Bearer ${hfToken}`;
175
- }
176
-
177
- const controller = new AbortController();
178
- const timeoutId = setTimeout(() => controller.abort(), 10000);
179
-
180
- try {
181
- const response = await fetch(schemaUrl, {
182
- method: 'GET',
183
- headers,
184
- signal: controller.signal,
185
- });
186
-
187
- clearTimeout(timeoutId);
188
-
189
- if (!response.ok) {
190
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
191
- }
192
-
193
- const schemaResponse = (await response.json()) as unknown;
194
-
195
- // Parse schema response (handle both array and object formats)
196
- const parsed = parseGradioSchemaResponse(schemaResponse);
197
- return normalizeParsedTools(parsed);
198
- } finally {
199
- clearTimeout(timeoutId);
200
- }
201
- }
@@ -2,6 +2,7 @@ import type { ToolResult } from '../../types/tool-result.js';
2
2
  import type { Tool } from '@modelcontextprotocol/sdk/types.js';
3
3
  import { analyzeSchemaComplexity } from '../utils/schema-validator.js';
4
4
  import { formatParameters, formatComplexSchemaError } from '../utils/parameter-formatter.js';
5
+ import { fetchGradioSchema, fetchSpaceMetadata } from '../utils/space-http.js';
5
6
 
6
7
  /**
7
8
  * Fetches space metadata and schema to discover parameters
@@ -56,7 +57,8 @@ export async function viewParameters(spaceName: string, hfToken?: string): Promi
56
57
  let formattedError = `Error fetching parameters for space '${spaceName}': ${errorMessage}`;
57
58
 
58
59
  if (is404) {
59
- formattedError += '\n\nNote: The space MUST be an MCP enabled space. Use the `space_search` tool to find MCP enabled spaces.';
60
+ formattedError +=
61
+ '\n\nNote: The space MUST be an MCP enabled space. Use the `space_search` tool to find MCP enabled spaces.';
60
62
  }
61
63
 
62
64
  return {
@@ -67,138 +69,3 @@ export async function viewParameters(spaceName: string, hfToken?: string): Promi
67
69
  };
68
70
  }
69
71
  }
70
-
71
- /**
72
- * Fetches space metadata from HuggingFace API
73
- */
74
- async function fetchSpaceMetadata(
75
- spaceName: string,
76
- hfToken?: string
77
- ): Promise<{ subdomain: string; private: boolean }> {
78
- const url = `https://huggingface.co/api/spaces/${spaceName}`;
79
- const headers: Record<string, string> = {};
80
-
81
- if (hfToken) {
82
- headers['Authorization'] = `Bearer ${hfToken}`;
83
- }
84
-
85
- const controller = new AbortController();
86
- const timeoutId = setTimeout(() => controller.abort(), 10000);
87
-
88
- try {
89
- const response = await fetch(url, {
90
- headers,
91
- signal: controller.signal,
92
- });
93
-
94
- clearTimeout(timeoutId);
95
-
96
- if (!response.ok) {
97
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
98
- }
99
-
100
- const info = (await response.json()) as {
101
- subdomain?: string;
102
- private?: boolean;
103
- };
104
-
105
- if (!info.subdomain) {
106
- throw new Error('Space does not have a subdomain');
107
- }
108
-
109
- return {
110
- subdomain: info.subdomain,
111
- private: info.private || false,
112
- };
113
- } finally {
114
- clearTimeout(timeoutId);
115
- }
116
- }
117
-
118
- /**
119
- * Fetches schema from Gradio endpoint
120
- */
121
- async function fetchGradioSchema(subdomain: string, isPrivate: boolean, hfToken?: string): Promise<Tool[]> {
122
- const schemaUrl = `https://${subdomain}.hf.space/gradio_api/mcp/schema`;
123
-
124
- const headers: Record<string, string> = {
125
- 'Content-Type': 'application/json',
126
- };
127
-
128
- if (isPrivate && hfToken) {
129
- headers['X-HF-Authorization'] = `Bearer ${hfToken}`;
130
- }
131
-
132
- const controller = new AbortController();
133
- const timeoutId = setTimeout(() => controller.abort(), 10000);
134
-
135
- try {
136
- const response = await fetch(schemaUrl, {
137
- method: 'GET',
138
- headers,
139
- signal: controller.signal,
140
- });
141
-
142
- clearTimeout(timeoutId);
143
-
144
- if (!response.ok) {
145
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
146
- }
147
-
148
- const schemaResponse = (await response.json()) as unknown;
149
-
150
- // Parse schema response (handle both array and object formats)
151
- return parseSchemaResponse(schemaResponse);
152
- } finally {
153
- clearTimeout(timeoutId);
154
- }
155
- }
156
-
157
- /**
158
- * Parses schema response and extracts tools
159
- */
160
- function parseSchemaResponse(schemaResponse: unknown): Tool[] {
161
- const tools: Tool[] = [];
162
-
163
- if (Array.isArray(schemaResponse)) {
164
- // Array format: [{ name: "toolName", description: "...", inputSchema: {...} }, ...]
165
- for (const item of schemaResponse) {
166
- if (
167
- typeof item === 'object' &&
168
- item !== null &&
169
- 'name' in item &&
170
- 'inputSchema' in item
171
- ) {
172
- const itemRecord = item as Record<string, unknown>;
173
- if (typeof itemRecord.name === 'string') {
174
- const tool = itemRecord as { name: string; description?: string; inputSchema: unknown };
175
- tools.push({
176
- name: tool.name,
177
- description: tool.description || `${tool.name} tool`,
178
- inputSchema: {
179
- type: 'object',
180
- ...(tool.inputSchema as Record<string, unknown>),
181
- },
182
- });
183
- }
184
- }
185
- }
186
- } else if (typeof schemaResponse === 'object' && schemaResponse !== null) {
187
- // Object format: { "toolName": { properties: {...}, required: [...] }, ... }
188
- for (const [name, toolSchema] of Object.entries(schemaResponse)) {
189
- if (typeof toolSchema === 'object' && toolSchema !== null) {
190
- const schema = toolSchema as { description?: string };
191
- tools.push({
192
- name,
193
- description: schema.description || `${name} tool`,
194
- inputSchema: {
195
- type: 'object',
196
- ...(toolSchema as Record<string, unknown>),
197
- },
198
- });
199
- }
200
- }
201
- }
202
-
203
- return tools.filter((tool) => !tool.name.toLowerCase().includes('<lambda'));
204
- }
@@ -144,6 +144,10 @@ function getUsageInstructions(): string {
144
144
  return isDynamicSpaceMode() ? DYNAMIC_USAGE_INSTRUCTIONS : USAGE_INSTRUCTIONS;
145
145
  }
146
146
 
147
+ function formatUnknownOperationLine(requestedOperation?: string): string {
148
+ return `Unknown operation: "${requestedOperation ?? 'unknown'}"`;
149
+ }
150
+
147
151
  /**
148
152
  * Space tool configuration
149
153
  * Returns dynamic config based on environment
@@ -222,7 +226,7 @@ export class SpaceTool {
222
226
  const validOperations = getOperationNames();
223
227
  if (!validOperations.includes(normalizedOperation)) {
224
228
  return {
225
- formatted: `Unknown operation: "${requestedOperation}"
229
+ formatted: `${formatUnknownOperationLine(requestedOperation)}
226
230
  Available operations: ${validOperations.join(', ')}
227
231
 
228
232
  Call this tool with no operation for full usage instructions.`,
@@ -249,7 +253,7 @@ Call this tool with no operation for full usage instructions.`,
249
253
 
250
254
  default:
251
255
  return {
252
- formatted: `Unknown operation: "${requestedOperation}"`,
256
+ formatted: formatUnknownOperationLine(requestedOperation),
253
257
  totalResults: 0,
254
258
  resultsShared: 0,
255
259
  isError: true,
@@ -3,10 +3,17 @@ import {
3
3
  StreamableHTTPClientTransport,
4
4
  type StreamableHTTPClientTransportOptions,
5
5
  } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
6
- import { CallToolResultSchema, type CallToolResult, type ServerNotification, type ServerRequest } from '@modelcontextprotocol/sdk/types.js';
6
+ import {
7
+ CallToolResultSchema,
8
+ type CallToolResult,
9
+ type ServerNotification,
10
+ type ServerRequest,
11
+ } from '@modelcontextprotocol/sdk/types.js';
7
12
  import { Protocol, type RequestHandlerExtra, type RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js';
8
13
  import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';
9
14
  import { logger } from '../../logger.js';
15
+ import { fetchWithProfile, NETWORK_FETCH_PROFILES } from '../../network/fetch-profile.js';
16
+ import { createGradioMcpPolicy, parseAndValidateUrl } from '../../network/url-policy.js';
10
17
 
11
18
  class GradioClient extends Client {
12
19
  override async connect(transport: Transport, _options?: RequestOptions): Promise<void> {
@@ -102,6 +109,10 @@ export async function callGradioToolWithHeaders(
102
109
  extra: RequestHandlerExtra<ServerRequest, ServerNotification> | undefined,
103
110
  options: GradioCallOptions = {}
104
111
  ): Promise<GradioCallResult> {
112
+ const validatedMcpUrl = parseAndValidateUrl(mcpUrl, createGradioMcpPolicy());
113
+ const protocol = validatedMcpUrl.protocol === 'http:' ? 'http:' : 'https:';
114
+ const mcpRequestProfile = NETWORK_FETCH_PROFILES.gradioMcpHost(validatedMcpUrl.hostname, protocol);
115
+
105
116
  const capturedHeaders: Record<string, string> = {};
106
117
  let loggedHeader = false;
107
118
 
@@ -154,7 +165,9 @@ export async function callGradioToolWithHeaders(
154
165
  hasBody: Boolean(init?.body),
155
166
  requestSummary,
156
167
  });
157
- const response = await fetch(url, init);
168
+ const { response } = await fetchWithProfile(url.toString(), mcpRequestProfile, {
169
+ requestInit: init,
170
+ });
158
171
  logger.trace('[gradio] upstream response', {
159
172
  method,
160
173
  url: url.toString(),
@@ -196,22 +209,22 @@ export async function callGradioToolWithHeaders(
196
209
  }
197
210
 
198
211
  logger.trace('[gradio] connecting streamable client', {
199
- mcpUrl,
212
+ mcpUrl: validatedMcpUrl.toString(),
200
213
  hasToken: Boolean(hfToken),
201
214
  skipInitialize,
202
215
  });
203
- const transport = new StreamableHTTPClientTransport(new URL(mcpUrl), transportOptions);
216
+ const transport = new StreamableHTTPClientTransport(validatedMcpUrl, transportOptions);
204
217
  let isClosing = false;
205
218
  transport.onmessage = (message) => {
206
219
  const messageInfo =
207
220
  message && typeof message === 'object'
208
221
  ? {
209
- hasId: 'id' in message,
210
- id: (message as { id?: unknown }).id ?? null,
211
- method: 'method' in message ? (message as { method?: unknown }).method : null,
212
- isResult: 'result' in message,
213
- isError: 'error' in message,
214
- }
222
+ hasId: 'id' in message,
223
+ id: (message as { id?: unknown }).id ?? null,
224
+ method: 'method' in message ? (message as { method?: unknown }).method : null,
225
+ isResult: 'result' in message,
226
+ isError: 'error' in message,
227
+ }
215
228
  : { messageType: typeof message };
216
229
  logger.trace('[gradio] transport message', messageInfo);
217
230
  };
@@ -228,13 +241,13 @@ export async function callGradioToolWithHeaders(
228
241
  let connectCompleted = false;
229
242
  const connectWatchdog = setTimeout(() => {
230
243
  if (!connectCompleted) {
231
- logger.trace('[gradio] connect still pending', { mcpUrl });
244
+ logger.trace('[gradio] connect still pending', { mcpUrl: validatedMcpUrl.toString() });
232
245
  }
233
246
  }, 15000);
234
247
  await remoteClient.connect(transport);
235
248
  connectCompleted = true;
236
249
  clearTimeout(connectWatchdog);
237
- logger.trace('[gradio] connected streamable client', { mcpUrl });
250
+ logger.trace('[gradio] connected streamable client', { mcpUrl: validatedMcpUrl.toString() });
238
251
 
239
252
  try {
240
253
  // Check if the client is requesting progress notifications
@@ -0,0 +1,75 @@
1
+ import type { Tool } from '@modelcontextprotocol/sdk/types.js';
2
+ import { fetchWithProfile, NETWORK_FETCH_PROFILES } from '../../network/fetch-profile.js';
3
+ import { normalizeParsedTools, parseGradioSchemaResponse } from './gradio-schema.js';
4
+
5
+ const SPACE_HTTP_TIMEOUT_MS = 10_000;
6
+
7
+ export interface SpaceMetadata {
8
+ subdomain: string;
9
+ private: boolean;
10
+ }
11
+
12
+ export async function fetchSpaceMetadata(spaceName: string, hfToken?: string): Promise<SpaceMetadata> {
13
+ const url = `https://huggingface.co/api/spaces/${spaceName}`;
14
+ const headers: Record<string, string> = {};
15
+
16
+ if (hfToken) {
17
+ headers['Authorization'] = `Bearer ${hfToken}`;
18
+ }
19
+
20
+ const { response } = await fetchWithProfile(url, NETWORK_FETCH_PROFILES.hfHub(), {
21
+ timeoutMs: SPACE_HTTP_TIMEOUT_MS,
22
+ requestInit: {
23
+ headers,
24
+ },
25
+ });
26
+
27
+ if (!response.ok) {
28
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
29
+ }
30
+
31
+ const info = (await response.json()) as {
32
+ subdomain?: string;
33
+ private?: boolean;
34
+ };
35
+
36
+ if (!info.subdomain) {
37
+ throw new Error('Space does not have a subdomain');
38
+ }
39
+
40
+ return {
41
+ subdomain: info.subdomain,
42
+ private: info.private || false,
43
+ };
44
+ }
45
+
46
+ export async function fetchGradioSchema(subdomain: string, isPrivate: boolean, hfToken?: string): Promise<Tool[]> {
47
+ const schemaUrl = `https://${subdomain}.hf.space/gradio_api/mcp/schema`;
48
+
49
+ const headers: Record<string, string> = {
50
+ 'Content-Type': 'application/json',
51
+ };
52
+
53
+ if (isPrivate && hfToken) {
54
+ headers['X-HF-Authorization'] = `Bearer ${hfToken}`;
55
+ }
56
+
57
+ const { response } = await fetchWithProfile(
58
+ schemaUrl,
59
+ NETWORK_FETCH_PROFILES.gradioSchemaHost(`${subdomain}.hf.space`),
60
+ {
61
+ timeoutMs: SPACE_HTTP_TIMEOUT_MS,
62
+ requestInit: {
63
+ method: 'GET',
64
+ headers,
65
+ },
66
+ }
67
+ );
68
+
69
+ if (!response.ok) {
70
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
71
+ }
72
+
73
+ const schemaResponse = (await response.json()) as unknown;
74
+ return normalizeParsedTools(parseGradioSchemaResponse(schemaResponse));
75
+ }
@@ -3,6 +3,7 @@ import { listFiles, spaceInfo } 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
  export interface FileWithUrl {
@@ -226,7 +227,7 @@ export class SpaceFilesTool {
226
227
  for (const file of dirFiles) {
227
228
  const fileName = file.path.split('/').pop() || file.path;
228
229
  const indent = dir === '/' ? '' : '&nbsp;&nbsp;&nbsp;&nbsp;';
229
- const icon = this.getFileIcon(fileName);
230
+ const icon = getFileIcon(fileName);
230
231
  const lastMod = file.lastModified ? new Date(file.lastModified).toLocaleDateString() : '-';
231
232
 
232
233
  markdown += `| ${indent}${icon} ${escapeMarkdown(fileName)} | ${file.sizeFormatted} | ${file.lfs ? 'LFS' : 'Regular'} | ${lastMod} | ${file.url} |\n`;
@@ -265,7 +266,7 @@ export class SpaceFilesTool {
265
266
 
266
267
  for (const file of files) {
267
268
  const fileName = file.path.split('/').pop() || file.path;
268
- const icon = this.getFileIcon(fileName);
269
+ const icon = getFileIcon(fileName);
269
270
  markdown += `| ${icon} ${escapeMarkdown(fileName)} | ${escapeMarkdown(file.path)} | ${file.sizeFormatted} | [Link](${file.url}) |\n`;
270
271
  }
271
272
 
@@ -283,43 +284,4 @@ export class SpaceFilesTool {
283
284
 
284
285
  return this.generateDetailedMarkdown(spaceName, fileType);
285
286
  }
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
287
  }
@@ -0,0 +1,70 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ const SRC_ROOT = join(process.cwd(), 'src');
6
+
7
+ function collectTsFiles(root: string): string[] {
8
+ const entries = readdirSync(root);
9
+ const files: string[] = [];
10
+
11
+ for (const entry of entries) {
12
+ const fullPath = join(root, entry);
13
+ const stat = statSync(fullPath);
14
+ if (stat.isDirectory()) {
15
+ files.push(...collectTsFiles(fullPath));
16
+ continue;
17
+ }
18
+
19
+ if (fullPath.endsWith('.ts')) {
20
+ files.push(fullPath);
21
+ }
22
+ }
23
+
24
+ return files;
25
+ }
26
+
27
+ function hasDirectFetchCall(content: string): boolean {
28
+ const regex = /fetch\s*\(/g;
29
+ let match: RegExpExecArray | null;
30
+
31
+ while ((match = regex.exec(content)) !== null) {
32
+ const index = match.index;
33
+ const previousChar = index > 0 ? content[index - 1] : '';
34
+
35
+ if (/[\w$.]/.test(previousChar)) {
36
+ continue;
37
+ }
38
+
39
+ const prefix = content.slice(Math.max(0, index - 20), index);
40
+ if (/\basync\s+$/.test(prefix) || /\bfunction\s+$/.test(prefix)) {
41
+ continue;
42
+ }
43
+
44
+ return true;
45
+ }
46
+
47
+ return false;
48
+ }
49
+
50
+ describe('fetch usage guard', () => {
51
+ it('only allows direct fetch calls in network/safe-fetch.ts', () => {
52
+ const allowedSuffixes = new Set(['/network/safe-fetch.ts']);
53
+ const offenders: string[] = [];
54
+ const files = collectTsFiles(SRC_ROOT);
55
+
56
+ for (const file of files) {
57
+ const content = readFileSync(file, 'utf8');
58
+ if (!hasDirectFetchCall(content)) {
59
+ continue;
60
+ }
61
+
62
+ const normalized = file.replace(SRC_ROOT, '').replace(/\\/g, '/');
63
+ if (!allowedSuffixes.has(normalized)) {
64
+ offenders.push(normalized);
65
+ }
66
+ }
67
+
68
+ expect(offenders).toEqual([]);
69
+ });
70
+ });
@@ -0,0 +1,60 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { fetchJobLogs } from '../../src/jobs/sse-handler.js';
3
+ import { safeFetch } from '../../src/network/safe-fetch.js';
4
+
5
+ vi.mock('../../src/network/safe-fetch.js', () => ({
6
+ safeFetch: vi.fn(),
7
+ }));
8
+
9
+ function createSseResponse(read: () => Promise<ReadableStreamReadResult<Uint8Array>>): Response {
10
+ return {
11
+ ok: true,
12
+ body: {
13
+ getReader: () => ({
14
+ read,
15
+ cancel: vi.fn().mockResolvedValue(undefined),
16
+ }),
17
+ },
18
+ } as unknown as Response;
19
+ }
20
+
21
+ describe('fetchJobLogs', () => {
22
+ beforeEach(() => {
23
+ vi.clearAllMocks();
24
+ });
25
+
26
+ it('treats timeout-aborted SSE reads as expected truncation', async () => {
27
+ const abortedRead = async (): Promise<ReadableStreamReadResult<Uint8Array>> => {
28
+ await new Promise((resolve) => setTimeout(resolve, 20));
29
+ throw new DOMException('The operation was aborted.', 'AbortError');
30
+ };
31
+
32
+ vi.mocked(safeFetch).mockResolvedValue({
33
+ response: createSseResponse(abortedRead),
34
+ finalUrl: new URL('https://example.com/logs'),
35
+ redirectsFollowed: 0,
36
+ });
37
+
38
+ const result = await fetchJobLogs('https://example.com/logs', { maxDuration: 1, maxLines: 5 });
39
+
40
+ expect(result).toEqual({
41
+ logs: [],
42
+ finished: false,
43
+ truncated: true,
44
+ });
45
+ });
46
+
47
+ it('throws non-timeout stream errors', async () => {
48
+ const failingRead = async (): Promise<ReadableStreamReadResult<Uint8Array>> => {
49
+ throw new Error('stream read failed');
50
+ };
51
+
52
+ vi.mocked(safeFetch).mockResolvedValue({
53
+ response: createSseResponse(failingRead),
54
+ finalUrl: new URL('https://example.com/logs'),
55
+ redirectsFollowed: 0,
56
+ });
57
+
58
+ await expect(fetchJobLogs('https://example.com/logs', { maxDuration: 100 })).rejects.toThrow('stream read failed');
59
+ });
60
+ });
@@ -1,4 +0,0 @@
1
- import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
2
- export declare function formatToolResult(result: CallToolResult): string;
3
- export declare function formatWarnings(warnings: string[]): string;
4
- //# sourceMappingURL=result-formatter.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"result-formatter.d.ts","sourceRoot":"","sources":["../../../src/space/utils/result-formatter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,oCAAoC,CAAC;AAUzE,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,cAAc,GAAG,MAAM,CAa/D;AAmMD,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,MAAM,CAOzD"}