@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
@@ -0,0 +1,304 @@
1
+ export interface UrlPathRules {
2
+ requiredPrefix?: string;
3
+ }
4
+
5
+ export type UrlProtocol = 'https:' | 'http:';
6
+
7
+ export interface UrlQueryRules {
8
+ allowAny?: boolean;
9
+ allowKeys?: ReadonlySet<string>;
10
+ }
11
+
12
+ export interface UrlPolicy {
13
+ allowedProtocols: ReadonlySet<UrlProtocol>;
14
+ allowedHosts?: ReadonlySet<string>;
15
+ allowSubdomainsOf?: readonly string[];
16
+ requireDefaultPort?: boolean;
17
+ pathRules?: UrlPathRules;
18
+ queryRules?: UrlQueryRules;
19
+ allowCredentials?: boolean;
20
+ customValidator?: (url: URL) => void;
21
+ }
22
+
23
+ const LOCALHOST_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
24
+
25
+ const ENCODED_SEPARATOR_RE = /%(?:2f|5c)/i;
26
+ const ENCODED_BYTE_RE = /%[0-9a-f]{2}/i;
27
+ const INVALID_PERCENT_ENCODING_RE = /%(?![0-9a-f]{2})/i;
28
+
29
+ function normalizeHostname(hostname: string): string {
30
+ return hostname.toLowerCase().replace(/\.+$/, '');
31
+ }
32
+
33
+ function safeDecodeURIComponent(value: string): string {
34
+ try {
35
+ return decodeURIComponent(value);
36
+ } catch {
37
+ throw new Error('URL contains invalid percent-encoding');
38
+ }
39
+ }
40
+
41
+ function collectDecodedPathVariants(pathname: string): string[] {
42
+ const variants = [pathname];
43
+ let current = pathname;
44
+
45
+ for (let i = 0; i < 2; i += 1) {
46
+ if (!current.includes('%')) {
47
+ break;
48
+ }
49
+
50
+ const decoded = safeDecodeURIComponent(current);
51
+ if (decoded === current) {
52
+ break;
53
+ }
54
+
55
+ variants.push(decoded);
56
+ current = decoded;
57
+ }
58
+
59
+ return variants;
60
+ }
61
+
62
+ function hasDotSegments(pathname: string): boolean {
63
+ const normalized = pathname.replace(/\\/g, '/');
64
+ const segments = normalized.split('/');
65
+ return segments.some((segment) => segment === '.' || segment === '..');
66
+ }
67
+
68
+ function matchesRequiredPrefix(pathname: string, requiredPrefix: string): boolean {
69
+ const normalizedPath = pathname.replace(/\\/g, '/');
70
+ const normalizedPrefix = requiredPrefix.replace(/\\/g, '/');
71
+
72
+ if (normalizedPath === normalizedPrefix) {
73
+ return true;
74
+ }
75
+
76
+ if (normalizedPrefix.endsWith('/') && normalizedPath === normalizedPrefix.slice(0, -1)) {
77
+ return true;
78
+ }
79
+
80
+ return normalizedPath.startsWith(normalizedPrefix);
81
+ }
82
+
83
+ function assertHostAllowed(hostname: string, policy: UrlPolicy): void {
84
+ if (!policy.allowedHosts && (!policy.allowSubdomainsOf || policy.allowSubdomainsOf.length === 0)) {
85
+ return;
86
+ }
87
+
88
+ const normalized = normalizeHostname(hostname);
89
+
90
+ if (policy.allowedHosts) {
91
+ for (const host of policy.allowedHosts) {
92
+ if (normalizeHostname(host) === normalized) {
93
+ return;
94
+ }
95
+ }
96
+ }
97
+
98
+ if (policy.allowSubdomainsOf) {
99
+ for (const domain of policy.allowSubdomainsOf) {
100
+ const normalizedDomain = normalizeHostname(domain);
101
+ if (normalized === normalizedDomain || normalized.endsWith(`.${normalizedDomain}`)) {
102
+ return;
103
+ }
104
+ }
105
+ }
106
+
107
+ throw new Error(`URL hostname is not allowed: ${hostname}`);
108
+ }
109
+
110
+ function assertPathAllowed(url: URL, pathRules?: UrlPathRules): void {
111
+ const pathname = url.pathname;
112
+
113
+ if (pathname.includes('%') && INVALID_PERCENT_ENCODING_RE.test(pathname)) {
114
+ throw new Error('URL path contains invalid percent-encoding');
115
+ }
116
+
117
+ const variants = collectDecodedPathVariants(pathname);
118
+
119
+ const hasEncodedSeparators = variants.some((variant) => ENCODED_SEPARATOR_RE.test(variant));
120
+ if (hasEncodedSeparators) {
121
+ throw new Error('URL path contains encoded separators');
122
+ }
123
+
124
+ const hasUnsafeDotSegments = variants.some((variant) => hasDotSegments(variant));
125
+ if (hasUnsafeDotSegments) {
126
+ throw new Error('URL path contains dot-segments');
127
+ }
128
+
129
+ if (variants.length > 1) {
130
+ const decodedOnce = variants[1] ?? '';
131
+ if (ENCODED_BYTE_RE.test(decodedOnce)) {
132
+ throw new Error('URL path appears to use double-encoding');
133
+ }
134
+ }
135
+
136
+ if (pathRules?.requiredPrefix) {
137
+ const hasPrefix = variants.some((variant) => matchesRequiredPrefix(variant, pathRules.requiredPrefix ?? ''));
138
+ if (!hasPrefix) {
139
+ throw new Error(`URL path must start with ${pathRules.requiredPrefix}`);
140
+ }
141
+ }
142
+ }
143
+
144
+ function assertQueryAllowed(url: URL, policy: UrlPolicy): void {
145
+ const rules = policy.queryRules;
146
+ if (!rules || rules.allowAny === true) {
147
+ return;
148
+ }
149
+
150
+ if (!rules.allowKeys) {
151
+ if (url.search.length > 0) {
152
+ throw new Error('URL query string is not allowed');
153
+ }
154
+ return;
155
+ }
156
+
157
+ for (const key of url.searchParams.keys()) {
158
+ if (!rules.allowKeys.has(key)) {
159
+ throw new Error(`URL query parameter is not allowed: ${key}`);
160
+ }
161
+ }
162
+ }
163
+
164
+ function assertPortAllowed(url: URL, policy: UrlPolicy): void {
165
+ if (!policy.requireDefaultPort || url.port.length === 0) {
166
+ return;
167
+ }
168
+
169
+ const defaultPort = url.protocol === 'https:' ? '443' : url.protocol === 'http:' ? '80' : '';
170
+ if (!defaultPort || url.port !== defaultPort) {
171
+ throw new Error(`URL port is not allowed for protocol ${url.protocol}`);
172
+ }
173
+ }
174
+
175
+ export function validateUrlAgainstPolicy(url: URL, policy: UrlPolicy): void {
176
+ if (!policy.allowedProtocols.has(url.protocol as UrlProtocol)) {
177
+ throw new Error(`URL protocol is not allowed: ${url.protocol}`);
178
+ }
179
+
180
+ if (!policy.allowCredentials && (url.username.length > 0 || url.password.length > 0)) {
181
+ throw new Error('URL credentials are not allowed');
182
+ }
183
+
184
+ assertHostAllowed(url.hostname, policy);
185
+ assertPortAllowed(url, policy);
186
+ assertPathAllowed(url, policy.pathRules);
187
+ assertQueryAllowed(url, policy);
188
+
189
+ policy.customValidator?.(url);
190
+ }
191
+
192
+ export function parseAndValidateUrl(input: string | URL, policy: UrlPolicy): URL {
193
+ const parsed = input instanceof URL ? new URL(input.toString()) : new URL(input.trim());
194
+ validateUrlAgainstPolicy(parsed, policy);
195
+ return parsed;
196
+ }
197
+
198
+ export function createHfDocsPolicy(): UrlPolicy {
199
+ const hfHosts = new Set(['huggingface.co', 'www.huggingface.co']);
200
+
201
+ return {
202
+ allowedProtocols: new Set(['https:']),
203
+ allowedHosts: new Set(['huggingface.co', 'www.huggingface.co', 'gradio.app', 'www.gradio.app']),
204
+ allowCredentials: false,
205
+ queryRules: { allowAny: true },
206
+ customValidator: (url) => {
207
+ const host = normalizeHostname(url.hostname);
208
+ if (hfHosts.has(host) && !matchesRequiredPrefix(url.pathname, '/docs/')) {
209
+ throw new Error('Hugging Face docs URLs must remain under /docs/');
210
+ }
211
+ },
212
+ };
213
+ }
214
+
215
+ export function createGradioMcpPolicy(): UrlPolicy {
216
+ return {
217
+ allowedProtocols: new Set(['https:', 'http:']),
218
+ pathRules: {
219
+ requiredPrefix: '/gradio_api/mcp',
220
+ },
221
+ allowCredentials: false,
222
+ queryRules: { allowAny: true },
223
+ customValidator: (url) => {
224
+ const enforceLocalHttpOnly = process.env.NODE_ENV === 'production';
225
+ if (enforceLocalHttpOnly && url.protocol === 'http:' && !isLocalhostHostname(url.hostname)) {
226
+ throw new Error('HTTP is only allowed for localhost Gradio MCP endpoints');
227
+ }
228
+ },
229
+ };
230
+ }
231
+
232
+ export function isLocalhostHostname(hostname: string): boolean {
233
+ return LOCALHOST_HOSTS.has(normalizeHostname(hostname));
234
+ }
235
+
236
+ export function createExternalHttpsPolicy(): UrlPolicy {
237
+ return {
238
+ allowedProtocols: new Set(['https:']),
239
+ allowCredentials: false,
240
+ queryRules: { allowAny: true },
241
+ };
242
+ }
243
+
244
+ export function createHuggingFaceHubPolicy(): UrlPolicy {
245
+ return {
246
+ allowedProtocols: new Set(['https:']),
247
+ allowedHosts: new Set(['huggingface.co', 'www.huggingface.co', 'hf.co']),
248
+ allowCredentials: false,
249
+ queryRules: { allowAny: true },
250
+ };
251
+ }
252
+
253
+ export function createLocalhostHttpPolicy(): UrlPolicy {
254
+ return {
255
+ allowedProtocols: new Set(['https:', 'http:']),
256
+ allowedHosts: new Set(['localhost', '127.0.0.1', '[::1]']),
257
+ allowCredentials: false,
258
+ queryRules: { allowAny: true },
259
+ };
260
+ }
261
+
262
+ export function createExactHostPolicy(hostname: string, allowedProtocol: UrlProtocol): UrlPolicy {
263
+ return {
264
+ allowedProtocols: new Set([allowedProtocol]),
265
+ allowedHosts: new Set([hostname.toLowerCase()]),
266
+ allowCredentials: false,
267
+ queryRules: { allowAny: true },
268
+ };
269
+ }
270
+
271
+ export function createHostPrefixPolicy(
272
+ hostname: string,
273
+ requiredPrefix: string,
274
+ allowedProtocol: UrlProtocol = 'https:'
275
+ ): UrlPolicy {
276
+ return {
277
+ allowedProtocols: new Set([allowedProtocol]),
278
+ allowedHosts: new Set([hostname.toLowerCase()]),
279
+ pathRules: {
280
+ requiredPrefix,
281
+ },
282
+ allowCredentials: false,
283
+ queryRules: { allowAny: true },
284
+ };
285
+ }
286
+
287
+ export function createGradioMcpHostPolicy(
288
+ hostname: string,
289
+ allowedProtocol: UrlProtocol
290
+ ): UrlPolicy {
291
+ return createHostPrefixPolicy(hostname, '/gradio_api/mcp', allowedProtocol);
292
+ }
293
+
294
+ export function createGradioSchemaHostPolicy(hostname: string): UrlPolicy {
295
+ return createHostPrefixPolicy(hostname, '/gradio_api/mcp/schema');
296
+ }
297
+
298
+ export function createHttpOrHttpsPolicy(): UrlPolicy {
299
+ return {
300
+ allowedProtocols: new Set(['https:', 'http:']),
301
+ allowCredentials: false,
302
+ queryRules: { allowAny: true },
303
+ };
304
+ }
@@ -2,12 +2,14 @@
2
2
  * Utility functions for fetching and processing README files from Hugging Face repositories
