Skip to content

Commit 1d331ba

Browse files
authored
Dynamic Space Tool (dynamic_space bouquet)
Early Access of dynamic_space tool.
2 parents 69ef0d4 + 89bb969 commit 1d331ba

19 files changed

+2121
-330
lines changed

packages/app/src/server/gradio-endpoint-connector.ts

Lines changed: 107 additions & 324 deletions
Large diffs are not rendered by default.

packages/app/src/server/mcp-server.ts

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2+
import type { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
23
import type { z } from 'zod';
34
import { createRequire } from 'module';
45
import { whoAmI, type WhoAmI } from '@huggingface/hub';
@@ -54,19 +55,25 @@ import {
5455
type DocFetchParams,
5556
HF_JOBS_TOOL_CONFIG,
5657
HfJobsTool,
58+
DYNAMIC_SPACE_TOOL_CONFIG,
59+
SpaceTool,
60+
type SpaceArgs,
61+
type InvokeResult,
62+
type ToolResult,
5763
} from '@llmindset/hf-mcp';
5864

5965
import type { ServerFactory, ServerFactoryResult } from './transport/base-transport.js';
6066
import type { McpApiClient } from './utils/mcp-api-client.js';
6167
import type { WebServer } from './web-server.js';
6268
import { logger } from './utils/logger.js';
63-
import { logSearchQuery, logPromptQuery } from './utils/query-logger.js';
69+
import { logSearchQuery, logPromptQuery, logGradioEvent } from './utils/query-logger.js';
6470
import { DEFAULT_SPACE_TOOLS, type AppSettings } from '../shared/settings.js';
6571
import { extractAuthBouquetAndMix } from './utils/auth-utils.js';
6672
import { ToolSelectionStrategy, type ToolSelectionContext } from './utils/tool-selection-strategy.js';
6773
import { hasReadmeFlag } from '../shared/behavior-flags.js';
6874
import { registerCapabilities } from './utils/capability-utils.js';
6975
import { createGradioWidgetResourceConfig } from './resources/gradio-widget-resource.js';
76+
import { applyResultPostProcessing, type GradioToolCallOptions } from './utils/gradio-tool-caller.js';
7077

7178
// Fallback settings when API fails (enables all tools)
7279
export const BOUQUET_FALLBACK: AppSettings = {
@@ -176,6 +183,9 @@ export const createServerFactory = (_webServerInstance: WebServer, sharedApiClie
176183
hfToken,
177184
};
178185
const toolSelection = await toolSelectionStrategy.selectTools(toolSelectionContext);
186+
const rawNoImageHeader = headers?.['x-mcp-no-image-content'];
187+
const noImageContentHeaderEnabled =
188+
typeof rawNoImageHeader === 'string' && rawNoImageHeader.trim().toLowerCase() === 'true';
179189

180190
// Always register all tools and store instances for dynamic control
181191
const toolInstances: { [name: string]: Tool } = {};
@@ -686,6 +696,124 @@ export const createServerFactory = (_webServerInstance: WebServer, sharedApiClie
686696
}
687697
);
688698

699+
toolInstances[DYNAMIC_SPACE_TOOL_CONFIG.name] = server.tool(
700+
DYNAMIC_SPACE_TOOL_CONFIG.name,
701+
DYNAMIC_SPACE_TOOL_CONFIG.description,
702+
DYNAMIC_SPACE_TOOL_CONFIG.schema.shape,
703+
DYNAMIC_SPACE_TOOL_CONFIG.annotations,
704+
async (params: SpaceArgs, extra) => {
705+
// Check if invoke operation is disabled by gradio=none
706+
const { gradio } = extractAuthBouquetAndMix(headers);
707+
if (params.operation === 'invoke' && gradio === 'none') {
708+
const errorMessage =
709+
'The invoke operation is disabled because gradio=none is set. ' +
710+
'To use invoke, remove gradio=none from your headers or set gradio to a space ID. ' +
711+
'You can still use operation=view_parameters to inspect the tool schema.';
712+
return {
713+
content: [{ type: 'text', text: errorMessage }],
714+
isError: true,
715+
};
716+
}
717+
718+
const startTime = Date.now();
719+
let success = false;
720+
721+
try {
722+
const spaceTool = new SpaceTool(hfToken);
723+
const result = await spaceTool.execute(params, extra);
724+
725+
// Check if this is an InvokeResult (has raw MCP content from invoke operation)
726+
if ('result' in result && result.result) {
727+
const invokeResult = result as InvokeResult;
728+
success = !invokeResult.isError;
729+
730+
// Prepare post-processing options
731+
const stripImageContent =
732+
noImageContentHeaderEnabled || toolSelection.enabledToolIds.includes('NO_GRADIO_IMAGE_CONTENT');
733+
const postProcessOptions: GradioToolCallOptions = {
734+
stripImageContent,
735+
toolName: DYNAMIC_SPACE_TOOL_CONFIG.name,
736+
outwardFacingName: DYNAMIC_SPACE_TOOL_CONFIG.name,
737+
sessionInfo,
738+
spaceName: params.space_name,
739+
};
740+
741+
// Apply unified post-processing (image filtering + OpenAI transforms)
742+
const processedResult = applyResultPostProcessing(
743+
invokeResult.result as typeof CallToolResultSchema._type,
744+
postProcessOptions
745+
);
746+
747+
// Prepend warnings if any
748+
const warningsContent =
749+
invokeResult.warnings.length > 0
750+
? [
751+
{
752+
type: 'text' as const,
753+
text:
754+
(invokeResult.warnings.length === 1 ? 'Warning:\n' : 'Warnings:\n') +
755+
invokeResult.warnings.map((w) => `- ${w}`).join('\n') +
756+
'\n',
757+
},
758+
]
759+
: [];
760+
761+
// Log Gradio event with timing metrics for invoke operation (like proxied tools)
762+
const endTime = Date.now();
763+
const responseContent = [...warningsContent, ...(processedResult.content as unknown[])];
764+
logGradioEvent(params.space_name || 'unknown-space', sessionInfo?.clientSessionId || 'unknown', {
765+
durationMs: endTime - startTime,
766+
isAuthenticated: !!hfToken,
767+
clientName: sessionInfo?.clientInfo?.name,
768+
clientVersion: sessionInfo?.clientInfo?.version,
769+
success,
770+
error: invokeResult.isError ? 'Tool returned isError=true' : undefined,
771+
responseSizeBytes: JSON.stringify(responseContent).length,
772+
isDynamic: true, // Mark as dynamic invocation (vs proxied gr_* tool)
773+
});
774+
775+
return {
776+
content: responseContent,
777+
...(invokeResult.isError && { isError: true }),
778+
} as typeof CallToolResultSchema._type;
779+
}
780+
781+
// For view_parameters and errors - return formatted text
782+
const toolResult = result as ToolResult;
783+
success = !toolResult.isError;
784+
785+
const loggedOperation = params.operation ?? 'no-operation';
786+
logSearchQuery(DYNAMIC_SPACE_TOOL_CONFIG.name, loggedOperation, params, {
787+
...getLoggingOptions(),
788+
totalResults: toolResult.totalResults,
789+
resultsShared: toolResult.resultsShared,
790+
responseCharCount: toolResult.formatted.length,
791+
});
792+
793+
return {
794+
content: [{ type: 'text', text: toolResult.formatted }],
795+
...(toolResult.isError && { isError: true }),
796+
};
797+
} catch (err) {
798+
// Log error for invoke operation
799+
if (params.operation === 'invoke') {
800+
const endTime = Date.now();
801+
logGradioEvent(params.space_name || 'unknown-space', sessionInfo?.clientSessionId || 'unknown', {
802+
durationMs: endTime - startTime,
803+
isAuthenticated: !!hfToken,
804+
clientName: sessionInfo?.clientInfo?.name,
805+
clientVersion: sessionInfo?.clientInfo?.version,
806+
success: false,
807+
error: err,
808+
isDynamic: true,
809+
});
810+
}
811+
812+
throw err;
813+
}
814+
}
815+
);
816+
689817
// Register Gradio widget resource for OpenAI MCP client (skybridge)
690818
if (sessionInfo?.clientInfo?.name === 'openai-mcp') {
691819
logger.debug('Registering Gradio widget resource for skybridge client');
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import type { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js';
2+
import { logger } from './logger.js';
3+
4+
/**
5+
* Options for filtering image content
6+
*/
7+
export interface ImageFilterOptions {
8+
enabled: boolean;
9+
toolName: string;
10+
outwardFacingName: string;
11+
}
12+
13+
/**
14+
* Strips image content from a tool result if filtering is enabled
15+
*
16+
* This function is used by both proxied Gradio tools and the space tool's invoke operation
17+
* to conditionally remove image content blocks based on user configuration.
18+
*/
19+
export function stripImageContentFromResult(
20+
callResult: typeof CallToolResultSchema._type,
21+
{ enabled, toolName, outwardFacingName }: ImageFilterOptions
22+
): typeof CallToolResultSchema._type {
23+
if (!enabled) {
24+
return callResult;
25+
}
26+
27+
const content = callResult.content;
28+
if (!Array.isArray(content) || content.length === 0) {
29+
return callResult;
30+
}
31+
32+
const filteredContent = content.filter((item) => {
33+
if (!item || typeof item !== 'object') {
34+
return true;
35+
}
36+
37+
const candidate = item as { type?: unknown };
38+
const typeValue = typeof candidate.type === 'string' ? candidate.type.toLowerCase() : undefined;
39+
return typeValue !== 'image';
40+
});
41+
42+
if (filteredContent.length === content.length) {
43+
return callResult;
44+
}
45+
46+
const removedCount = content.length - filteredContent.length;
47+
logger.debug({ tool: toolName, outwardFacingName, removedCount }, 'Stripped image content from Gradio tool response');
48+
49+
if (filteredContent.length === 0) {
50+
filteredContent.push({
51+
type: 'text',
52+
text: 'Image content omitted due to client configuration (no_image_content=true).',
53+
});
54+
}
55+
56+
return { ...callResult, content: filteredContent };
57+
}
58+
59+
/**
60+
* Extracts a URL from the result content if present
61+
*
62+
* Used for OpenAI MCP client to populate structuredContent field
63+
*/
64+
export function extractUrlFromContent(content: unknown[]): string | undefined {
65+
if (!Array.isArray(content) || content.length === 0) {
66+
return undefined;
67+
}
68+
69+
// Check each content item for a URL-like string
70+
for (const item of content) {
71+
if (!item || typeof item !== 'object') {
72+
continue;
73+
}
74+
75+
const candidate = item as { type?: string; text?: string; url?: string };
76+
77+
// Check for explicit url field
78+
if (typeof candidate.url === 'string' && /^https?:\/\//i.test(candidate.url.trim())) {
79+
return candidate.url.trim();
80+
}
81+
82+
// Check for text field that looks like a URL
83+
if (typeof candidate.text === 'string') {
84+
let text = candidate.text.trim();
85+
86+
// Remove "Image URL:" or "Image URL :" prefix if present (case insensitive)
87+
text = text.replace(/^image\s+url\s*:\s*/i, '');
88+
89+
if (/^https?:\/\//i.test(text)) {
90+
return text;
91+
}
92+
}
93+
}
94+
95+
return undefined;
96+
}

0 commit comments

Comments
 (0)