Skip to content

Commit 4502e70

Browse files
authored
Merge pull request #93 from evalstate/feat/tool-updates
Feat/tool updates
2 parents 76af398 + 9612b26 commit 4502e70

File tree

16 files changed

+841
-755
lines changed

16 files changed

+841
-755
lines changed

README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
Welcome to the official Hugging Face MCP Server 🤗. Connect your LLM to the Hugging Face Hub and thousands of Gradio AI Applications.
66

7-
87
## Installing the MCP Server
98

109
Follow the instructions below to get started:

packages/app/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
"@llmindset/hf-mcp": "workspace:^",
5757
"@huggingface/hub": "^2.1.0",
5858
"@mcp-ui/server": "^5.10.0",
59-
"@modelcontextprotocol/sdk": "^1.11.2",
59+
"@modelcontextprotocol/sdk": "^1.18.2",
6060
"@radix-ui/react-checkbox": "^1.2.3",
6161
"@radix-ui/react-dropdown-menu": "^2.1.15",
6262
"@radix-ui/react-label": "^2.1.4",

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { ServerFactory, ServerFactoryResult } from './transport/base-transp
33
import type { McpApiClient } from './utils/mcp-api-client.js';
44
import type { WebServer } from './web-server.js';
55
import type { AppSettings } from '../shared/settings.js';
6+
import { GRADIO_IMAGE_FILTER_FLAG } from '../shared/behavior-flags.js';
67
import { logger } from './utils/logger.js';
78
import { connectToGradioEndpoints, registerRemoteTools } from './gradio-endpoint-connector.js';
89
import { extractAuthBouquetAndMix } from './utils/auth-utils.js';
@@ -129,7 +130,8 @@ export const createProxyServerFactory = (
129130
// Extract auth, bouquet, and gradio using shared utility
130131
const { hfToken, bouquet, gradio } = extractAuthBouquetAndMix(headers);
131132
const rawNoImageHeader = headers ? headers['x-mcp-no-image-content'] : undefined;
132-
const stripImageContent = typeof rawNoImageHeader === 'string' && rawNoImageHeader.toLowerCase() === 'true';
133+
const noImageFromHeader =
134+
typeof rawNoImageHeader === 'string' && rawNoImageHeader.toLowerCase() === 'true';
133135

134136
// Skip expensive operations for requests that skip Gradio
135137
let settings = userSettings;
@@ -148,6 +150,9 @@ export const createProxyServerFactory = (
148150
return result;
149151
}
150152

153+
const noImageFromSettings = settings?.builtInTools?.includes(GRADIO_IMAGE_FILTER_FLAG) ?? false;
154+
const stripImageContent = noImageFromHeader || noImageFromSettings;
155+
151156
// Skip Gradio endpoints if bouquet is not "all"
152157
if (bouquet && bouquet !== 'all') {
153158
logger.debug({ bouquet }, 'Bouquet specified and not "all", skipping Gradio endpoints');

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

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import { logSearchQuery, logPromptQuery } from './utils/query-logger.js';
6262
import { DEFAULT_SPACE_TOOLS, type AppSettings } from '../shared/settings.js';
6363
import { extractAuthBouquetAndMix } from './utils/auth-utils.js';
6464
import { ToolSelectionStrategy, type ToolSelectionContext } from './utils/tool-selection-strategy.js';
65+
import { hasReadmeFlag } from '../shared/behavior-flags.js';
6566

6667
// Fallback settings when API fails (enables all tools)
6768
export const BOUQUET_FALLBACK: AppSettings = {
@@ -157,7 +158,7 @@ export const createServerFactory = (_webServerInstance: WebServer, sharedApiClie
157158
disable(): void;
158159
}
159160

160-
// Get tool selection first (needed for runtime configuration like INCLUDE_README)
161+
// Get tool selection first (needed for runtime configuration like ALLOW_README_INCLUDE)
161162
const toolSelectionContext: ToolSelectionContext = {
162163
headers,
163164
userSettings,
@@ -481,7 +482,7 @@ export const createServerFactory = (_webServerInstance: WebServer, sharedApiClie
481482
);
482483

483484
// Compute README availability; adjust description and schema accordingly
484-
const hubInspectReadmeAllowed = toolSelection.enabledToolIds.includes('INCLUDE_README');
485+
const hubInspectReadmeAllowed = hasReadmeFlag(toolSelection.enabledToolIds);
485486
const hubInspectDescription = hubInspectReadmeAllowed
486487
? `${HUB_INSPECT_TOOL_CONFIG.description} README file is included from the external repository.`
487488
: HUB_INSPECT_TOOL_CONFIG.description;
@@ -501,7 +502,7 @@ export const createServerFactory = (_webServerInstance: WebServer, sharedApiClie
501502
async (params: Record<string, unknown>) => {
502503
// Re-evaluate flag dynamically to reflect UI changes without restarting server
503504
const currentSelection = await toolSelectionStrategy.selectTools(toolSelectionContext);
504-
const allowReadme = currentSelection.enabledToolIds.includes('INCLUDE_README');
505+
const allowReadme = hasReadmeFlag(currentSelection.enabledToolIds);
505506
const wantReadme = (params as { include_readme?: boolean }).include_readme === true; // explicit opt-in required
506507
const includeReadme = allowReadme && wantReadme;
507508

@@ -682,6 +683,15 @@ export const createServerFactory = (_webServerInstance: WebServer, sharedApiClie
682683
},
683684
});
684685

686+
// Remove the completions capability that was auto-added by prompt registration
687+
// The MCP SDK automatically adds this when prompts are registered, but we don't want it
688+
// @ts-expect-error quick workaround for an SDK issue (adding prompt/resource adds completions)
689+
if (server.server._capabilities?.completions) {
690+
// @ts-expect-error quick workaround for an SDK issue (adding prompt/resource adds completions)
691+
delete server.server._capabilities.completions;
692+
logger.debug('Removed auto-added completions capability');
693+
}
694+
685695
if (!skipGradio) {
686696
void applyToolStates();
687697

packages/app/src/server/transport/base-transport.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export interface BaseSession<T = unknown> {
6969
server: McpServer;
7070
metadata: SessionMetadata;
7171
heartbeatInterval?: NodeJS.Timeout;
72+
cleaningUp?: boolean;
7273
}
7374

7475
/**
@@ -681,6 +682,13 @@ export abstract class StatefulTransport<TSession extends BaseSession = BaseSessi
681682
const session = this.sessions.get(sessionId);
682683
if (!session) return;
683684

685+
// Prevent recursive cleanup
686+
if (session.cleaningUp) {
687+
logger.debug({ sessionId }, 'Session already being cleaned up, skipping');
688+
return;
689+
}
690+
session.cleaningUp = true;
691+
684692
logger.debug({ sessionId }, 'Cleaning up session');
685693

686694
// Clear heartbeat interval

packages/app/src/server/transport/sse-transport.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ export class SseTransport extends StatefulTransport<SSEConnection> {
125125
isAuthenticated: authResult.shouldContinue && !!headers['authorization'],
126126
capabilities: {},
127127
},
128+
cleaningUp: false,
128129
};
129130

130131
this.sessions.set(sessionId, connection);

packages/app/src/server/transport/streamable-http-transport.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@ export class StreamableHttpTransport extends StatefulTransport<Session> {
249249
isAuthenticated: !!requestHeaders?.['authorization'],
250250
capabilities: {},
251251
},
252+
cleaningUp: false,
252253
};
253254

254255
this.sessions.set(sessionId, session);

packages/app/src/server/utils/mcp-api-client.ts

Lines changed: 33 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { AppSettings } from '../../shared/settings.js';
44
import type { TransportInfo } from '../../shared/transport-info.js';
55
import { BOUQUET_FALLBACK } from '../mcp-server.js';
66
import { ALL_BUILTIN_TOOL_IDS } from '@llmindset/hf-mcp';
7+
import { normalizeBuiltInTools } from '../../shared/tool-normalizer.js';
78
import { apiMetrics } from '../utils/api-metrics.js';
89
export interface ToolStateChangeCallback {
910
(toolId: string, enabled: boolean): void;
@@ -27,6 +28,14 @@ export interface ApiClientConfig {
2728
staticGradioEndpoints?: GradioEndpoint[];
2829
}
2930

31+
function withNormalizedFlags(settings: AppSettings): AppSettings {
32+
const normalizedBuiltInTools = normalizeBuiltInTools(settings.builtInTools);
33+
const isIdentical =
34+
normalizedBuiltInTools.length === settings.builtInTools.length &&
35+
normalizedBuiltInTools.every((value, index) => value === settings.builtInTools[index]);
36+
return isIdentical ? settings : { ...settings, builtInTools: normalizedBuiltInTools };
37+
}
38+
3039
export class McpApiClient extends EventEmitter {
3140
private config: ApiClientConfig;
3241
private pollTimer: NodeJS.Timeout | null = null;
@@ -56,32 +65,32 @@ export class McpApiClient extends EventEmitter {
5665
case 'polling':
5766
if (!this.config.baseUrl) {
5867
logger.error('baseUrl required for polling mode');
59-
return BOUQUET_FALLBACK;
68+
return withNormalizedFlags(BOUQUET_FALLBACK);
6069
}
6170
try {
6271
const response = await fetch(`${this.config.baseUrl}/api/settings`);
6372
if (!response.ok) {
6473
logger.error(`Failed to fetch settings: ${response.status.toString()} ${response.statusText}`);
65-
return BOUQUET_FALLBACK;
74+
return withNormalizedFlags(BOUQUET_FALLBACK);
6675
}
67-
return (await response.json()) as AppSettings;
76+
return withNormalizedFlags((await response.json()) as AppSettings);
6877
} catch (error) {
6978
logger.error({ error }, 'Error fetching settings from local API');
70-
return BOUQUET_FALLBACK;
79+
return withNormalizedFlags(BOUQUET_FALLBACK);
7180
}
7281

7382
case 'external':
7483
if (!this.config.externalUrl) {
7584
logger.error('externalUrl required for external mode');
76-
return BOUQUET_FALLBACK;
85+
return withNormalizedFlags(BOUQUET_FALLBACK);
7786
}
7887
try {
7988
const token = overrideToken || this.config.hfToken;
8089
if (!token || token.trim() === '') {
8190
// Record anonymous access (successful fallback usage)
8291
apiMetrics.recordCall(false, 200);
8392
logger.debug('No HF token available for external config API - using fallback');
84-
return BOUQUET_FALLBACK;
93+
return withNormalizedFlags(BOUQUET_FALLBACK);
8594
}
8695

8796
const headers: Record<string, string> = {};
@@ -114,20 +123,20 @@ export class McpApiClient extends EventEmitter {
114123
logger.debug(
115124
`Failed to fetch external settings: ${response.status.toString()} ${response.statusText} - using fallback bouquet`
116125
);
117-
return BOUQUET_FALLBACK;
126+
return withNormalizedFlags(BOUQUET_FALLBACK);
118127
}
119128

120129
// Record metrics for successful responses
121130
apiMetrics.recordCall(hasToken, response.status);
122-
return (await response.json()) as AppSettings;
131+
return withNormalizedFlags((await response.json()) as AppSettings);
123132
} catch (error) {
124133
logger.warn({ error }, 'Error fetching settings from external API - defaulting to fallback bouquet');
125-
return BOUQUET_FALLBACK;
134+
return withNormalizedFlags(BOUQUET_FALLBACK);
126135
}
127136

128137
default:
129138
logger.error(`Unknown API client type: ${String(this.config.type)}`);
130-
return BOUQUET_FALLBACK;
139+
return withNormalizedFlags(BOUQUET_FALLBACK);
131140
}
132141
}
133142

@@ -149,20 +158,20 @@ export class McpApiClient extends EventEmitter {
149158
logger.trace({ gradioEndpoints: this.gradioEndpoints }, 'Updated gradio endpoints from external API');
150159
}
151160

152-
// Create tool states: enabled tools = true, rest = false
153-
const toolStates: Record<string, boolean> = {};
154-
for (const toolId of ALL_BUILTIN_TOOL_IDS) {
155-
toolStates[toolId] = settings.builtInTools.includes(toolId);
156-
}
157-
158-
// Include virtual/behavior flags that aren't real tools (e.g., INCLUDE_README)
159-
// Anything present in builtInTools but not in ALL_BUILTIN_TOOL_IDS is treated as an enabled flag.
160-
for (const id of settings.builtInTools) {
161-
if (!(id in toolStates)) {
162-
toolStates[id] = true;
163-
}
164-
}
165-
return toolStates;
161+
// Create tool states: enabled tools = true, rest = false
162+
const toolStates: Record<string, boolean> = {};
163+
for (const toolId of ALL_BUILTIN_TOOL_IDS) {
164+
toolStates[toolId] = settings.builtInTools.includes(toolId);
165+
}
166+
167+
// Include virtual/behavior flags that aren't real tools (e.g., ALLOW_README_INCLUDE)
168+
// Anything present in builtInTools but not in ALL_BUILTIN_TOOL_IDS is treated as an enabled flag.
169+
for (const id of settings.builtInTools) {
170+
if (!(id in toolStates)) {
171+
toolStates[id] = true;
172+
}
173+
}
174+
return toolStates;
166175
}
167176

168177
getGradioEndpoints(): GradioEndpoint[] {

packages/app/src/server/utils/tool-selection-strategy.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import type { AppSettings, SpaceTool } from '../../shared/settings.js';
33
import { ALL_BUILTIN_TOOL_IDS, HUB_INSPECT_TOOL_ID, TOOL_ID_GROUPS, USE_SPACE_TOOL_ID } from '@llmindset/hf-mcp';
44
import type { McpApiClient } from './mcp-api-client.js';
55
import { extractAuthBouquetAndMix } from '../utils/auth-utils.js';
6+
import { GRADIO_IMAGE_FILTER_FLAG, README_INCLUDE_FLAG } from '../../shared/behavior-flags.js';
7+
import { normalizeBuiltInTools } from '../../shared/tool-normalizer.js';
68

79
export enum ToolSelectionMode {
810
BOUQUET_OVERRIDE = 'bouquet_override',
@@ -50,13 +52,17 @@ export const BOUQUETS: Record<string, AppSettings> = {
5052
},
5153
// Test bouquets for README inclusion behavior
5254
hub_repo_details_readme: {
53-
builtInTools: [HUB_INSPECT_TOOL_ID, 'INCLUDE_README'],
55+
builtInTools: [HUB_INSPECT_TOOL_ID, README_INCLUDE_FLAG],
5456
spaceTools: [],
5557
},
5658
hub_repo_details: {
5759
builtInTools: [HUB_INSPECT_TOOL_ID],
5860
spaceTools: [],
5961
},
62+
no_gradio_images: {
63+
builtInTools: [GRADIO_IMAGE_FILTER_FLAG],
64+
spaceTools: [],
65+
},
6066
mcp_ui: {
6167
builtInTools: [USE_SPACE_TOOL_ID],
6268
spaceTools: [],
@@ -146,7 +152,9 @@ export class ToolSelectionStrategy {
146152

147153
// 1. Bouquet override (highest precedence)
148154
if (bouquet && BOUQUETS[bouquet]) {
149-
const enabledToolIds = this.applySearchEnablesFetch(BOUQUETS[bouquet].builtInTools);
155+
const enabledToolIds = normalizeBuiltInTools(
156+
this.applySearchEnablesFetch(BOUQUETS[bouquet].builtInTools)
157+
);
150158
logger.debug({ bouquet, enabledToolIds, gradioCount: gradioSpaceTools.length }, 'Using bouquet override');
151159
return {
152160
mode: ToolSelectionMode.BOUQUET_OVERRIDE,
@@ -162,8 +170,9 @@ export class ToolSelectionStrategy {
162170
// 3. Apply mix if specified and we have base settings
163171
if (mix && BOUQUETS[mix] && baseSettings) {
164172
const mixedTools = [...baseSettings.builtInTools, ...BOUQUETS[mix].builtInTools];
165-
const dedupedTools = [...new Set(mixedTools)]; // dedupe
166-
const enabledToolIds = this.applySearchEnablesFetch(dedupedTools);
173+
const enabledToolIds = normalizeBuiltInTools(
174+
this.applySearchEnablesFetch([...new Set(mixedTools)])
175+
);
167176

168177
logger.debug(
169178
{
@@ -191,7 +200,9 @@ export class ToolSelectionStrategy {
191200
? ToolSelectionMode.EXTERNAL_API
192201
: ToolSelectionMode.INTERNAL_API;
193202

194-
const enabledToolIds = this.applySearchEnablesFetch(baseSettings.builtInTools);
203+
const enabledToolIds = normalizeBuiltInTools(
204+
this.applySearchEnablesFetch(baseSettings.builtInTools)
205+
);
195206

196207
logger.debug(
197208
{
@@ -215,7 +226,9 @@ export class ToolSelectionStrategy {
215226

216227
// 5. Fallback - all tools enabled
217228
logger.warn('No settings available, using fallback (all tools enabled)');
218-
const enabledToolIds = this.applySearchEnablesFetch([...ALL_BUILTIN_TOOL_IDS]);
229+
const enabledToolIds = normalizeBuiltInTools(
230+
this.applySearchEnablesFetch([...ALL_BUILTIN_TOOL_IDS])
231+
);
219232
return {
220233
mode: ToolSelectionMode.FALLBACK,
221234
enabledToolIds,
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export const README_INCLUDE_FLAG = 'ALLOW_README_INCLUDE' as const;
2+
export const GRADIO_IMAGE_FILTER_FLAG = 'NO_GRADIO_IMAGE_CONTENT' as const;
3+
4+
export function hasReadmeFlag(ids: readonly string[]): boolean {
5+
return ids.includes(README_INCLUDE_FLAG);
6+
}

0 commit comments

Comments
 (0)