3
3
  */
4
4
 
5
+ import { fetchWithProfile, NETWORK_FETCH_PROFILES } from './network/fetch-profile.js';
6
+
5
7
  // Maximum number of characters to include from a README
6
8
  const DEFAULT_MAX_README_CHARS = 10_000;
7
9
 
8
10
  /**
9
11
  * Fetches README content from a Hugging Face repository
10
- *
12
+ *
11
13
  * @param repoName The resolved repository name (e.g., 'rajpurkar/squad', 'openai-community/gpt2')
12
14
  * @param type The repository type ('models' or 'datasets')
13
15
  * @param includeYaml Whether to include YAML frontmatter (default: false)
@@ -20,14 +22,13 @@ export async function fetchReadmeContent(
20
22
  ): Promise<string | null> {
21
23
  try {
22
24
  // Construct the URL based on repository type
23
- const baseUrl = type === 'datasets'
24
- ? `https://huggingface.co/datasets/${repoName}`
25
- : `https://huggingface.co/${repoName}`;
26
-
25
+ const baseUrl =
26
+ type === 'datasets' ? `https://huggingface.co/datasets/${repoName}` : `https://huggingface.co/${repoName}`;
27
+
27
28
  const url = `${baseUrl}/resolve/main/README.md`;
28
29
 
29
- const response = await fetch(url);
30
-
30
+ const { response } = await fetchWithProfile(url, NETWORK_FETCH_PROFILES.hfHub());
31
+
31
32
  if (!response.ok) {
32
33
  if (response.status === 404) {
33
34
  // README doesn't exist, return null silently
@@ -64,7 +65,7 @@ export async function fetchReadmeContent(
64
65
 
65
66
  /**
66
67
  * Strips YAML frontmatter from markdown content
67
- *
68
+ *
68
69
  * @param content The full markdown content
69
70
  * @returns The content with YAML frontmatter removed
70
71
  */
