Skip to content

Commit 12ab326

Browse files
authored
Merge pull request #132 from huggingface/feat/skills-bouquet
skills bouquet!
2 parents b019f6b + a91bfba commit 12ab326

File tree

7 files changed

+111
-34
lines changed

7 files changed

+111
-34
lines changed

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -989,7 +989,7 @@ export const createServerFactory = (_webServerInstance: WebServer, sharedApiClie
989989
reason: toolSelection.reason,
990990
enabledCount: toolSelection.enabledToolIds.length,
991991
totalTools: Object.keys(toolInstances).length,
992-
mixedBouquet: toolSelection.mixedBouquet,
992+
mixedBouquet: toolSelection.mixedBouquet?.join(','),
993993
},
994994
'Tool selection strategy applied'
995995
);

packages/app/src/server/utils/auth-utils.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,24 @@ import { logger } from '../utils/logger.js';
33
/**
44
* Extracts HF token, bouquet, mix, and gradio from headers and environment
55
*/
6+
function parseListParam(value: string | undefined): string[] | undefined {
7+
if (!value) return undefined;
8+
const parts = value
9+
.split(',')
10+
.map((part) => part.trim())
11+
.filter(Boolean);
12+
return parts.length > 0 ? parts : undefined;
13+
}
14+
615
export function extractAuthBouquetAndMix(headers: Record<string, string> | null): {
716
hfToken: string | undefined;
817
bouquet: string | undefined;
9-
mix: string | undefined;
18+
mix: string[] | undefined;
1019
gradio: string | undefined;
1120
} {
1221
let tokenFromHeader: string | undefined;
1322
let bouquet: string | undefined;
14-
let mix: string | undefined;
23+
let mix: string[] | undefined;
1524
let gradio: string | undefined;
1625

1726
if (headers) {
@@ -31,7 +40,7 @@ export function extractAuthBouquetAndMix(headers: Record<string, string> | null)
3140

3241
// Extract mix from custom header
3342
if ('x-mcp-mix' in headers) {
34-
mix = headers['x-mcp-mix'];
43+
mix = parseListParam(headers['x-mcp-mix']);
3544
logger.trace({ mix }, 'Mix parameter received');
3645
}
3746

packages/app/src/server/utils/query-params.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type { Request } from 'express';
77
*/
88
export function extractQueryParamsToHeaders(req: Request, headers: Record<string, string>): void {
99
const bouquet = req.query.bouquet as string | undefined;
10-
const mix = req.query.mix as string | undefined;
10+
const mix = Array.isArray(req.query.mix) ? req.query.mix.join(',') : (req.query.mix as string | undefined);
1111
const gradio = req.query.gradio as string | undefined;
1212
const forceauth = req.query.forceauth as string | undefined;
1313
const login = req.query.login;

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

Lines changed: 35 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export interface ToolSelectionResult {
2626
enabledToolIds: string[];
2727
reason: string;
2828
baseSettings?: AppSettings;
29-
mixedBouquet?: string;
29+
mixedBouquet?: string[];
3030
gradioSpaceTools?: SpaceTool[];
3131
}
3232

@@ -128,6 +128,7 @@ export class ToolSelectionStrategy {
128128
*/
129129
async selectTools(context: ToolSelectionContext): Promise<ToolSelectionResult> {
130130
const { bouquet, mix, gradio } = extractAuthBouquetAndMix(context.headers);
131+
const mixList = mix ?? [];
131132

132133
// Parse gradio endpoints if provided (independent of bouquet selection)
133134
// These endpoints will be registered in mcp-proxy.ts unless gradio="none"
@@ -151,30 +152,41 @@ export class ToolSelectionStrategy {
151152
const baseSettings = await this.getUserSettings(context);
152153

153154
// 3. Apply mix if specified and we have base settings
154-
if (mix && BOUQUETS[mix] && baseSettings) {
155-
const mixedTools = [...baseSettings.builtInTools, ...BOUQUETS[mix].builtInTools];
156-
const enabledToolIds = normalizeBuiltInTools(
157-
this.applySearchEnablesFetch([...new Set(mixedTools)])
158-
);
155+
if (mixList.length > 0 && baseSettings) {
156+
const validMixes = mixList.filter((mixName) => {
157+
const isValid = Boolean(BOUQUETS[mixName]);
158+
if (!isValid) {
159+
logger.warn({ mixName }, 'Ignoring invalid mix bouquet name');
160+
}
161+
return isValid;
162+
});
159163

160-
logger.debug(
161-
{
162-
mix,
163-
baseToolCount: baseSettings.builtInTools.length,
164-
mixToolCount: BOUQUETS[mix].builtInTools.length,
165-
finalToolCount: enabledToolIds.length,
166-
},
167-
'Applying mix to user settings'
168-
);
164+
if (validMixes.length > 0) {
165+
const mixedTools = validMixes.flatMap((mixName) => BOUQUETS[mixName]?.builtInTools ?? []);
166+
const combinedTools = [...new Set([...baseSettings.builtInTools, ...mixedTools])];
167+
const enabledToolIds = normalizeBuiltInTools(
168+
this.applySearchEnablesFetch(combinedTools)
169+
);
169170

170-
return {
171-
mode: ToolSelectionMode.MIX,
172-
enabledToolIds,
173-
reason: `User settings + mix(${mix})${gradioSpaceTools.length > 0 ? ` + ${gradioSpaceTools.length} gradio endpoints` : ''}`,
174-
baseSettings,
175-
mixedBouquet: mix,
176-
gradioSpaceTools: gradioSpaceTools.length > 0 ? gradioSpaceTools : undefined,
177-
};
171+
logger.debug(
172+
{
173+
mix: validMixes,
174+
baseToolCount: baseSettings.builtInTools.length,
175+
mixToolCount: mixedTools.length,
176+
finalToolCount: enabledToolIds.length,
177+
},
178+
'Applying mix to user settings'
179+
);
180+
181+
return {
182+
mode: ToolSelectionMode.MIX,
183+
enabledToolIds,
184+
reason: `User settings + mix(${validMixes.join(',')})${gradioSpaceTools.length > 0 ? ` + ${gradioSpaceTools.length} gradio endpoints` : ''}`,
185+
baseSettings,
186+
mixedBouquet: validMixes,
187+
gradioSpaceTools: gradioSpaceTools.length > 0 ? gradioSpaceTools : undefined,
188+
};
189+
}
178190
}
179191

180192
// 4. Use base settings if available

packages/app/src/shared/bouquet-presets.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ import {
55
USE_SPACE_TOOL_ID,
66
HF_JOBS_TOOL_ID,
77
DYNAMIC_SPACE_TOOL_ID,
8+
MODEL_SEARCH_TOOL_ID,
9+
DATASET_SEARCH_TOOL_ID,
10+
DOCS_SEMANTIC_SEARCH_TOOL_ID,
811
} from '@llmindset/hf-mcp';
912
import type { AppSettings } from './settings.js';
1013
import { README_INCLUDE_FLAG, GRADIO_IMAGE_FILTER_FLAG } from './behavior-flags.js';
@@ -26,6 +29,17 @@ export const BOUQUETS: Record<string, AppSettings> = {
2629
builtInTools: [...TOOL_ID_GROUPS.docs],
2730
spaceTools: [],
2831
},
32+
skills: {
33+
builtInTools: [
34+
HUB_INSPECT_TOOL_ID,
35+
README_INCLUDE_FLAG,
36+
MODEL_SEARCH_TOOL_ID,
37+
DATASET_SEARCH_TOOL_ID,
38+
DOCS_SEMANTIC_SEARCH_TOOL_ID,
39+
HF_JOBS_TOOL_ID,
40+
],
41+
spaceTools: [],
42+
},
2943
all: {
3044
builtInTools: [...ALL_BUILTIN_TOOL_IDS],
3145
spaceTools: [],

packages/app/test/server/utils/query-params.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,17 @@ describe('extractQueryParamsToHeaders', () => {
2727
expect(headers['x-mcp-bouquet']).toBeUndefined();
2828
});
2929

30+
it('should join multiple mix query parameters', () => {
31+
const req = {
32+
query: { mix: ['hf_api', 'jobs'] },
33+
} as unknown as Request;
34+
35+
const headers: Record<string, string> = {};
36+
extractQueryParamsToHeaders(req, headers);
37+
38+
expect(headers['x-mcp-mix']).toBe('hf_api,jobs');
39+
});
40+
3041
it('should extract both bouquet and mix query parameters', () => {
3142
const req = {
3243
query: { bouquet: 'search', mix: 'hf_api' },

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

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ describe('extractBouquetAndMix', () => {
2222
const result = extractAuthBouquetAndMix(headers);
2323

2424
expect(result.bouquet).toBeUndefined();
25-
expect(result.mix).toBe('hf_api');
25+
expect(result.mix).toEqual(['hf_api']);
2626
});
2727

2828
it('should extract both bouquet and mix from headers', () => {
@@ -33,7 +33,7 @@ describe('extractBouquetAndMix', () => {
3333
const result = extractAuthBouquetAndMix(headers);
3434

3535
expect(result.bouquet).toBe('search');
36-
expect(result.mix).toBe('hf_api');
36+
expect(result.mix).toEqual(['hf_api']);
3737
});
3838

3939
it('should handle null headers', () => {
@@ -49,6 +49,13 @@ describe('extractBouquetAndMix', () => {
4949
expect(result.bouquet).toBeUndefined();
5050
expect(result.mix).toBeUndefined();
5151
});
52+
53+
it('should parse comma-separated mix list', () => {
54+
const headers = { 'x-mcp-mix': 'hf_api, jobs ,hub_repo_details_readme' };
55+
const result = extractAuthBouquetAndMix(headers);
56+
57+
expect(result.mix).toEqual(['hf_api', 'jobs', 'hub_repo_details_readme']);
58+
});
5259
});
5360

5461
describe('BOUQUETS configuration', () => {
@@ -224,7 +231,7 @@ describe('ToolSelectionStrategy', () => {
224231
expect(result.mode).toBe(ToolSelectionMode.MIX);
225232
expect(result.reason).toBe('User settings + mix(hf_api)');
226233
expect(result.baseSettings).toEqual(userSettings);
227-
expect(result.mixedBouquet).toBe('hf_api');
234+
expect(result.mixedBouquet).toEqual(['hf_api']);
228235

229236
// Should contain user tools + hf_api tools (deduplicated)
230237
const expectedTools = [...new Set([...userSettings.builtInTools, ...TOOL_ID_GROUPS.hf_api])];
@@ -274,6 +281,30 @@ describe('ToolSelectionStrategy', () => {
274281
expect(result.enabledToolIds.length).toBe(uniqueTools.length);
275282
});
276283

284+
it('should mix multiple bouquets when comma separated', async () => {
285+
const userSettings: AppSettings = {
286+
builtInTools: ['hf_whoami'],
287+
spaceTools: [],
288+
};
289+
290+
const context: ToolSelectionContext = {
291+
headers: { 'x-mcp-mix': 'hf_api,search' },
292+
userSettings,
293+
hfToken: 'test-token',
294+
};
295+
296+
const result = await strategy.selectTools(context);
297+
298+
expect(result.mode).toBe(ToolSelectionMode.MIX);
299+
expect(result.reason).toBe('User settings + mix(hf_api,search)');
300+
expect(result.mixedBouquet).toEqual(['hf_api', 'search']);
301+
302+
const expectedTools = normalizeBuiltInTools([
303+
...new Set([...userSettings.builtInTools, ...TOOL_ID_GROUPS.hf_api, ...TOOL_ID_GROUPS.search]),
304+
]);
305+
expect(result.enabledToolIds).toEqual(expectedTools);
306+
});
307+
277308
it('should ignore mix when no user settings available', async () => {
278309
const context: ToolSelectionContext = {
279310
headers: { 'x-mcp-mix': 'hf_api' },
@@ -431,7 +462,7 @@ describe('ToolSelectionStrategy', () => {
431462

432463
expect(result.mode).toBe(ToolSelectionMode.MIX);
433464
expect(result.enabledToolIds).toEqual(TOOL_ID_GROUPS.search);
434-
expect(result.mixedBouquet).toBe('search');
465+
expect(result.mixedBouquet).toEqual(['search']);
435466
});
436467

437468
it('should handle all possible tool types in mix', async () => {
@@ -451,7 +482,7 @@ describe('ToolSelectionStrategy', () => {
451482
const result = await strategy.selectTools(context);
452483

453484
expect(result.mode).toBe(ToolSelectionMode.MIX);
454-
expect(result.mixedBouquet).toBe(bouquetName);
485+
expect(result.mixedBouquet).toEqual([bouquetName]);
455486

456487
const expectedTools = [...new Set([...userSettings.builtInTools, ...bouquetConfig.builtInTools])];
457488
expect(result.enabledToolIds).toEqual(normalizeBuiltInTools(expectedTools));
@@ -486,7 +517,7 @@ describe('ToolSelectionStrategy', () => {
486517
const result = await strategy.selectTools(context);
487518

488519
expect(result.mode).toBe(ToolSelectionMode.MIX);
489-
expect(result.mixedBouquet).toBe('all');
520+
expect(result.mixedBouquet).toEqual(['all']);
490521
expect(result.reason).toBe('User settings + mix(all)');
491522

492523
// Should get user's minimal tools + ALL built-in tools (deduplicated)

0 commit comments

Comments
 (0)