From a62023bcac6d618726ff96c84cdf1480bca4e6c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Nov 2025 17:58:58 +0000 Subject: [PATCH 01/15] feat: create unified Gradio tool caller for consistent behavior - Extract callGradioTool utility for SSE connection, MCP call, and progress relay - Extract applyResultPostProcessing for image filtering and OpenAI transforms - Refactor proxied gr_* tools to use shared utilities - Move stripImageContentFromResult and extractUrlFromContent to gradio-result-processor.ts - Ensure both proxied tools and space tool can use identical logic --- .../src/server/gradio-endpoint-connector.ts | 364 +++++------------- .../server/utils/gradio-result-processor.ts | 96 +++++ .../src/server/utils/gradio-tool-caller.ts | 190 +++++++++ .../server/gradio-endpoint-connector.test.ts | 2 +- 4 files changed, 389 insertions(+), 263 deletions(-) create mode 100644 packages/app/src/server/utils/gradio-result-processor.ts create mode 100644 packages/app/src/server/utils/gradio-tool-caller.ts diff --git a/packages/app/src/server/gradio-endpoint-connector.ts b/packages/app/src/server/gradio-endpoint-connector.ts index bad3ed7..9108cae 100644 --- a/packages/app/src/server/gradio-endpoint-connector.ts +++ b/packages/app/src/server/gradio-endpoint-connector.ts @@ -17,6 +17,8 @@ import { gradioMetrics, getMetricsSafeName } from './utils/gradio-metrics.js'; import { createGradioToolName } from './utils/gradio-utils.js'; import { createAudioPlayerUIResource } from './utils/ui/audio-player.js'; import { spaceMetadataCache, CACHE_CONFIG } from './utils/gradio-cache.js'; +import { stripImageContentFromResult, extractUrlFromContent } from './utils/gradio-result-processor.js'; +import { callGradioTool, applyResultPostProcessing, type GradioToolCallOptions } from './utils/gradio-tool-caller.js'; // Define types for JSON Schema interface JsonSchemaProperty { @@ -57,52 +59,6 @@ interface RegisterRemoteToolsOptions { gradioWidgetUri?: string; } -interface ImageFilterOptions { - enabled: boolean; - toolName: string; - outwardFacingName: string; -} - -export function stripImageContentFromResult( - callResult: typeof CallToolResultSchema._type, - { enabled, toolName, outwardFacingName }: ImageFilterOptions -): typeof CallToolResultSchema._type { - if (!enabled) { - return callResult; - } - - const content = callResult.content; - if (!Array.isArray(content) || content.length === 0) { - return callResult; - } - - const filteredContent = content.filter((item) => { - if (!item || typeof item !== 'object') { - return true; - } - - const candidate = item as { type?: unknown }; - const typeValue = typeof candidate.type === 'string' ? candidate.type.toLowerCase() : undefined; - return typeValue !== 'image'; - }); - - if (filteredContent.length === content.length) { - return callResult; - } - - const removedCount = content.length - filteredContent.length; - logger.debug({ tool: toolName, outwardFacingName, removedCount }, 'Stripped image content from remote tool response'); - - if (filteredContent.length === 0) { - filteredContent.push({ - type: 'text', - text: 'Image content omitted due to client configuration (no_image_content=true).', - }); - } - - return { ...callResult, content: filteredContent }; -} - type EndpointConnectionResult = | { success: true; @@ -449,43 +405,6 @@ function createToolDisplayInfo(connection: EndpointConnection, tool: Tool): { ti return { title, description }; } -/** - * Extracts a URL from the result content if present - */ -function extractUrlFromContent(content: unknown[]): string | undefined { - if (!Array.isArray(content) || content.length === 0) { - return undefined; - } - - // Check each content item for a URL-like string - for (const item of content) { - if (!item || typeof item !== 'object') { - continue; - } - - const candidate = item as { type?: string; text?: string; url?: string }; - - // Check for explicit url field - if (typeof candidate.url === 'string' && /^https?:\/\//i.test(candidate.url.trim())) { - return candidate.url.trim(); - } - - // Check for text field that looks like a URL - if (typeof candidate.text === 'string') { - let text = candidate.text.trim(); - - // Remove "Image URL:" or "Image URL :" prefix if present (case insensitive) - text = text.replace(/^image\s+url\s*:\s*/i, ''); - - if (/^https?:\/\//i.test(text)) { - return text; - } - } - } - - return undefined; -} - /** * Creates the tool handler function */ @@ -515,205 +434,126 @@ function createToolHandler( let notificationCount = 0; try { - // Since we use schema fetch, we always need to create SSE connection for tool execution + // Validate SSE URL if (!connection.sseUrl) { throw new Error('No SSE URL available for tool execution'); } - logger.debug({ tool: tool.name }, 'Creating SSE connection for tool execution'); - const activeClient = await createLazyConnection(connection.sseUrl, hfToken); - - // Check if the client is requesting progress notifications - const progressToken = extra._meta?.progressToken; - const requestOptions: RequestOptions = {}; - - if (progressToken !== undefined) { - logger.debug({ tool: tool.name, progressToken }, 'Progress notifications requested'); - - // Set up progress relay from remote tool to our client - requestOptions.onprogress = async (progress) => { - logger.trace({ tool: tool.name, progressToken, progress }, 'Relaying progress notification'); - - // Track notification count - notificationCount++; - - // Relay the progress notification to our client - await extra.sendNotification({ - method: 'notifications/progress', - params: { - progressToken, - progress: progress.progress, - total: progress.total, - message: progress.message, - }, - }); - }; - } - try { - const result = await activeClient.request( - { - method: 'tools/call', - params: { - name: tool.name, - arguments: params, - _meta: progressToken !== undefined ? { progressToken } : undefined, - }, - }, - CallToolResultSchema, - requestOptions - ); + // Use unified Gradio tool caller for SSE connection, MCP call, and progress relay + const result = await callGradioTool(connection.sseUrl, tool.name, params, hfToken, extra); - // Calculate response size (rough estimate based on JSON serialization) - try { - responseSizeBytes = JSON.stringify(result).length; - } catch { - // If serialization fails, don't worry about size - } + // Calculate response size (rough estimate based on JSON serialization) + try { + responseSizeBytes = JSON.stringify(result).length; + } catch { + // If serialization fails, don't worry about size + } - success = !result.isError; - if (result.isError) { - // Extract a meaningful error message from MCP content items - const first = - Array.isArray(result.content) && result.content.length > 0 ? (result.content[0] as unknown) : undefined; - let message: string | undefined; - if (typeof first === 'string') { - message = first; - } else if (first && typeof first === 'object') { - const obj = first as Record; - if (typeof obj.text === 'string') { - message = obj.text; - } else if (typeof obj.message === 'string') { - message = obj.message; - } else if (typeof obj.error === 'string') { - message = obj.error; - } else { - try { - message = JSON.stringify(obj); - } catch { - message = String(obj); - } + success = !result.isError; + if (result.isError) { + // Extract a meaningful error message from MCP content items + const first = + Array.isArray(result.content) && result.content.length > 0 ? (result.content[0] as unknown) : undefined; + let message: string | undefined; + if (typeof first === 'string') { + message = first; + } else if (first && typeof first === 'object') { + const obj = first as Record; + if (typeof obj.text === 'string') { + message = obj.text; + } else if (typeof obj.message === 'string') { + message = obj.message; + } else if (typeof obj.error === 'string') { + message = obj.error; + } else { + try { + message = JSON.stringify(obj); + } catch { + message = String(obj); } - } else if (first !== undefined) { - // Fallback for other primitive types - message = String(first); } + } else if (first !== undefined) { + // Fallback for other primitive types + message = String(first); + } - error = message || 'Unknown error'; + error = message || 'Unknown error'; - // Bubble up the error so upstream callers and metrics can track failures - throw new Error(error); - } + // Bubble up the error so upstream callers and metrics can track failures + throw new Error(error); + } - // Record success in gradio metrics when no error and no exception thrown - if (success) { - const metricsName = getMetricsSafeName(outwardFacingName); - gradioMetrics.recordSuccess(metricsName); - } + // Record success in gradio metrics when no error and no exception thrown + if (success) { + const metricsName = getMetricsSafeName(outwardFacingName); + gradioMetrics.recordSuccess(metricsName); + } - // Special handling: if the tool name contains "_mcpui" and it returns a single text URL, - // wrap it as an embedded audio player UI resource. - try { - const hasUiSuffix = tool.name.includes('_mcpui'); - if (!result.isError && hasUiSuffix && Array.isArray(result.content) && result.content.length === 1) { - const item = result.content[0] as unknown as { type?: string; text?: string }; - const text = typeof item?.text === 'string' ? item.text.trim() : ''; - const looksLikeUrl = /^https?:\/\//i.test(text); - - if ((item.type === 'text' || !item.type) && looksLikeUrl) { - let base64Audio: string | undefined; - const url = text; - - try { - const resp = await fetch(url); - if (resp.ok) { - const buf = Buffer.from(await resp.arrayBuffer()); - base64Audio = buf.toString('base64'); - } - } catch (e) { - logger.debug( - { tool: tool.name, url, error: e instanceof Error ? e.message : String(e) }, - 'Failed to inline audio; falling back to URL source' - ); - } + // Prepare post-processing options + const postProcessOptions: GradioToolCallOptions = { + stripImageContent: options.stripImageContent, + toolName: tool.name, + outwardFacingName, + sessionInfo, + gradioWidgetUri: options.gradioWidgetUri, + spaceName: connection.name, + }; - const title = `${connection.name || 'MCP UI tool'}`; - const uriSafeName = (connection.name || 'audio').replace(/[^a-z0-9-_]+/gi, '-'); - const uiUri: `ui://${string}` = `ui://huggingface-mcp/${uriSafeName}/${Date.now().toString()}`; - - const uiResource = createAudioPlayerUIResource(uiUri, { - title, - base64Audio, - srcUrl: base64Audio ? undefined : url, - mimeType: `audio/wav`, - }); - - const decoratedResult = { - isError: false, - content: [result.content[0], uiResource], - } as typeof CallToolResultSchema._type; - - const mcpuiResult = stripImageContentFromResult(decoratedResult, { - enabled: !!options.stripImageContent, - toolName: tool.name, - outwardFacingName, - }); - - // For openai-mcp client, check if result contains a URL and set structuredContent - if (sessionInfo?.clientInfo?.name == 'openai-mcp') { - const extractedUrl = extractUrlFromContent(mcpuiResult.content); - if (extractedUrl) { - logger.debug({ tool: tool.name, url: extractedUrl }, 'Setting structuredContent for _mcpui tool'); - ( - mcpuiResult as typeof CallToolResultSchema._type & { - structuredContent?: { url: string; spaceName?: string }; - } - ).structuredContent = { - url: extractedUrl, - spaceName: connection.name, - }; - } + // Special handling: if the tool name contains "_mcpui" and it returns a single text URL, + // wrap it as an embedded audio player UI resource. + try { + const hasUiSuffix = tool.name.includes('_mcpui'); + if (!result.isError && hasUiSuffix && Array.isArray(result.content) && result.content.length === 1) { + const item = result.content[0] as unknown as { type?: string; text?: string }; + const text = typeof item?.text === 'string' ? item.text.trim() : ''; + const looksLikeUrl = /^https?:\/\//i.test(text); + + if ((item.type === 'text' || !item.type) && looksLikeUrl) { + let base64Audio: string | undefined; + const url = text; + + try { + const resp = await fetch(url); + if (resp.ok) { + const buf = Buffer.from(await resp.arrayBuffer()); + base64Audio = buf.toString('base64'); } - - return mcpuiResult; + } catch (e) { + logger.debug( + { tool: tool.name, url, error: e instanceof Error ? e.message : String(e) }, + 'Failed to inline audio; falling back to URL source' + ); } - } - } catch (e) { - logger.debug( - { tool: tool.name, error: e instanceof Error ? e.message : String(e) }, - 'MCP UI transform skipped' - ); - } - // Strip image content first - const finalResult = stripImageContentFromResult(result, { - enabled: !!options.stripImageContent, - toolName: tool.name, - outwardFacingName, - }); + const title = `${connection.name || 'MCP UI tool'}`; + const uriSafeName = (connection.name || 'audio').replace(/[^a-z0-9-_]+/gi, '-'); + const uiUri: `ui://${string}` = `ui://huggingface-mcp/${uriSafeName}/${Date.now().toString()}`; - // For openai-mcp client, check if result contains a URL and set structuredContent - if (sessionInfo?.clientInfo?.name == 'openai-mcp') { - const extractedUrl = extractUrlFromContent(finalResult.content); - if (extractedUrl) { - logger.debug({ tool: tool.name, url: extractedUrl }, 'Setting structuredContent with extracted URL'); - ( - finalResult as typeof CallToolResultSchema._type & { - structuredContent?: { url: string; spaceName?: string }; - } - ).structuredContent = { - url: extractedUrl, - spaceName: connection.name, - }; + const uiResource = createAudioPlayerUIResource(uiUri, { + title, + base64Audio, + srcUrl: base64Audio ? undefined : url, + mimeType: `audio/wav`, + }); + + const decoratedResult = { + isError: false, + content: [result.content[0], uiResource], + } as typeof CallToolResultSchema._type; + + // Apply post-processing to the decorated result + return applyResultPostProcessing(decoratedResult, postProcessOptions); } } - - return finalResult; - } catch (callError) { - // Handle request errors - success = false; - error = callError instanceof Error ? callError.message : String(callError); - throw callError; + } catch (e) { + logger.debug( + { tool: tool.name, error: e instanceof Error ? e.message : String(e) }, + 'MCP UI transform skipped' + ); } + + // Apply standard post-processing (image stripping + OpenAI structured content) + return applyResultPostProcessing(result, postProcessOptions); } catch (err) { // Ensure meaningful error output instead of [object Object] const errObj = diff --git a/packages/app/src/server/utils/gradio-result-processor.ts b/packages/app/src/server/utils/gradio-result-processor.ts new file mode 100644 index 0000000..9a7dbec --- /dev/null +++ b/packages/app/src/server/utils/gradio-result-processor.ts @@ -0,0 +1,96 @@ +import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; +import { logger } from './logger.js'; + +/** + * Options for filtering image content + */ +export interface ImageFilterOptions { + enabled: boolean; + toolName: string; + outwardFacingName: string; +} + +/** + * Strips image content from a tool result if filtering is enabled + * + * This function is used by both proxied Gradio tools and the space tool's invoke operation + * to conditionally remove image content blocks based on user configuration. + */ +export function stripImageContentFromResult( + callResult: typeof CallToolResultSchema._type, + { enabled, toolName, outwardFacingName }: ImageFilterOptions +): typeof CallToolResultSchema._type { + if (!enabled) { + return callResult; + } + + const content = callResult.content; + if (!Array.isArray(content) || content.length === 0) { + return callResult; + } + + const filteredContent = content.filter((item) => { + if (!item || typeof item !== 'object') { + return true; + } + + const candidate = item as { type?: unknown }; + const typeValue = typeof candidate.type === 'string' ? candidate.type.toLowerCase() : undefined; + return typeValue !== 'image'; + }); + + if (filteredContent.length === content.length) { + return callResult; + } + + const removedCount = content.length - filteredContent.length; + logger.debug({ tool: toolName, outwardFacingName, removedCount }, 'Stripped image content from Gradio tool response'); + + if (filteredContent.length === 0) { + filteredContent.push({ + type: 'text', + text: 'Image content omitted due to client configuration (no_image_content=true).', + }); + } + + return { ...callResult, content: filteredContent }; +} + +/** + * Extracts a URL from the result content if present + * + * Used for OpenAI MCP client to populate structuredContent field + */ +export function extractUrlFromContent(content: unknown[]): string | undefined { + if (!Array.isArray(content) || content.length === 0) { + return undefined; + } + + // Check each content item for a URL-like string + for (const item of content) { + if (!item || typeof item !== 'object') { + continue; + } + + const candidate = item as { type?: string; text?: string; url?: string }; + + // Check for explicit url field + if (typeof candidate.url === 'string' && /^https?:\/\//i.test(candidate.url.trim())) { + return candidate.url.trim(); + } + + // Check for text field that looks like a URL + if (typeof candidate.text === 'string') { + let text = candidate.text.trim(); + + // Remove "Image URL:" or "Image URL :" prefix if present (case insensitive) + text = text.replace(/^image\s+url\s*:\s*/i, ''); + + if (/^https?:\/\//i.test(text)) { + return text; + } + } + } + + return undefined; +} diff --git a/packages/app/src/server/utils/gradio-tool-caller.ts b/packages/app/src/server/utils/gradio-tool-caller.ts new file mode 100644 index 0000000..5b48325 --- /dev/null +++ b/packages/app/src/server/utils/gradio-tool-caller.ts @@ -0,0 +1,190 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { SSEClientTransport, type SSEClientTransportOptions } from '@modelcontextprotocol/sdk/client/sse.js'; +import { + CallToolResultSchema, + type ServerNotification, + type ServerRequest, +} from '@modelcontextprotocol/sdk/types.js'; +import type { RequestHandlerExtra, RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js'; +import { logger } from './logger.js'; +import { stripImageContentFromResult, extractUrlFromContent } from './gradio-result-processor.js'; + +/** + * Options for calling a Gradio tool + */ +export interface GradioToolCallOptions { + /** Whether to strip image content from the result */ + stripImageContent?: boolean; + /** Original tool name (for logging) */ + toolName: string; + /** Outward-facing tool name (for logging) */ + outwardFacingName: string; + /** Session information for client-specific handling */ + sessionInfo?: { + clientSessionId?: string; + isAuthenticated?: boolean; + clientInfo?: { name: string; version: string }; + }; + /** Gradio widget URI for OpenAI client */ + gradioWidgetUri?: string; + /** Space name for structured content */ + spaceName?: string; +} + +/** + * Creates SSE connection to a Gradio endpoint + */ +async function createGradioConnection(sseUrl: string, hfToken?: string): Promise { + logger.debug({ url: sseUrl }, 'Creating SSE connection to Gradio endpoint'); + + // Create MCP client + const remoteClient = new Client( + { + name: 'hf-mcp-gradio-client', + version: '1.0.0', + }, + { + capabilities: {}, + } + ); + + // Create SSE transport with HF token if available + const transportOptions: SSEClientTransportOptions = {}; + if (hfToken) { + const headerName = 'X-HF-Authorization'; + const customHeaders = { + [headerName]: `Bearer ${hfToken}`, + }; + logger.trace('Creating Gradio connection with authorization header'); + + // Headers for POST requests + transportOptions.requestInit = { + headers: customHeaders, + }; + + // Headers for SSE connection + transportOptions.eventSourceInit = { + fetch: (url, init) => { + const headers = new Headers(init.headers); + Object.entries(customHeaders).forEach(([key, value]) => { + headers.set(key, value); + }); + return fetch(url.toString(), { ...init, headers }); + }, + }; + } + + const transport = new SSEClientTransport(new URL(sseUrl), transportOptions); + + // Connect the client to the transport + await remoteClient.connect(transport); + logger.debug('SSE connection established'); + + return remoteClient; +} + +/** + * Unified Gradio tool caller that handles: + * - SSE connection management + * - MCP tool invocation + * - Progress notification relay + * + * Returns the raw MCP result without post-processing. Callers should apply + * image filtering and OpenAI-specific transforms as needed using applyResultPostProcessing. + * + * This ensures both proxied gr_* tools and the space tool's invoke operation + * behave identically. + */ +export async function callGradioTool( + sseUrl: string, + toolName: string, + parameters: Record, + hfToken: string | undefined, + extra: RequestHandlerExtra | undefined +): Promise { + logger.info({ tool: toolName, params: parameters }, 'Calling Gradio tool via unified caller'); + + const client = await createGradioConnection(sseUrl, hfToken); + + try { + // Check if the client is requesting progress notifications + const progressToken = extra?._meta?.progressToken; + const requestOptions: RequestOptions = {}; + + if (progressToken !== undefined && extra) { + logger.debug({ tool: toolName, progressToken }, 'Progress notifications requested'); + + // Set up progress relay from remote tool to our client + // eslint-disable-next-line @typescript-eslint/no-misused-promises + requestOptions.onprogress = async (progress) => { + logger.trace({ tool: toolName, progressToken, progress }, 'Relaying progress notification'); + + // Relay the progress notification to our client + await extra.sendNotification({ + method: 'notifications/progress', + params: { + progressToken, + progress: progress.progress, + total: progress.total, + message: progress.message, + }, + }); + }; + } + + // Call the remote tool and return raw result + return await client.request( + { + method: 'tools/call', + params: { + name: toolName, + arguments: parameters, + _meta: progressToken !== undefined ? { progressToken } : undefined, + }, + }, + CallToolResultSchema, + requestOptions + ); + } finally { + // Always clean up the connection + await client.close(); + } +} + +/** + * Applies post-processing to a Gradio tool result: + * - Image content filtering (conditionally) + * - OpenAI-specific structured content + * + * This should be called after any custom transformations (like _mcpui handling) + * to ensure consistent behavior across all Gradio tools. + */ +export function applyResultPostProcessing( + result: typeof CallToolResultSchema._type, + options: GradioToolCallOptions +): typeof CallToolResultSchema._type { + // Strip image content if requested + const filteredResult = stripImageContentFromResult(result, { + enabled: !!options.stripImageContent, + toolName: options.toolName, + outwardFacingName: options.outwardFacingName, + }); + + // For OpenAI MCP client, check if result contains a URL and set structuredContent + if (options.sessionInfo?.clientInfo?.name === 'openai-mcp') { + const extractedUrl = extractUrlFromContent(filteredResult.content); + if (extractedUrl) { + logger.debug({ tool: options.toolName, url: extractedUrl }, 'Setting structuredContent with extracted URL'); + ( + filteredResult as typeof CallToolResultSchema._type & { + structuredContent?: { url: string; spaceName?: string }; + } + ).structuredContent = { + url: extractedUrl, + spaceName: options.spaceName, + }; + } + } + + return filteredResult; +} diff --git a/packages/app/test/server/gradio-endpoint-connector.test.ts b/packages/app/test/server/gradio-endpoint-connector.test.ts index 2a60f3b..40f5396 100644 --- a/packages/app/test/server/gradio-endpoint-connector.test.ts +++ b/packages/app/test/server/gradio-endpoint-connector.test.ts @@ -2,8 +2,8 @@ import { describe, it, expect } from 'vitest'; import { parseSchemaResponse, convertJsonSchemaToZod, - stripImageContentFromResult, } from '../../src/server/gradio-endpoint-connector.js'; +import { stripImageContentFromResult } from '../../src/server/utils/gradio-result-processor.js'; import { z } from 'zod'; import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; From 74dd3c4c049e4699445585104343708d681633b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Nov 2025 18:03:52 +0000 Subject: [PATCH 02/15] feat: integrate space tool with raw MCP content pass-through - Import space tool files from PR #112 - Modify invoke.ts to return raw MCP result instead of formatted text - Add InvokeResult type to support both raw content and warnings - Export space tool from packages/mcp/src/index.ts - Add SPACE_TOOL_CONFIG and SPACE_TOOL_ID to tool-ids.ts - Space tool invoke operation now returns same content blocks as proxied gr_* tools - This ensures consistent behavior across both tool invocation paths --- packages/mcp/src/index.ts | 1 + packages/mcp/src/space/commands/invoke.ts | 335 ++++++++++++++++++ .../mcp/src/space/commands/view-parameters.ts | 194 ++++++++++ packages/mcp/src/space/space-tool.ts | 252 +++++++++++++ packages/mcp/src/space/types.ts | 127 +++++++ .../src/space/utils/parameter-formatter.ts | 200 +++++++++++ .../mcp/src/space/utils/result-formatter.ts | 226 ++++++++++++ .../mcp/src/space/utils/schema-validator.ts | 232 ++++++++++++ packages/mcp/src/tool-ids.ts | 4 + 9 files changed, 1571 insertions(+) create mode 100644 packages/mcp/src/space/commands/invoke.ts create mode 100644 packages/mcp/src/space/commands/view-parameters.ts create mode 100644 packages/mcp/src/space/space-tool.ts create mode 100644 packages/mcp/src/space/types.ts create mode 100644 packages/mcp/src/space/utils/parameter-formatter.ts create mode 100644 packages/mcp/src/space/utils/result-formatter.ts create mode 100644 packages/mcp/src/space/utils/schema-validator.ts diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 9a266e0..a21096e 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -20,6 +20,7 @@ export * from './docs-search/doc-fetch.js'; export * from './readme-utils.js'; export * from './use-space.js'; export * from './jobs/jobs-tool.js'; +export * from './space/space-tool.js'; // Export shared types export * from './types/tool-result.js'; diff --git a/packages/mcp/src/space/commands/invoke.ts b/packages/mcp/src/space/commands/invoke.ts new file mode 100644 index 0000000..06bbe3c --- /dev/null +++ b/packages/mcp/src/space/commands/invoke.ts @@ -0,0 +1,335 @@ +import type { ToolResult } from '../../types/tool-result.js'; +import type { InvokeResult } from '../types.js'; +import type { Tool, ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types.js'; +import type { RequestHandlerExtra, RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js'; +import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { SSEClientTransport, type SSEClientTransportOptions } from '@modelcontextprotocol/sdk/client/sse.js'; +import { analyzeSchemaComplexity, validateParameters, applyDefaults } from '../utils/schema-validator.js'; +import { formatComplexSchemaError, formatValidationError } from '../utils/parameter-formatter.js'; + +/** + * Invokes a Gradio space with provided parameters + * Returns raw MCP content blocks for compatibility with proxied gr_* tools + */ +export async function invokeSpace( + spaceName: string, + parametersJson: string, + hfToken?: string, + extra?: RequestHandlerExtra +): Promise { + try { + // Step 1: Parse parameters JSON + let inputParameters: Record; + try { + const parsed: unknown = JSON.parse(parametersJson); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('Parameters must be a JSON object'); + } + inputParameters = parsed as Record; + } catch (error) { + return { + formatted: `Error: Invalid JSON in parameters.\n\nExpected format: {"param1": "value", "param2": 123}\nNote: Use double quotes, no trailing commas.\n\n${error instanceof Error ? error.message : String(error)}`, + totalResults: 0, + resultsShared: 0, + isError: true, + }; + } + + // Step 2: Fetch space metadata to get subdomain + const metadata = await fetchSpaceMetadata(spaceName, hfToken); + + // Step 3: Fetch schema from Gradio endpoint + const tools = await fetchGradioSchema(metadata.subdomain, metadata.private, hfToken); + + if (tools.length === 0) { + return { + formatted: `Error: No tools found for space '${spaceName}'.`, + totalResults: 0, + resultsShared: 0, + isError: true, + }; + } + + const tool = tools[0] as Tool; + + // Step 4: Analyze schema complexity + const schemaResult = analyzeSchemaComplexity(tool); + + if (!schemaResult.isSimple) { + return { + formatted: formatComplexSchemaError(spaceName, schemaResult.reason || 'Unknown reason'), + totalResults: 0, + resultsShared: 0, + isError: true, + }; + } + + // Step 5: Validate parameters + const validation = validateParameters(inputParameters, schemaResult); + if (!validation.valid) { + return { + formatted: formatValidationError(validation.errors, spaceName), + totalResults: 0, + resultsShared: 0, + isError: true, + }; + } + + // Step 6: Check for unknown parameters (warnings) + const warnings: string[] = []; + const knownParamNames = new Set(schemaResult.parameters.map((p) => p.name)); + for (const key of Object.keys(inputParameters)) { + if (!knownParamNames.has(key)) { + warnings.push(`Unknown parameter: "${key}" (will be passed through)`); + } + } + + // Step 7: Apply default values for missing optional parameters + const finalParameters = applyDefaults(inputParameters, schemaResult); + + // Step 8: Create SSE connection and invoke tool + const sseUrl = `https://${metadata.subdomain}.hf.space/gradio_api/mcp/sse`; + const client = await createLazyConnection(sseUrl, hfToken); + + try { + // Check if the client is requesting progress notifications + const progressToken = extra?._meta?.progressToken; + const requestOptions: RequestOptions = {}; + + if (progressToken !== undefined && extra) { + // Set up progress relay from remote tool to our client + // eslint-disable-next-line @typescript-eslint/no-misused-promises + requestOptions.onprogress = async (progress) => { + // Relay the progress notification to our client + await extra.sendNotification({ + method: 'notifications/progress', + params: { + progressToken, + progress: progress.progress, + total: progress.total, + message: progress.message, + }, + }); + }; + } + + const result = await client.request( + { + method: 'tools/call', + params: { + name: tool.name, + arguments: finalParameters, + _meta: progressToken !== undefined ? { progressToken } : undefined, + }, + }, + CallToolResultSchema, + requestOptions + ); + + // Return raw MCP result with warnings if any + // This ensures the space tool behaves identically to proxied gr_* tools + return { + result, + warnings, + totalResults: 1, + resultsShared: 1, + isError: result.isError, + }; + } finally { + // Clean up connection + await client.close(); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { + formatted: `Error invoking space '${spaceName}': ${errorMessage}`, + totalResults: 0, + resultsShared: 0, + isError: true, + }; + } +} + +/** + * Fetches space metadata from HuggingFace API + */ +async function fetchSpaceMetadata( + spaceName: string, + hfToken?: string +): Promise<{ subdomain: string; private: boolean }> { + const url = `https://huggingface.co/api/spaces/${spaceName}`; + const headers: Record = {}; + + if (hfToken) { + headers['Authorization'] = `Bearer ${hfToken}`; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + try { + const response = await fetch(url, { + headers, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const info = (await response.json()) as { + subdomain?: string; + private?: boolean; + }; + + if (!info.subdomain) { + throw new Error('Space does not have a subdomain'); + } + + return { + subdomain: info.subdomain, + private: info.private || false, + }; + } finally { + clearTimeout(timeoutId); + } +} + +/** + * Fetches schema from Gradio endpoint + */ +async function fetchGradioSchema(subdomain: string, isPrivate: boolean, hfToken?: string): Promise { + const schemaUrl = `https://${subdomain}.hf.space/gradio_api/mcp/schema`; + + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (isPrivate && hfToken) { + headers['X-HF-Authorization'] = `Bearer ${hfToken}`; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + try { + const response = await fetch(schemaUrl, { + method: 'GET', + headers, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const schemaResponse = (await response.json()) as unknown; + + // Parse schema response (handle both array and object formats) + return parseSchemaResponse(schemaResponse); + } finally { + clearTimeout(timeoutId); + } +} + +/** + * Parses schema response and extracts tools + */ +function parseSchemaResponse(schemaResponse: unknown): Tool[] { + const tools: Tool[] = []; + + if (Array.isArray(schemaResponse)) { + // Array format: [{ name: "toolName", description: "...", inputSchema: {...} }, ...] + for (const item of schemaResponse) { + if ( + typeof item === 'object' && + item !== null && + 'name' in item && + 'inputSchema' in item + ) { + const itemRecord = item as Record; + if (typeof itemRecord.name === 'string') { + const tool = itemRecord as { name: string; description?: string; inputSchema: unknown }; + tools.push({ + name: tool.name, + description: tool.description || `${tool.name} tool`, + inputSchema: { + type: 'object', + ...(tool.inputSchema as Record), + }, + }); + } + } + } + } else if (typeof schemaResponse === 'object' && schemaResponse !== null) { + // Object format: { "toolName": { properties: {...}, required: [...] }, ... } + for (const [name, toolSchema] of Object.entries(schemaResponse)) { + if (typeof toolSchema === 'object' && toolSchema !== null) { + const schema = toolSchema as { description?: string }; + tools.push({ + name, + description: schema.description || `${name} tool`, + inputSchema: { + type: 'object', + ...(toolSchema as Record), + }, + }); + } + } + } + + return tools.filter((tool) => !tool.name.toLowerCase().includes(' { + // Create MCP client + const remoteClient = new Client( + { + name: 'hf-mcp-space-client', + version: '1.0.0', + }, + { + capabilities: {}, + } + ); + + // Create SSE transport with HF token if available + const transportOptions: SSEClientTransportOptions = {}; + if (hfToken) { + const headerName = 'X-HF-Authorization'; + const customHeaders = { + [headerName]: `Bearer ${hfToken}`, + }; + + // Headers for POST requests + transportOptions.requestInit = { + headers: customHeaders, + }; + + // Headers for SSE connection + transportOptions.eventSourceInit = { + fetch: (url, init) => { + const headers = new Headers(init.headers); + Object.entries(customHeaders).forEach(([key, value]) => { + headers.set(key, value); + }); + return fetch(url.toString(), { ...init, headers }); + }, + }; + } + + const transport = new SSEClientTransport(new URL(sseUrl), transportOptions); + + // Connect the client to the transport + await remoteClient.connect(transport); + + return remoteClient; +} diff --git a/packages/mcp/src/space/commands/view-parameters.ts b/packages/mcp/src/space/commands/view-parameters.ts new file mode 100644 index 0000000..df472a2 --- /dev/null +++ b/packages/mcp/src/space/commands/view-parameters.ts @@ -0,0 +1,194 @@ +import type { ToolResult } from '../../types/tool-result.js'; +import type { Tool } from '@modelcontextprotocol/sdk/types.js'; +import { analyzeSchemaComplexity } from '../utils/schema-validator.js'; +import { formatParameters, formatComplexSchemaError } from '../utils/parameter-formatter.js'; + +/** + * Fetches space metadata and schema to discover parameters + */ +export async function viewParameters(spaceName: string, hfToken?: string): Promise { + try { + // Step 1: Fetch space metadata to get subdomain + const metadata = await fetchSpaceMetadata(spaceName, hfToken); + + // Step 2: Fetch schema from Gradio endpoint + const tools = await fetchGradioSchema(metadata.subdomain, metadata.private, hfToken); + + // For simplicity, we'll work with the first tool + // (most Gradio spaces expose a single primary tool) + if (tools.length === 0) { + return { + formatted: `Error: No tools found for space '${spaceName}'.`, + totalResults: 0, + resultsShared: 0, + isError: true, + }; + } + + const tool = tools[0] as Tool; + + // Step 3: Analyze schema complexity + const schemaResult = analyzeSchemaComplexity(tool); + + if (!schemaResult.isSimple) { + return { + formatted: formatComplexSchemaError(spaceName, schemaResult.reason || 'Unknown reason'), + totalResults: 0, + resultsShared: 0, + isError: true, + }; + } + + // Step 4: Format parameters for display + const formatted = formatParameters(schemaResult, spaceName); + + return { + formatted, + totalResults: schemaResult.parameters.length, + resultsShared: schemaResult.parameters.length, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { + formatted: `Error fetching parameters for space '${spaceName}': ${errorMessage}`, + totalResults: 0, + resultsShared: 0, + isError: true, + }; + } +} + +/** + * Fetches space metadata from HuggingFace API + */ +async function fetchSpaceMetadata( + spaceName: string, + hfToken?: string +): Promise<{ subdomain: string; private: boolean }> { + const url = `https://huggingface.co/api/spaces/${spaceName}`; + const headers: Record = {}; + + if (hfToken) { + headers['Authorization'] = `Bearer ${hfToken}`; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + try { + const response = await fetch(url, { + headers, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const info = (await response.json()) as { + subdomain?: string; + private?: boolean; + }; + + if (!info.subdomain) { + throw new Error('Space does not have a subdomain'); + } + + return { + subdomain: info.subdomain, + private: info.private || false, + }; + } finally { + clearTimeout(timeoutId); + } +} + +/** + * Fetches schema from Gradio endpoint + */ +async function fetchGradioSchema(subdomain: string, isPrivate: boolean, hfToken?: string): Promise { + const schemaUrl = `https://${subdomain}.hf.space/gradio_api/mcp/schema`; + + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (isPrivate && hfToken) { + headers['X-HF-Authorization'] = `Bearer ${hfToken}`; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + try { + const response = await fetch(schemaUrl, { + method: 'GET', + headers, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const schemaResponse = (await response.json()) as unknown; + + // Parse schema response (handle both array and object formats) + return parseSchemaResponse(schemaResponse); + } finally { + clearTimeout(timeoutId); + } +} + +/** + * Parses schema response and extracts tools + */ +function parseSchemaResponse(schemaResponse: unknown): Tool[] { + const tools: Tool[] = []; + + if (Array.isArray(schemaResponse)) { + // Array format: [{ name: "toolName", description: "...", inputSchema: {...} }, ...] + for (const item of schemaResponse) { + if ( + typeof item === 'object' && + item !== null && + 'name' in item && + 'inputSchema' in item + ) { + const itemRecord = item as Record; + if (typeof itemRecord.name === 'string') { + const tool = itemRecord as { name: string; description?: string; inputSchema: unknown }; + tools.push({ + name: tool.name, + description: tool.description || `${tool.name} tool`, + inputSchema: { + type: 'object', + ...(tool.inputSchema as Record), + }, + }); + } + } + } + } else if (typeof schemaResponse === 'object' && schemaResponse !== null) { + // Object format: { "toolName": { properties: {...}, required: [...] }, ... } + for (const [name, toolSchema] of Object.entries(schemaResponse)) { + if (typeof toolSchema === 'object' && toolSchema !== null) { + const schema = toolSchema as { description?: string }; + tools.push({ + name, + description: schema.description || `${name} tool`, + inputSchema: { + type: 'object', + ...(toolSchema as Record), + }, + }); + } + } + } + + return tools.filter((tool) => !tool.name.toLowerCase().includes(' + ): Promise { + const requestedOperation = params.operation; + + // If no operation provided, return usage instructions + if (!requestedOperation) { + return { + formatted: USAGE_INSTRUCTIONS, + totalResults: 1, + resultsShared: 1, + }; + } + + // Validate operation + const normalizedOperation = requestedOperation.toLowerCase(); + if (!isOperationName(normalizedOperation)) { + return { + formatted: `Unknown operation: "${requestedOperation}" +Available operations: ${OPERATION_NAMES.join(', ')} + +Call this tool with no operation for full usage instructions.`, + totalResults: 0, + resultsShared: 0, + isError: true, + }; + } + + // Execute operation + try { + switch (normalizedOperation) { + case 'view_parameters': + return await this.handleViewParameters(params); + + case 'invoke': + return await this.handleInvoke(params, extra); + + default: + return { + formatted: `Unknown operation: "${requestedOperation}"`, + totalResults: 0, + resultsShared: 0, + isError: true, + }; + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { + formatted: `Error executing ${requestedOperation}: ${errorMessage}`, + totalResults: 0, + resultsShared: 0, + isError: true, + }; + } + } + + /** + * Handle view_parameters operation + */ + private async handleViewParameters(params: SpaceArgs): Promise { + if (!params.space_name) { + return { + formatted: `Error: Missing required parameter: "space_name" + +Example: +\`\`\`json +{ + "operation": "view_parameters", + "space_name": "username/space-name" +} +\`\`\``, + totalResults: 0, + resultsShared: 0, + isError: true, + }; + } + + return await viewParameters(params.space_name, this.hfToken); + } + + /** + * Handle invoke operation + * Returns either InvokeResult (with raw MCP content) or ToolResult (error messages) + */ + private async handleInvoke( + params: SpaceArgs, + extra?: RequestHandlerExtra + ): Promise { + // Validate required parameters + if (!params.space_name) { + return { + formatted: `Error: Missing required parameter: "space_name" + +Example: +\`\`\`json +{ + "operation": "invoke", + "space_name": "username/space-name", + "parameters": "{\\"param1\\": \\"value1\\"}" +} +\`\`\``, + totalResults: 0, + resultsShared: 0, + isError: true, + }; + } + + if (!params.parameters) { + return { + formatted: `Error: Missing required parameter: "parameters" + +The "parameters" field must be a JSON object string containing the space parameters. + +Example: +\`\`\`json +{ + "operation": "invoke", + "space_name": "${params.space_name}", + "parameters": "{\\"param1\\": \\"value1\\", \\"param2\\": 42}" +} +\`\`\` + +Use "view_parameters" to see what parameters this space accepts.`, + totalResults: 0, + resultsShared: 0, + isError: true, + }; + } + + return await invokeSpace(params.space_name, params.parameters, this.hfToken, extra); + } +} + +/** + * Type guard for operation names + */ +function isOperationName(value: string): value is OperationName { + return (OPERATION_NAMES as readonly string[]).includes(value); +} diff --git a/packages/mcp/src/space/types.ts b/packages/mcp/src/space/types.ts new file mode 100644 index 0000000..5b69514 --- /dev/null +++ b/packages/mcp/src/space/types.ts @@ -0,0 +1,127 @@ +import { z } from 'zod'; + +/** + * Operations supported by the space tool + */ +export const OPERATION_NAMES = ['view_parameters', 'invoke'] as const; +export type OperationName = (typeof OPERATION_NAMES)[number]; + +/** + * Zod schema for operation arguments + */ +export const spaceArgsSchema = z.object({ + operation: z + .enum(OPERATION_NAMES) + .optional() + .describe('Operation to execute. Valid values: "view_parameters", "invoke"'), + space_name: z.string().optional().describe('The Hugging Face space ID (format: "username/space-name")'), + parameters: z.string().optional().describe('For invoke operation: JSON object string of parameters'), +}); + +export type SpaceArgs = z.infer; + +/** + * Parameter information extracted from schema + */ +export interface ParameterInfo { + name: string; + type: string; + description?: string; + required: boolean; + default?: unknown; + enum?: unknown[]; + isFileData?: boolean; + complexType?: string; // Reason why the type is complex +} + +/** + * Result of schema complexity analysis + */ +export interface SchemaComplexityResult { + isSimple: boolean; + reason?: string; // Reason if not simple + parameters: ParameterInfo[]; + toolName: string; + toolDescription?: string; +} + +/** + * Result of parameter processing + */ +export interface ProcessParametersResult { + valid: boolean; + parameters?: Record; + error?: string; + warnings?: string[]; +} + +/** + * JSON Schema property definition + */ +export interface JsonSchemaProperty { + type?: string; + title?: string; + description?: string; + default?: unknown; + enum?: unknown[]; + format?: string; + properties?: Record; + items?: JsonSchemaProperty; + required?: string[]; + [key: string]: unknown; +} + +/** + * JSON Schema definition + */ +export interface JsonSchema { + type?: string; + properties?: Record; + required?: string[]; + description?: string; + [key: string]: unknown; +} + +/** + * File input help message constant + */ +export const FILE_INPUT_HELP_MESSAGE = + 'Provide a publicly accessible URL (http:// or https://) pointing to the file. ' + + 'To upload local files, use the dedicated gr_* prefixed tool for this space, which supports file upload.'; + +/** + * Check if a property is a FileData type + */ +export function isFileDataProperty(prop: JsonSchemaProperty): boolean { + return ( + prop.title === 'ImageData' || + prop.title === 'FileData' || + (prop.format?.includes('http') && prop.format?.includes('file')) || + false + ); +} + +/** + * Check if a schema contains file data + */ +export function hasFileData(schema: JsonSchema): boolean { + if (!schema.properties) return false; + + return Object.values(schema.properties).some((prop) => isFileDataProperty(prop)); +} + +/** + * Extended result type for invoke operation that includes raw MCP result + * This allows the space tool to return structured content blocks instead of formatted text + */ +export interface InvokeResult { + result: { + content: unknown[]; + isError: boolean; + [key: string]: unknown; + }; + warnings: string[]; + totalResults: number; + resultsShared: number; + isError?: boolean; +} diff --git a/packages/mcp/src/space/utils/parameter-formatter.ts b/packages/mcp/src/space/utils/parameter-formatter.ts new file mode 100644 index 0000000..628c9ae --- /dev/null +++ b/packages/mcp/src/space/utils/parameter-formatter.ts @@ -0,0 +1,200 @@ +import type { SchemaComplexityResult, ParameterInfo } from '../types.js'; +import { FILE_INPUT_HELP_MESSAGE } from '../types.js'; + +/** + * Formats parameter schema for display to users + * Shows parameters in a clear, organized manner with: + * - Required parameters first + * - Alphabetical sorting within groups + * - Clear type information + * - Default values + * - Enum options + */ +export function formatParameters(schemaResult: SchemaComplexityResult, spaceName: string): string { + const { toolName, toolDescription, parameters } = schemaResult; + + let output = `# Parameters for: ${toolName}\n\n`; + + if (toolDescription) { + output += `**Description:** ${toolDescription}\n\n`; + } + + // Sort parameters: required first, then alphabetical + const sortedParams = [...parameters].sort((a, b) => { + if (a.required !== b.required) { + return a.required ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + + output += `## Parameters:\n\n`; + + for (const param of sortedParams) { + output += formatParameter(param); + } + + // Add usage example + output += '\n## Usage Example:\n\n'; + output += formatUsageExample(spaceName, parameters); + + return output; +} + +/** + * Formats a single parameter for display + */ +function formatParameter(param: ParameterInfo): string { + const badge = param.required ? '[REQUIRED]' : '[OPTIONAL]'; + let output = `### ${param.name} ${badge}\n`; + + // Type + output += `- **Type:** ${param.type}\n`; + + // Description + if (param.description) { + output += `- **Description:** ${param.description}\n`; + } + + // Default value + if (param.default !== undefined) { + const defaultStr = formatValue(param.default); + output += `- **Default:** ${defaultStr}\n`; + } + + // Enum values + if (param.enum && param.enum.length > 0) { + const enumStr = param.enum.map((v) => formatValue(v)).join(', '); + output += `- **Allowed values:** ${enumStr}\n`; + } + + // File input help + if (param.isFileData) { + output += `- **Note:** ${FILE_INPUT_HELP_MESSAGE}\n`; + } + + output += '\n'; + return output; +} + +/** + * Formats a value for display + */ +function formatValue(value: unknown): string { + if (value === null) return 'null'; + if (value === undefined) return 'undefined'; + if (typeof value === 'string') return `"${value}"`; + if (typeof value === 'object') { + try { + return JSON.stringify(value); + } catch { + return '[object]'; + } + } + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + return JSON.stringify(value); +} + +/** + * Formats a usage example + */ +function formatUsageExample(spaceName: string, parameters: ParameterInfo[]): string { + // Create example with required parameters and some optional ones + const exampleParams: Record = {}; + + // Add required parameters + for (const param of parameters.filter((p) => p.required)) { + exampleParams[param.name] = getExampleValue(param); + } + + // Add one or two optional parameters if they exist + const optionalParams = parameters.filter((p) => !p.required); + for (let i = 0; i < Math.min(2, optionalParams.length); i++) { + const param = optionalParams[i]; + if (param) { + exampleParams[param.name] = getExampleValue(param); + } + } + + const paramsJson = JSON.stringify(exampleParams, null, 2) + .split('\n') + .map((line) => ` ${line}`) + .join('\n') + .trim(); + + return `\`\`\`json +{ + "operation": "invoke", + "space_name": "${spaceName}", + "parameters": "${paramsJson.replace(/"/g, '\\"')}" +} +\`\`\``; +} + +/** + * Gets an example value for a parameter + */ +function getExampleValue(param: ParameterInfo): string { + // Use default if available + if (param.default !== undefined) { + return formatValue(param.default); + } + + // Use first enum value if available + if (param.enum && param.enum.length > 0) { + return formatValue(param.enum[0]); + } + + // File data + if (param.isFileData) { + return '"https://example.com/file.jpg"'; + } + + // Generate example based on type + const baseType = param.type.split(' ')[0]?.split('<')[0]; + + switch (baseType) { + case 'string': + return '"example value"'; + case 'number': + case 'integer': + return '42'; + case 'boolean': + return 'true'; + case 'array': + return '["item1", "item2"]'; + case 'object': + return '{"key": "value"}'; + default: + return '"value"'; + } +} + +/** + * Formats a complex schema error message + */ +export function formatComplexSchemaError(spaceName: string, reason: string): string { + return `Error: Schema too complex for space '${spaceName}'. + +${reason} + +Supported types: strings, numbers, booleans, arrays of primitives, enums, shallow objects, and file URLs. + +For this space, use the dedicated gr_* prefixed tools instead.`; +} + +/** + * Formats validation errors + */ +export function formatValidationError(errors: string[], spaceName: string): string { + let output = `Error: Invalid parameters for space '${spaceName}'.\n\n`; + + for (const error of errors) { + output += `- ${error}\n`; + } + + output += `\nUse the view_parameters operation to see all required parameters and their types.`; + + return output; +} diff --git a/packages/mcp/src/space/utils/result-formatter.ts b/packages/mcp/src/space/utils/result-formatter.ts new file mode 100644 index 0000000..672e2a5 --- /dev/null +++ b/packages/mcp/src/space/utils/result-formatter.ts @@ -0,0 +1,226 @@ +import type { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; + +/** + * Formats tool result for user-friendly display + * Handles different content types: + * - Text content (primary) + * - Image content (with descriptions) + * - Resource content (with URIs) + * - Error results + */ +export function formatToolResult(result: typeof CallToolResultSchema._type): string { + // Handle error results + if (result.isError) { + return formatErrorResult(result); + } + + // Handle successful results with content + if (Array.isArray(result.content) && result.content.length > 0) { + return formatContentArray(result.content); + } + + // Fallback for empty results + return 'Tool executed successfully (no content returned).'; +} + +/** + * Formats error results + */ +function formatErrorResult(result: typeof CallToolResultSchema._type): string { + if (!Array.isArray(result.content) || result.content.length === 0) { + return 'Error: Tool execution failed (no error details provided).'; + } + + const errorMessages: string[] = []; + + for (const item of result.content) { + if (typeof item === 'string') { + errorMessages.push(item); + } else if (item && typeof item === 'object') { + const obj = item as Record; + if (typeof obj.text === 'string') { + errorMessages.push(obj.text); + } else if (typeof obj.message === 'string') { + errorMessages.push(obj.message); + } else if (typeof obj.error === 'string') { + errorMessages.push(obj.error); + } + } + } + + if (errorMessages.length > 0) { + return `Error: ${errorMessages.join('\n')}`; + } + + return 'Error: Tool execution failed.'; +} + +/** + * Formats an array of content items + */ +function formatContentArray(content: unknown[]): string { + const formattedItems: string[] = []; + + for (const item of content) { + const formatted = formatContentItem(item); + if (formatted) { + formattedItems.push(formatted); + } + } + + if (formattedItems.length === 0) { + return 'Tool executed successfully (no displayable content).'; + } + + return formattedItems.join('\n\n'); +} + +/** + * Formats a single content item + */ +function formatContentItem(item: unknown): string | null { + if (!item) { + return null; + } + + // Handle string content + if (typeof item === 'string') { + return item; + } + + // Handle non-object content + if (typeof item !== 'object') { + if (typeof item === 'number' || typeof item === 'boolean') { + return String(item); + } + return JSON.stringify(item); + } + + const obj = item as Record; + const type = typeof obj.type === 'string' ? obj.type.toLowerCase() : undefined; + + switch (type) { + case 'text': + return formatTextContent(obj); + + case 'image': + return formatImageContent(obj); + + case 'resource': + return formatResourceContent(obj); + + case 'embedded_resource': + return formatEmbeddedResourceContent(obj); + + default: + // Try to extract text from unknown types + if (typeof obj.text === 'string') { + return obj.text; + } + // Fallback to JSON representation + try { + return JSON.stringify(item, null, 2); + } catch { + return '[complex object]'; + } + } +} + +/** + * Formats text content + */ +function formatTextContent(obj: Record): string | null { + if (typeof obj.text === 'string') { + return obj.text; + } + return null; +} + +/** + * Formats image content + */ +function formatImageContent(obj: Record): string { + const parts: string[] = ['[Image Content]']; + + // Add MIME type if available + if (typeof obj.mimeType === 'string') { + parts.push(`Type: ${obj.mimeType}`); + } + + // Add URL if available + if (typeof obj.url === 'string') { + parts.push(`URL: ${obj.url}`); + } + + // Add data indicator if present + if (typeof obj.data === 'string') { + const dataLength = obj.data.length; + parts.push(`Data: ${dataLength} characters (base64)`); + } + + return parts.join('\n'); +} + +/** + * Formats resource content + */ +function formatResourceContent(obj: Record): string { + const parts: string[] = ['[Resource]']; + + // Extract resource details + const resource = obj.resource as Record | undefined; + if (resource) { + if (typeof resource.uri === 'string') { + parts.push(`URI: ${resource.uri}`); + } + + if (typeof resource.name === 'string') { + parts.push(`Name: ${resource.name}`); + } + + if (typeof resource.mimeType === 'string') { + parts.push(`Type: ${resource.mimeType}`); + } + + if (typeof resource.description === 'string') { + parts.push(`Description: ${resource.description}`); + } + } + + return parts.join('\n'); +} + +/** + * Formats embedded resource content + */ +function formatEmbeddedResourceContent(obj: Record): string { + const parts: string[] = ['[Embedded Resource]']; + + if (typeof obj.uri === 'string') { + parts.push(`URI: ${obj.uri}`); + } + + if (typeof obj.mimeType === 'string') { + parts.push(`Type: ${obj.mimeType}`); + } + + // Add blob indicator if present + if (typeof obj.blob === 'string') { + const blobLength = obj.blob.length; + parts.push(`Data: ${blobLength} characters`); + } + + return parts.join('\n'); +} + +/** + * Formats a list of warnings + */ +export function formatWarnings(warnings: string[]): string { + if (warnings.length === 0) { + return ''; + } + + const header = warnings.length === 1 ? 'Warning:' : 'Warnings:'; + return `${header}\n${warnings.map((w) => `- ${w}`).join('\n')}\n\n`; +} diff --git a/packages/mcp/src/space/utils/schema-validator.ts b/packages/mcp/src/space/utils/schema-validator.ts new file mode 100644 index 0000000..51defcb --- /dev/null +++ b/packages/mcp/src/space/utils/schema-validator.ts @@ -0,0 +1,232 @@ +import type { Tool } from '@modelcontextprotocol/sdk/types.js'; +import type { + JsonSchema, + JsonSchemaProperty, + SchemaComplexityResult, + ParameterInfo, +} from '../types.js'; +import { isFileDataProperty } from '../types.js'; + +/** + * Analyzes a tool schema to determine if it's simple enough for the dynamic space tool + * + * Supported types: + * - string, number, boolean + * - enum (with predefined values) + * - array of primitives + * - shallow objects (one level deep with primitive properties) + * - FileData (as string URLs) + * + * Rejected types: + * - Deeply nested objects (2+ levels) + * - Arrays of objects + * - Union types + * - Recursive schemas + */ +export function analyzeSchemaComplexity(tool: Tool): SchemaComplexityResult { + const result: SchemaComplexityResult = { + isSimple: true, + parameters: [], + toolName: tool.name, + toolDescription: tool.description, + }; + + const inputSchema = tool.inputSchema as JsonSchema; + if (!inputSchema || !inputSchema.properties) { + return result; + } + + const properties = inputSchema.properties; + const required = inputSchema.required || []; + + for (const [paramName, prop] of Object.entries(properties)) { + const paramInfo = analyzeProperty(paramName, prop, required.includes(paramName)); + + // Check if this parameter is too complex + if (paramInfo.complexType) { + result.isSimple = false; + result.reason = `Parameter "${paramName}" has complex type: ${paramInfo.complexType}`; + return result; + } + + result.parameters.push(paramInfo); + } + + return result; +} + +/** + * Analyzes a single property to extract parameter information + */ +function analyzeProperty(name: string, prop: JsonSchemaProperty, isRequired: boolean): ParameterInfo { + const paramInfo: ParameterInfo = { + name, + type: prop.type || 'unknown', + description: prop.description, + required: isRequired, + default: prop.default, + enum: prop.enum, + }; + + // Check for FileData types - treat as string URLs + if (isFileDataProperty(prop)) { + paramInfo.type = 'string (file URL)'; + paramInfo.isFileData = true; + return paramInfo; + } + + // Check for enum types + if (prop.enum && Array.isArray(prop.enum) && prop.enum.length > 0) { + paramInfo.type = `enum (${prop.enum.length} values)`; + return paramInfo; + } + + // Check for complex types + if (prop.type === 'object') { + // Check if it's a shallow object + if (prop.properties) { + const nestedProps = Object.values(prop.properties); + const hasComplexNested = nestedProps.some( + (nested) => nested.type === 'object' || nested.type === 'array' + ); + + if (hasComplexNested) { + paramInfo.complexType = 'deeply nested object (2+ levels)'; + } else { + paramInfo.type = 'object (shallow)'; + } + } else { + // Object without properties definition is too vague + paramInfo.complexType = 'object without defined properties'; + } + return paramInfo; + } + + // Check for array types + if (prop.type === 'array') { + if (prop.items) { + const itemType = prop.items.type; + if (itemType === 'object') { + paramInfo.complexType = 'array of objects'; + } else if (itemType === 'array') { + paramInfo.complexType = 'nested arrays'; + } else { + paramInfo.type = `array<${itemType || 'unknown'}>`; + } + } else { + paramInfo.type = 'array'; + } + return paramInfo; + } + + // Simple types are fine + if (['string', 'number', 'integer', 'boolean'].includes(prop.type || '')) { + return paramInfo; + } + + // Unknown or unsupported type + if (!prop.type) { + paramInfo.complexType = 'unknown type'; + } + + return paramInfo; +} + +/** + * Validates parameter values against the schema + */ +export function validateParameters( + parameters: Record, + schemaResult: SchemaComplexityResult +): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + // Check for missing required parameters + const requiredParams = schemaResult.parameters.filter((p) => p.required); + for (const param of requiredParams) { + if (!(param.name in parameters) || parameters[param.name] === undefined) { + errors.push(`Missing required parameter: "${param.name}"`); + } + } + + // Check types (basic validation) + for (const [key, value] of Object.entries(parameters)) { + const paramInfo = schemaResult.parameters.find((p) => p.name === key); + + if (!paramInfo) { + // Unknown parameter - warning but not error (permissive inputs) + continue; + } + + // Type checking + if (value !== null && value !== undefined) { + if (!validateType(value, paramInfo)) { + errors.push( + `Parameter "${key}" should be type ${paramInfo.type}, got ${typeof value}` + ); + } + } + } + + return { + valid: errors.length === 0, + errors, + }; +} + +/** + * Validates a value against a parameter type + */ +function validateType(value: unknown, paramInfo: ParameterInfo): boolean { + // FileData as string URL + if (paramInfo.isFileData) { + return typeof value === 'string'; + } + + // Enum validation + if (paramInfo.enum && Array.isArray(paramInfo.enum)) { + return paramInfo.enum.includes(value); + } + + // Basic type validation + const baseType = paramInfo.type.split(' ')[0]?.split('<')[0]; // Extract base type + + switch (baseType) { + case 'string': + return typeof value === 'string'; + case 'number': + case 'integer': + return typeof value === 'number'; + case 'boolean': + return typeof value === 'boolean'; + case 'array': + return Array.isArray(value); + case 'object': + return typeof value === 'object' && value !== null && !Array.isArray(value); + case 'enum': + // Already checked above + return true; + default: + // Unknown type, allow it (permissive) + return true; + } +} + +/** + * Applies default values to parameters + */ +export function applyDefaults( + parameters: Record, + schemaResult: SchemaComplexityResult +): Record { + const result = { ...parameters }; + + for (const param of schemaResult.parameters) { + // Only apply defaults for optional parameters that are missing + if (!param.required && !(param.name in result) && param.default !== undefined) { + result[param.name] = param.default; + } + } + + return result; +} diff --git a/packages/mcp/src/tool-ids.ts b/packages/mcp/src/tool-ids.ts index 67faff3..97e4f3d 100644 --- a/packages/mcp/src/tool-ids.ts +++ b/packages/mcp/src/tool-ids.ts @@ -22,6 +22,7 @@ import { DOC_FETCH_CONFIG, USE_SPACE_TOOL_CONFIG, HF_JOBS_TOOL_CONFIG, + SPACE_TOOL_CONFIG, } from './index.js'; // Extract tool IDs from their configs (single source of truth) @@ -43,6 +44,7 @@ export const PAPER_SUMMARY_PROMPT_ID = PAPER_SUMMARY_PROMPT_CONFIG.name; export const MODEL_DETAIL_PROMPT_ID = MODEL_DETAIL_PROMPT_CONFIG.name; export const DATASET_DETAIL_PROMPT_ID = DATASET_DETAIL_PROMPT_CONFIG.name; export const HF_JOBS_TOOL_ID = HF_JOBS_TOOL_CONFIG.name; +export const SPACE_TOOL_ID = SPACE_TOOL_CONFIG.name; // Complete list of all built-in tool IDs export const ALL_BUILTIN_TOOL_IDS = [ @@ -60,6 +62,7 @@ export const ALL_BUILTIN_TOOL_IDS = [ DOC_FETCH_TOOL_ID, USE_SPACE_TOOL_ID, HF_JOBS_TOOL_ID, + SPACE_TOOL_ID, ] as const; // Grouped tool IDs for bouquet configurations export const TOOL_ID_GROUPS = { @@ -76,6 +79,7 @@ export const TOOL_ID_GROUPS = { SPACE_INFO_TOOL_ID, SPACE_FILES_TOOL_ID, USE_SPACE_TOOL_ID, + SPACE_TOOL_ID, ] as const, detail: [MODEL_DETAIL_TOOL_ID, DATASET_DETAIL_TOOL_ID, HUB_INSPECT_TOOL_ID] as const, docs: [DOCS_SEMANTIC_SEARCH_TOOL_ID, DOC_FETCH_TOOL_ID] as const, From f9581592998aa62c7bc8ae789cb5313b5b2d92a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Nov 2025 18:20:15 +0000 Subject: [PATCH 03/15] feat: register space tool with raw MCP content pass-through - Add SPACE_TOOL_CONFIG, SpaceTool, SpaceArgs, and InvokeResult imports - Import applyResultPostProcessing from gradio-tool-caller - Register space tool in mcp-server.ts with proper InvokeResult handling - Apply unified post-processing (image filtering + OpenAI transforms) - Handle warnings from invoke operation - Return raw MCP content blocks for invoke, formatted text for view_parameters - Respect NO_GRADIO_IMAGE_CONTENT flag for image filtering - Space tool now behaves identically to proxied gr_* tools --- packages/app/src/server/mcp-server.ts | 76 +++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/packages/app/src/server/mcp-server.ts b/packages/app/src/server/mcp-server.ts index c9c6faf..84738a6 100644 --- a/packages/app/src/server/mcp-server.ts +++ b/packages/app/src/server/mcp-server.ts @@ -54,6 +54,10 @@ import { type DocFetchParams, HF_JOBS_TOOL_CONFIG, HfJobsTool, + SPACE_TOOL_CONFIG, + SpaceTool, + type SpaceArgs, + type InvokeResult, } from '@llmindset/hf-mcp'; import type { ServerFactory, ServerFactoryResult } from './transport/base-transport.js'; @@ -67,6 +71,7 @@ import { ToolSelectionStrategy, type ToolSelectionContext } from './utils/tool-s import { hasReadmeFlag } from '../shared/behavior-flags.js'; import { registerCapabilities } from './utils/capability-utils.js'; import { createGradioWidgetResourceConfig } from './resources/gradio-widget-resource.js'; +import { applyResultPostProcessing, type GradioToolCallOptions } from './utils/gradio-tool-caller.js'; // Fallback settings when API fails (enables all tools) export const BOUQUET_FALLBACK: AppSettings = { @@ -686,6 +691,77 @@ export const createServerFactory = (_webServerInstance: WebServer, sharedApiClie } ); + toolInstances[SPACE_TOOL_CONFIG.name] = server.tool( + SPACE_TOOL_CONFIG.name, + SPACE_TOOL_CONFIG.description, + SPACE_TOOL_CONFIG.schema.shape, + SPACE_TOOL_CONFIG.annotations, + async (params: SpaceArgs, extra) => { + const spaceTool = new SpaceTool(hfToken); + const result = await spaceTool.execute(params, extra); + + // Check if this is an InvokeResult (has raw MCP content from invoke operation) + if ('result' in result && result.result) { + const invokeResult = result as InvokeResult; + + // Prepare post-processing options + const stripImageContent = toolSelection.enabledToolIds.includes('NO_GRADIO_IMAGE_CONTENT'); + const postProcessOptions: GradioToolCallOptions = { + stripImageContent, + toolName: SPACE_TOOL_CONFIG.name, + outwardFacingName: SPACE_TOOL_CONFIG.name, + sessionInfo, + spaceName: params.space_name, + }; + + // Apply unified post-processing (image filtering + OpenAI transforms) + const processedResult = applyResultPostProcessing(invokeResult.result, postProcessOptions); + + // Prepend warnings if any + const warningsContent = + invokeResult.warnings.length > 0 + ? [ + { + type: 'text' as const, + text: + (invokeResult.warnings.length === 1 ? 'Warning:\n' : 'Warnings:\n') + + invokeResult.warnings.map((w) => `- ${w}`).join('\n') + + '\n', + }, + ] + : []; + + // Log the query with operation and result metrics + const loggedOperation = params.operation ?? 'no-operation'; + logSearchQuery(SPACE_TOOL_CONFIG.name, loggedOperation, params, { + ...getLoggingOptions(), + totalResults: invokeResult.totalResults, + resultsShared: invokeResult.resultsShared, + responseCharCount: JSON.stringify(processedResult.content).length, + }); + + return { + content: [...warningsContent, ...processedResult.content], + ...(invokeResult.isError && { isError: true }), + }; + } + + // For view_parameters and errors - return formatted text + const loggedOperation = params.operation ?? 'no-operation'; + logSearchQuery(SPACE_TOOL_CONFIG.name, loggedOperation, params, { + ...getLoggingOptions(), + totalResults: result.totalResults, + resultsShared: result.resultsShared, + responseCharCount: result.formatted.length, + }); + + return { + content: [{ type: 'text', text: result.formatted }], + ...(result.isError && { isError: true }), + }; + } + ); + // Register Gradio widget resource for OpenAI MCP client (skybridge) if (sessionInfo?.clientInfo?.name === 'openai-mcp') { logger.debug('Registering Gradio widget resource for skybridge client'); From ca2dd91725edca13401c31642d3fc92a94f52053 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Nov 2025 18:33:25 +0000 Subject: [PATCH 04/15] fix: remove unused imports and functions from gradio-endpoint-connector - Remove unused RequestOptions import - Remove unused stripImageContentFromResult and extractUrlFromContent imports - Remove unused createLazyConnection function (replaced by callGradioTool) - Fixes ESLint errors --- .../src/server/gradio-endpoint-connector.ts | 54 +------------------ 1 file changed, 1 insertion(+), 53 deletions(-) diff --git a/packages/app/src/server/gradio-endpoint-connector.ts b/packages/app/src/server/gradio-endpoint-connector.ts index 9108cae..5bd00dc 100644 --- a/packages/app/src/server/gradio-endpoint-connector.ts +++ b/packages/app/src/server/gradio-endpoint-connector.ts @@ -7,7 +7,7 @@ import { type Tool, } from '@modelcontextprotocol/sdk/types.js'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import type { RequestHandlerExtra, RequestOptions } from '@modelcontextprotocol/sdk/shared/protocol.js'; +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; import { logger } from './utils/logger.js'; import { logGradioEvent } from './utils/query-logger.js'; import { z } from 'zod'; @@ -17,7 +17,6 @@ import { gradioMetrics, getMetricsSafeName } from './utils/gradio-metrics.js'; import { createGradioToolName } from './utils/gradio-utils.js'; import { createAudioPlayerUIResource } from './utils/ui/audio-player.js'; import { spaceMetadataCache, CACHE_CONFIG } from './utils/gradio-cache.js'; -import { stripImageContentFromResult, extractUrlFromContent } from './utils/gradio-result-processor.js'; import { callGradioTool, applyResultPostProcessing, type GradioToolCallOptions } from './utils/gradio-tool-caller.js'; // Define types for JSON Schema @@ -342,57 +341,6 @@ export async function connectToGradioEndpoints( return results; } -/** - * Creates SSE connection to endpoint when needed for tool execution - */ -async function createLazyConnection(sseUrl: string, hfToken: string | undefined): Promise { - logger.debug({ url: sseUrl }, 'Creating lazy SSE connection for tool execution'); - - // Create MCP client - const remoteClient = new Client( - { - name: 'hf-mcp-proxy-client', - version: '1.0.0', - }, - { - capabilities: {}, - } - ); - - // Create SSE transport with HF token if available - const transportOptions: SSEClientTransportOptions = {}; - if (hfToken) { - const headerName = 'X-HF-Authorization'; - const customHeaders = { - [headerName]: `Bearer ${hfToken}`, - }; - logger.trace(`connection to gradio endpoint with ${headerName} header`); - // Headers for POST requests - transportOptions.requestInit = { - headers: customHeaders, - }; - - // Headers for SSE connection - transportOptions.eventSourceInit = { - fetch: (url, init) => { - const headers = new Headers(init.headers); - Object.entries(customHeaders).forEach(([key, value]) => { - headers.set(key, value); - }); - return fetch(url.toString(), { ...init, headers }); - }, - }; - } - logger.debug(`MCP Client connection contains token? (${undefined != hfToken})`); - const transport = new SSEClientTransport(new URL(sseUrl), transportOptions); - - // Connect the client to the transport - await remoteClient.connect(transport); - logger.debug('Lazy SSE connection established'); - - return remoteClient; -} - /** * Creates the display information for a tool */ From 2ee13a9c0da425b7b989687ad7dd5fcfed841661 Mon Sep 17 00:00:00 2001 From: evalstate <1936278+evalstate@users.noreply.github.com> Date: Mon, 10 Nov 2025 18:50:11 +0000 Subject: [PATCH 05/15] tweak import --- packages/app/src/server/utils/gradio-result-processor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/server/utils/gradio-result-processor.ts b/packages/app/src/server/utils/gradio-result-processor.ts index 9a7dbec..1613471 100644 --- a/packages/app/src/server/utils/gradio-result-processor.ts +++ b/packages/app/src/server/utils/gradio-result-processor.ts @@ -1,4 +1,4 @@ -import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; +import type { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; import { logger } from './logger.js'; /** From bad707247d5988b638a2cc377263a272976eb156 Mon Sep 17 00:00:00 2001 From: evalstate <1936278+evalstate@users.noreply.github.com> Date: Mon, 10 Nov 2025 18:52:16 +0000 Subject: [PATCH 06/15] import weak --- packages/app/src/server/gradio-endpoint-connector.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/app/src/server/gradio-endpoint-connector.ts b/packages/app/src/server/gradio-endpoint-connector.ts index 9108cae..7e5a9ed 100644 --- a/packages/app/src/server/gradio-endpoint-connector.ts +++ b/packages/app/src/server/gradio-endpoint-connector.ts @@ -1,7 +1,8 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { SSEClientTransport, type SSEClientTransportOptions } from '@modelcontextprotocol/sdk/client/sse.js'; +import type { + CallToolResultSchema} from '@modelcontextprotocol/sdk/types.js'; import { - CallToolResultSchema, type ServerNotification, type ServerRequest, type Tool, @@ -431,7 +432,7 @@ function createToolHandler( let success = false; let error: string | undefined; let responseSizeBytes: number | undefined; - let notificationCount = 0; + const notificationCount = 0; try { // Validate SSE URL From 9af6a965374841090f7d485780b322b36800f1e0 Mon Sep 17 00:00:00 2001 From: evalstate <1936278+evalstate@users.noreply.github.com> Date: Mon, 10 Nov 2025 19:03:05 +0000 Subject: [PATCH 07/15] lints --- packages/app/src/server/gradio-endpoint-connector.ts | 12 +++--------- packages/app/src/server/utils/gradio-tool-caller.ts | 2 +- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/app/src/server/gradio-endpoint-connector.ts b/packages/app/src/server/gradio-endpoint-connector.ts index 6a25052..dd30066 100644 --- a/packages/app/src/server/gradio-endpoint-connector.ts +++ b/packages/app/src/server/gradio-endpoint-connector.ts @@ -1,12 +1,6 @@ -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { SSEClientTransport, type SSEClientTransportOptions } from '@modelcontextprotocol/sdk/client/sse.js'; -import type { - CallToolResultSchema} from '@modelcontextprotocol/sdk/types.js'; -import { - type ServerNotification, - type ServerRequest, - type Tool, -} from '@modelcontextprotocol/sdk/types.js'; +import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import type { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; +import { type ServerNotification, type ServerRequest, type Tool } from '@modelcontextprotocol/sdk/types.js'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; import { logger } from './utils/logger.js'; diff --git a/packages/app/src/server/utils/gradio-tool-caller.ts b/packages/app/src/server/utils/gradio-tool-caller.ts index 5b48325..485072d 100644 --- a/packages/app/src/server/utils/gradio-tool-caller.ts +++ b/packages/app/src/server/utils/gradio-tool-caller.ts @@ -115,7 +115,7 @@ export async function callGradioTool( logger.debug({ tool: toolName, progressToken }, 'Progress notifications requested'); // Set up progress relay from remote tool to our client - // eslint-disable-next-line @typescript-eslint/no-misused-promises + requestOptions.onprogress = async (progress) => { logger.trace({ tool: toolName, progressToken, progress }, 'Relaying progress notification'); From 595b424b105e3bcac5a34163a78b69f01a14f15b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Nov 2025 19:04:38 +0000 Subject: [PATCH 08/15] fix: resolve TypeScript build errors in unified Gradio implementation - Make InvokeResult.result.isError optional to match CallToolResultSchema - Add type cast for invokeResult.result when calling applyResultPostProcessing - Add type cast for ToolResult in view_parameters path - Remove unused SSEClientTransport imports from gradio-endpoint-connector - Import CallToolResultSchema in mcp-server for type assertions This fixes type compatibility issues between MCP schema types and the InvokeResult interface, allowing both invoke and view_parameters operations to work correctly with proper type safety. --- packages/app/src/server/mcp-server.ts | 21 +++++++++++++-------- packages/mcp/src/space/types.ts | 2 +- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/app/src/server/mcp-server.ts b/packages/app/src/server/mcp-server.ts index 84738a6..780e359 100644 --- a/packages/app/src/server/mcp-server.ts +++ b/packages/app/src/server/mcp-server.ts @@ -1,4 +1,5 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; import type { z } from 'zod'; import { createRequire } from 'module'; import { whoAmI, type WhoAmI } from '@huggingface/hub'; @@ -715,7 +716,10 @@ export const createServerFactory = (_webServerInstance: WebServer, sharedApiClie }; // Apply unified post-processing (image filtering + OpenAI transforms) - const processedResult = applyResultPostProcessing(invokeResult.result, postProcessOptions); + const processedResult = applyResultPostProcessing( + invokeResult.result as typeof CallToolResultSchema._type, + postProcessOptions + ); // Prepend warnings if any const warningsContent = @@ -741,23 +745,24 @@ export const createServerFactory = (_webServerInstance: WebServer, sharedApiClie }); return { - content: [...warningsContent, ...processedResult.content], + content: [...warningsContent, ...(processedResult.content as unknown[])], ...(invokeResult.isError && { isError: true }), - }; + } as typeof CallToolResultSchema._type; } // For view_parameters and errors - return formatted text + const toolResult = result as import('@llmindset/hf-mcp').ToolResult; const loggedOperation = params.operation ?? 'no-operation'; logSearchQuery(SPACE_TOOL_CONFIG.name, loggedOperation, params, { ...getLoggingOptions(), - totalResults: result.totalResults, - resultsShared: result.resultsShared, - responseCharCount: result.formatted.length, + totalResults: toolResult.totalResults, + resultsShared: toolResult.resultsShared, + responseCharCount: toolResult.formatted.length, }); return { - content: [{ type: 'text', text: result.formatted }], - ...(result.isError && { isError: true }), + content: [{ type: 'text', text: toolResult.formatted }], + ...(toolResult.isError && { isError: true }), }; } ); diff --git a/packages/mcp/src/space/types.ts b/packages/mcp/src/space/types.ts index 5b69514..061bfa0 100644 --- a/packages/mcp/src/space/types.ts +++ b/packages/mcp/src/space/types.ts @@ -117,7 +117,7 @@ export function hasFileData(schema: JsonSchema): boolean { export interface InvokeResult { result: { content: unknown[]; - isError: boolean; + isError?: boolean; [key: string]: unknown; }; warnings: string[]; From 59ed3da00fbee7cb0afe334d006c9e6e231859cb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Nov 2025 19:17:35 +0000 Subject: [PATCH 09/15] fix: add ToolResult import to resolve linting error - Import ToolResult type from @llmindset/hf-mcp - Replace inline import() type annotation with imported type - Fix @typescript-eslint/consistent-type-imports violation The linter also updated CallToolResultSchema to use type import for better tree-shaking. --- packages/app/src/server/mcp-server.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/app/src/server/mcp-server.ts b/packages/app/src/server/mcp-server.ts index 780e359..9bb4d0a 100644 --- a/packages/app/src/server/mcp-server.ts +++ b/packages/app/src/server/mcp-server.ts @@ -1,5 +1,5 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; +import type { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; import type { z } from 'zod'; import { createRequire } from 'module'; import { whoAmI, type WhoAmI } from '@huggingface/hub'; @@ -59,6 +59,7 @@ import { SpaceTool, type SpaceArgs, type InvokeResult, + type ToolResult, } from '@llmindset/hf-mcp'; import type { ServerFactory, ServerFactoryResult } from './transport/base-transport.js'; @@ -751,7 +752,7 @@ export const createServerFactory = (_webServerInstance: WebServer, sharedApiClie } // For view_parameters and errors - return formatted text - const toolResult = result as import('@llmindset/hf-mcp').ToolResult; + const toolResult = result as ToolResult; const loggedOperation = params.operation ?? 'no-operation'; logSearchQuery(SPACE_TOOL_CONFIG.name, loggedOperation, params, { ...getLoggingOptions(), From 258415eda55fadf94488f20e369affcb2ac80c76 Mon Sep 17 00:00:00 2001 From: evalstate <1936278+evalstate@users.noreply.github.com> Date: Mon, 10 Nov 2025 20:27:00 +0000 Subject: [PATCH 10/15] improve no image content handling for new dynamic tool --- packages/app/src/server/mcp-server.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/app/src/server/mcp-server.ts b/packages/app/src/server/mcp-server.ts index 9bb4d0a..de77c47 100644 --- a/packages/app/src/server/mcp-server.ts +++ b/packages/app/src/server/mcp-server.ts @@ -183,6 +183,9 @@ export const createServerFactory = (_webServerInstance: WebServer, sharedApiClie hfToken, }; const toolSelection = await toolSelectionStrategy.selectTools(toolSelectionContext); + const rawNoImageHeader = headers?.['x-mcp-no-image-content']; + const noImageContentHeaderEnabled = + typeof rawNoImageHeader === 'string' && rawNoImageHeader.trim().toLowerCase() === 'true'; // Always register all tools and store instances for dynamic control const toolInstances: { [name: string]: Tool } = {}; @@ -707,7 +710,9 @@ export const createServerFactory = (_webServerInstance: WebServer, sharedApiClie const invokeResult = result as InvokeResult; // Prepare post-processing options - const stripImageContent = toolSelection.enabledToolIds.includes('NO_GRADIO_IMAGE_CONTENT'); + const stripImageContent = + noImageContentHeaderEnabled || + toolSelection.enabledToolIds.includes('NO_GRADIO_IMAGE_CONTENT'); const postProcessOptions: GradioToolCallOptions = { stripImageContent, toolName: SPACE_TOOL_CONFIG.name, From ccc15248e430b4c35ae4f732799659b4ab0a07ff Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Nov 2025 20:53:32 +0000 Subject: [PATCH 11/15] feat: improve space tool invoke logging and add gradio=none support 1. Switch space tool invoke operation to use logGradioEvent: - Track timing metrics (durationMs) like proxied gr_* tools - Log success/error status and response size - Add isDynamic: true flag to distinguish dynamic invocations from proxied tools - Keep logSearchQuery for view_parameters operation 2. Disable invoke operation when gradio=none: - Check for gradio=none header before allowing invoke operation - Return helpful error message if invoke is attempted with gradio=none - Keep view_parameters operation available regardless of gradio setting 3. Add isDynamic parameter to logGradioEvent: - New boolean column in Gradio dataset to differentiate: * isDynamic: true = space tool invoke (dynamic invocation) * isDynamic: false = proxied gr_* tool (static endpoint) - Defaults to false for backward compatibility This provides consistent logging across all Gradio interactions while clearly distinguishing between proxied tools and dynamic space tool invocations in the dataset. --- packages/app/src/server/mcp-server.ts | 162 +++++++++++------- packages/app/src/server/utils/query-logger.ts | 2 + 2 files changed, 104 insertions(+), 60 deletions(-) diff --git a/packages/app/src/server/mcp-server.ts b/packages/app/src/server/mcp-server.ts index de77c47..1a916af 100644 --- a/packages/app/src/server/mcp-server.ts +++ b/packages/app/src/server/mcp-server.ts @@ -66,7 +66,7 @@ import type { ServerFactory, ServerFactoryResult } from './transport/base-transp import type { McpApiClient } from './utils/mcp-api-client.js'; import type { WebServer } from './web-server.js'; import { logger } from './utils/logger.js'; -import { logSearchQuery, logPromptQuery } from './utils/query-logger.js'; +import { logSearchQuery, logPromptQuery, logGradioEvent } from './utils/query-logger.js'; import { DEFAULT_SPACE_TOOLS, type AppSettings } from '../shared/settings.js'; import { extractAuthBouquetAndMix } from './utils/auth-utils.js'; import { ToolSelectionStrategy, type ToolSelectionContext } from './utils/tool-selection-strategy.js'; @@ -702,74 +702,116 @@ export const createServerFactory = (_webServerInstance: WebServer, sharedApiClie SPACE_TOOL_CONFIG.schema.shape, SPACE_TOOL_CONFIG.annotations, async (params: SpaceArgs, extra) => { - const spaceTool = new SpaceTool(hfToken); - const result = await spaceTool.execute(params, extra); - - // Check if this is an InvokeResult (has raw MCP content from invoke operation) - if ('result' in result && result.result) { - const invokeResult = result as InvokeResult; - - // Prepare post-processing options - const stripImageContent = - noImageContentHeaderEnabled || - toolSelection.enabledToolIds.includes('NO_GRADIO_IMAGE_CONTENT'); - const postProcessOptions: GradioToolCallOptions = { - stripImageContent, - toolName: SPACE_TOOL_CONFIG.name, - outwardFacingName: SPACE_TOOL_CONFIG.name, - sessionInfo, - spaceName: params.space_name, + // Check if invoke operation is disabled by gradio=none + const { gradio } = extractAuthBouquetAndMix(headers); + if (params.operation === 'invoke' && gradio === 'none') { + const errorMessage = + 'The invoke operation is disabled because gradio=none is set. ' + + 'To use invoke, remove gradio=none from your headers or set gradio to a space ID. ' + + 'You can still use operation=view_parameters to inspect the tool schema.'; + return { + content: [{ type: 'text', text: errorMessage }], + isError: true, }; + } + + const startTime = Date.now(); + let success = false; + + try { + const spaceTool = new SpaceTool(hfToken); + const result = await spaceTool.execute(params, extra); + + // Check if this is an InvokeResult (has raw MCP content from invoke operation) + if ('result' in result && result.result) { + const invokeResult = result as InvokeResult; + success = !invokeResult.isError; + + // Prepare post-processing options + const stripImageContent = + noImageContentHeaderEnabled || + toolSelection.enabledToolIds.includes('NO_GRADIO_IMAGE_CONTENT'); + const postProcessOptions: GradioToolCallOptions = { + stripImageContent, + toolName: SPACE_TOOL_CONFIG.name, + outwardFacingName: SPACE_TOOL_CONFIG.name, + sessionInfo, + spaceName: params.space_name, + }; + + // Apply unified post-processing (image filtering + OpenAI transforms) + const processedResult = applyResultPostProcessing( + invokeResult.result as typeof CallToolResultSchema._type, + postProcessOptions + ); + + // Prepend warnings if any + const warningsContent = + invokeResult.warnings.length > 0 + ? [ + { + type: 'text' as const, + text: + (invokeResult.warnings.length === 1 ? 'Warning:\n' : 'Warnings:\n') + + invokeResult.warnings.map((w) => `- ${w}`).join('\n') + + '\n', + }, + ] + : []; + + // Log Gradio event with timing metrics for invoke operation (like proxied tools) + const endTime = Date.now(); + const responseContent = [...warningsContent, ...(processedResult.content as unknown[])]; + logGradioEvent(params.space_name || 'unknown-space', sessionInfo?.clientSessionId || 'unknown', { + durationMs: endTime - startTime, + isAuthenticated: !!hfToken, + clientName: sessionInfo?.clientInfo?.name, + clientVersion: sessionInfo?.clientInfo?.version, + success, + error: invokeResult.isError ? 'Tool returned isError=true' : undefined, + responseSizeBytes: JSON.stringify(responseContent).length, + isDynamic: true, // Mark as dynamic invocation (vs proxied gr_* tool) + }); + + return { + content: responseContent, + ...(invokeResult.isError && { isError: true }), + } as typeof CallToolResultSchema._type; + } + + // For view_parameters and errors - return formatted text + const toolResult = result as ToolResult; + success = !toolResult.isError; - // Apply unified post-processing (image filtering + OpenAI transforms) - const processedResult = applyResultPostProcessing( - invokeResult.result as typeof CallToolResultSchema._type, - postProcessOptions - ); - - // Prepend warnings if any - const warningsContent = - invokeResult.warnings.length > 0 - ? [ - { - type: 'text' as const, - text: - (invokeResult.warnings.length === 1 ? 'Warning:\n' : 'Warnings:\n') + - invokeResult.warnings.map((w) => `- ${w}`).join('\n') + - '\n', - }, - ] - : []; - - // Log the query with operation and result metrics const loggedOperation = params.operation ?? 'no-operation'; logSearchQuery(SPACE_TOOL_CONFIG.name, loggedOperation, params, { ...getLoggingOptions(), - totalResults: invokeResult.totalResults, - resultsShared: invokeResult.resultsShared, - responseCharCount: JSON.stringify(processedResult.content).length, + totalResults: toolResult.totalResults, + resultsShared: toolResult.resultsShared, + responseCharCount: toolResult.formatted.length, }); return { - content: [...warningsContent, ...(processedResult.content as unknown[])], - ...(invokeResult.isError && { isError: true }), - } as typeof CallToolResultSchema._type; - } - - // For view_parameters and errors - return formatted text - const toolResult = result as ToolResult; - const loggedOperation = params.operation ?? 'no-operation'; - logSearchQuery(SPACE_TOOL_CONFIG.name, loggedOperation, params, { - ...getLoggingOptions(), - totalResults: toolResult.totalResults, - resultsShared: toolResult.resultsShared, - responseCharCount: toolResult.formatted.length, - }); + content: [{ type: 'text', text: toolResult.formatted }], + ...(toolResult.isError && { isError: true }), + }; + } catch (err) { + // Log error for invoke operation + if (params.operation === 'invoke') { + const endTime = Date.now(); + logGradioEvent(params.space_name || 'unknown-space', sessionInfo?.clientSessionId || 'unknown', { + durationMs: endTime - startTime, + isAuthenticated: !!hfToken, + clientName: sessionInfo?.clientInfo?.name, + clientVersion: sessionInfo?.clientInfo?.version, + success: false, + error: err, + isDynamic: true, + }); + } - return { - content: [{ type: 'text', text: toolResult.formatted }], - ...(toolResult.isError && { isError: true }), - }; + throw err; + } } ); diff --git a/packages/app/src/server/utils/query-logger.ts b/packages/app/src/server/utils/query-logger.ts index d0061c9..f5feeeb 100644 --- a/packages/app/src/server/utils/query-logger.ts +++ b/packages/app/src/server/utils/query-logger.ts @@ -326,6 +326,7 @@ export function logGradioEvent( error?: unknown; responseSizeBytes?: number; notificationCount?: number; + isDynamic?: boolean; } ): void { if (!gradioLogger) { @@ -362,6 +363,7 @@ export function logGradioEvent( error: errorString, responseSizeBytes: options.responseSizeBytes || null, notificationCount: options.notificationCount || 0, + isDynamic: options.isDynamic ?? false, mcpServerSessionId, }, 'Gradio event logged' From d49084d42b229dd112681fc9ceabd091262f23b6 Mon Sep 17 00:00:00 2001 From: evalstate <1936278+evalstate@users.noreply.github.com> Date: Mon, 10 Nov 2025 21:37:23 +0000 Subject: [PATCH 12/15] space name and bouquet setup --- packages/app/src/server/mcp-server.ts | 21 ++++++++++----------- packages/app/src/shared/bouquet-presets.ts | 6 ++++++ 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/packages/app/src/server/mcp-server.ts b/packages/app/src/server/mcp-server.ts index 1a916af..0130581 100644 --- a/packages/app/src/server/mcp-server.ts +++ b/packages/app/src/server/mcp-server.ts @@ -55,7 +55,7 @@ import { type DocFetchParams, HF_JOBS_TOOL_CONFIG, HfJobsTool, - SPACE_TOOL_CONFIG, + DYNAMIC_SPACE_TOOL_CONFIG, SpaceTool, type SpaceArgs, type InvokeResult, @@ -696,11 +696,11 @@ export const createServerFactory = (_webServerInstance: WebServer, sharedApiClie } ); - toolInstances[SPACE_TOOL_CONFIG.name] = server.tool( - SPACE_TOOL_CONFIG.name, - SPACE_TOOL_CONFIG.description, - SPACE_TOOL_CONFIG.schema.shape, - SPACE_TOOL_CONFIG.annotations, + toolInstances[DYNAMIC_SPACE_TOOL_CONFIG.name] = server.tool( + DYNAMIC_SPACE_TOOL_CONFIG.name, + DYNAMIC_SPACE_TOOL_CONFIG.description, + DYNAMIC_SPACE_TOOL_CONFIG.schema.shape, + DYNAMIC_SPACE_TOOL_CONFIG.annotations, async (params: SpaceArgs, extra) => { // Check if invoke operation is disabled by gradio=none const { gradio } = extractAuthBouquetAndMix(headers); @@ -729,12 +729,11 @@ export const createServerFactory = (_webServerInstance: WebServer, sharedApiClie // Prepare post-processing options const stripImageContent = - noImageContentHeaderEnabled || - toolSelection.enabledToolIds.includes('NO_GRADIO_IMAGE_CONTENT'); + noImageContentHeaderEnabled || toolSelection.enabledToolIds.includes('NO_GRADIO_IMAGE_CONTENT'); const postProcessOptions: GradioToolCallOptions = { stripImageContent, - toolName: SPACE_TOOL_CONFIG.name, - outwardFacingName: SPACE_TOOL_CONFIG.name, + toolName: DYNAMIC_SPACE_TOOL_CONFIG.name, + outwardFacingName: DYNAMIC_SPACE_TOOL_CONFIG.name, sessionInfo, spaceName: params.space_name, }; @@ -784,7 +783,7 @@ export const createServerFactory = (_webServerInstance: WebServer, sharedApiClie success = !toolResult.isError; const loggedOperation = params.operation ?? 'no-operation'; - logSearchQuery(SPACE_TOOL_CONFIG.name, loggedOperation, params, { + logSearchQuery(DYNAMIC_SPACE_TOOL_CONFIG.name, loggedOperation, params, { ...getLoggingOptions(), totalResults: toolResult.totalResults, resultsShared: toolResult.resultsShared, diff --git a/packages/app/src/shared/bouquet-presets.ts b/packages/app/src/shared/bouquet-presets.ts index 4899916..9da18a7 100644 --- a/packages/app/src/shared/bouquet-presets.ts +++ b/packages/app/src/shared/bouquet-presets.ts @@ -4,6 +4,8 @@ import { HUB_INSPECT_TOOL_ID, USE_SPACE_TOOL_ID, HF_JOBS_TOOL_ID, + DYNAMIC_SPACE_TOOL_ID, + SPACE_SEARCH_TOOL_ID, } from '@llmindset/hf-mcp'; import type { AppSettings } from './settings.js'; import { README_INCLUDE_FLAG, GRADIO_IMAGE_FILTER_FLAG } from './behavior-flags.js'; @@ -50,6 +52,10 @@ export const BOUQUETS: Record = { builtInTools: [HF_JOBS_TOOL_ID], spaceTools: [], }, + dynamic_space: { + builtInTools: [SPACE_SEARCH_TOOL_ID, DYNAMIC_SPACE_TOOL_ID], + spaceTools: [], + }, }; export type BouquetKey = keyof typeof BOUQUETS; From f20db75291865caed99d1027b2f9d2ced0615a39 Mon Sep 17 00:00:00 2001 From: evalstate <1936278+evalstate@users.noreply.github.com> Date: Mon, 10 Nov 2025 21:37:41 +0000 Subject: [PATCH 13/15] tool name and bouquet setup --- packages/mcp/src/space-search.ts | 9 +++++---- packages/mcp/src/space/space-tool.ts | 16 ++++++++-------- packages/mcp/src/tool-ids.ts | 8 ++++---- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/packages/mcp/src/space-search.ts b/packages/mcp/src/space-search.ts index edcf608..4de7b87 100644 --- a/packages/mcp/src/space-search.ts +++ b/packages/mcp/src/space-search.ts @@ -34,7 +34,8 @@ const RESULTS_TO_RETURN = 10; export const SEMANTIC_SEARCH_TOOL_CONFIG = { name: 'space_search', description: - 'Find Hugging Face Spaces using semantic search. ' + 'Include links to the Space when presenting the results.', + 'Find Hugging Face Spaces using semantic search. IMPORTANT Only MCP Servers can be used with the dynamic_space tool' + + 'Include links to the Space when presenting the results.', schema: z.object({ query: z.string().min(1, 'Query is required').max(100, 'Query too long').describe('Semantic Search Query'), limit: z.number().optional().default(RESULTS_TO_RETURN).describe('Number of results to return'), @@ -115,7 +116,7 @@ export class SpaceSearchTool extends HfApiCall Date: Mon, 10 Nov 2025 21:39:09 +0000 Subject: [PATCH 14/15] feat: improve error message for non-existent spaces in view_parameters When a 404 error occurs (space not found), add a helpful note that: - The space MUST be an MCP enabled space - Users should use the space_search tool to find MCP enabled spaces This provides better guidance when users try to view parameters for spaces that don't exist or aren't MCP enabled. Example error message: Error fetching parameters for space 'silveroxides/Chroma-Extra': HTTP 404: Not Found Note: The space MUST be an MCP enabled space. Use the `space_search` tool to find MCP enabled spaces. --- packages/mcp/src/space/commands/view-parameters.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/mcp/src/space/commands/view-parameters.ts b/packages/mcp/src/space/commands/view-parameters.ts index df472a2..0ac3691 100644 --- a/packages/mcp/src/space/commands/view-parameters.ts +++ b/packages/mcp/src/space/commands/view-parameters.ts @@ -49,8 +49,18 @@ export async function viewParameters(spaceName: string, hfToken?: string): Promi }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); + + // Check if this is a 404 error (space not found) + const is404 = errorMessage.includes('404') || errorMessage.toLowerCase().includes('not found'); + + let formattedError = `Error fetching parameters for space '${spaceName}': ${errorMessage}`; + + if (is404) { + formattedError += '\n\nNote: The space MUST be an MCP enabled space. Use the `space_search` tool to find MCP enabled spaces.'; + } + return { - formatted: `Error fetching parameters for space '${spaceName}': ${errorMessage}`, + formatted: formattedError, totalResults: 0, resultsShared: 0, isError: true, From 89bb969d8106699a533f8626fc34c78bdfc4171e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Nov 2025 21:47:38 +0000 Subject: [PATCH 15/15] fix: add @modelcontextprotocol/sdk dependency to packages/mcp The space tool (invoke and view-parameters) imports from @modelcontextprotocol/sdk but the package didn't declare it as a dependency. This caused CI/Docker builds to fail with: error TS2307: Cannot find module '@modelcontextprotocol/sdk/types.js' While it worked locally due to pnpm workspace hoisting, proper dependency declaration is required for isolated package builds. Added @modelcontextprotocol/sdk ^1.20.0 to match the version used in packages/app. --- packages/mcp/package.json | 1 + pnpm-lock.yaml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 6f60f13..271cd07 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -34,6 +34,7 @@ "dependencies": { "@huggingface/hub": "^2.6.12", "@mcp-ui/server": "^5.12.0", + "@modelcontextprotocol/sdk": "^1.20.0", "shell-quote": "^1.8.3", "turndown": "^7.2.0", "zod": "^3.24.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2384102..0142904 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -198,6 +198,9 @@ importers: '@mcp-ui/server': specifier: ^5.12.0 version: 5.12.0 + '@modelcontextprotocol/sdk': + specifier: ^1.20.0 + version: 1.20.0 shell-quote: specifier: ^1.8.3 version: 1.8.3