@@ -72,12 +73,12 @@ function stripYamlFrontmatter(content: string): string {
72
73
  // Match YAML frontmatter: starts with ---, ends with ---
73
74
  const yamlPattern = /^(\s*---[\r\n]+)([\S\s]*?)([\r\n]+---(\r\n|\n|$))/;
74
75
  const match = content.match(yamlPattern);
75
-
76
+
76
77
  if (match) {
77
78
  // Return everything after the closing ---
78
79
  return content.substring(match[0].length);
79
80
  }
80
-
81
+
81
82
  // No YAML frontmatter found, return original content
82
83
  return content;
83
84
  }
@@ -1,12 +1,13 @@
1
1
  import type { ToolResult } from '../../types/tool-result.js';
2
2
  import { escapeMarkdown } from '../../utilities.js';
3
3
  import { VIEW_PARAMETERS } from '../types.js';
4
+ import { fetchWithProfile, NETWORK_FETCH_PROFILES } from '../../network/fetch-profile.js';
4
5
 
5
6
  /**
6
7
  * Prompt configuration for discover operation (from DYNAMIC_SPACE_DATA)
7
8
  * These prompts can be easily tweaked to adjust behavior
8
9
  */
9
- export const DISCOVER_PROMPTS = {
10
+ const DISCOVER_PROMPTS = {
10
11
  // Header for results
11
12
  RESULTS_HEADER: `**Available Spaces:**
12
13
 
@@ -90,7 +91,14 @@ export async function discoverSpaces(): Promise<ToolResult> {
90
91
  }
91
92
 
92
93
  try {
93
- const response = await fetch(url);
94
+ const allowPermissiveUrls = process.env.ALLOW_PERMISSIVE_URLS === 'true';
95
+ const profile = allowPermissiveUrls
96
+ ? NETWORK_FETCH_PROFILES.httpOrHttpsPermissive()
97
+ : NETWORK_FETCH_PROFILES.externalHttps();
98
+
99
+ const { response } = await fetchWithProfile(url, profile, {
100
+ timeoutMs: 10000,
101
+ });
94
102
 
95
103
  if (!response.ok) {
96
104
  return {
@@ -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,