@loop_ouroboros/mcp-hub-lite 1.3.1 → 1.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +8 -3
  3. package/dist/server/src/api/web/hub-tools.d.ts.map +1 -1
  4. package/dist/server/src/api/web/hub-tools.js +2 -2
  5. package/dist/server/src/api/web/search.d.ts +1 -1
  6. package/dist/server/src/api/web/search.d.ts.map +1 -1
  7. package/dist/server/src/api/web/search.js +18 -16
  8. package/dist/server/src/models/system-tools.constants.d.ts +1 -0
  9. package/dist/server/src/models/system-tools.constants.d.ts.map +1 -1
  10. package/dist/server/src/services/gateway/request-handlers/system-tools-handler.d.ts.map +1 -1
  11. package/dist/server/src/services/gateway/request-handlers/system-tools-handler.js +3 -2
  12. package/dist/server/src/services/hub-tools/index.d.ts +1 -0
  13. package/dist/server/src/services/hub-tools/index.d.ts.map +1 -1
  14. package/dist/server/src/services/hub-tools/index.js +1 -0
  15. package/dist/server/src/services/hub-tools/resource-generator.d.ts +1 -1
  16. package/dist/server/src/services/hub-tools/resource-generator.d.ts.map +1 -1
  17. package/dist/server/src/services/hub-tools/resource-generator.js +11 -29
  18. package/dist/server/src/services/hub-tools/server-metadata-cache.d.ts +19 -0
  19. package/dist/server/src/services/hub-tools/server-metadata-cache.d.ts.map +1 -0
  20. package/dist/server/src/services/hub-tools/server-metadata-cache.js +117 -0
  21. package/dist/server/src/services/hub-tools/server-selector.d.ts +1 -1
  22. package/dist/server/src/services/hub-tools/server-selector.d.ts.map +1 -1
  23. package/dist/server/src/services/hub-tools/server-selector.js +3 -8
  24. package/dist/server/src/services/hub-tools/system-tool-definitions.d.ts.map +1 -1
  25. package/dist/server/src/services/hub-tools/system-tool-definitions.js +8 -1
  26. package/dist/server/src/services/hub-tools.service.d.ts +1 -1
  27. package/dist/server/src/services/hub-tools.service.d.ts.map +1 -1
  28. package/dist/server/src/services/hub-tools.service.js +62 -88
  29. package/dist/server/src/services/system-tool-handler.js +1 -1
  30. package/dist/server/src/utils/search-matcher.d.ts +6 -0
  31. package/dist/server/src/utils/search-matcher.d.ts.map +1 -0
  32. package/dist/server/src/utils/search-matcher.js +24 -0
  33. package/dist/server/tests/unit/services/hub-tools.service.test.js +358 -8
  34. package/package.json +1 -1
@@ -6,10 +6,11 @@ import { generateGatewayToolsList } from './gateway/tool-list-generator.js';
6
6
  import { logger, LOG_MODULES } from '../utils/logger/index.js';
7
7
  import { stringifyForLogging } from '../utils/json-utils.js';
8
8
  import { normalizeToolName } from '../utils/name-converter.js';
9
+ import { countMatchingTokens } from '../utils/search-matcher.js';
9
10
  import { McpError } from '@modelcontextprotocol/sdk/types.js';
10
11
  import { MCP_HUB_LITE_SERVER, LIST_SERVERS_TOOL, LIST_TOOLS_TOOL, GET_TOOL_TOOL, CALL_TOOL_TOOL, UPDATE_SERVER_DESCRIPTION_TOOL, LIST_TAGS_TOOL, SEARCH_TOOLS_TOOL, SYSTEM_TOOL_NAMES } from '../models/system-tools.constants.js';
11
12
  import { ToolArgsParser } from '../utils/tool-args-parser.js';
12
- import { hasValidId, selectBestInstance, getServerDescription, getSystemTools, generateDynamicResources, readResource as readResourceUtil } from './hub-tools/index.js';
13
+ import { hasValidId, selectBestInstance, getServerDescription, getSystemTools, generateDynamicResources, readResource as readResourceUtil, serverMetadataCache } from './hub-tools/index.js';
13
14
  import { InstanceSelector } from './hub-tools/instance-selector.js';
14
15
  import { InstanceSelectionStrategy } from '../models/server.model.js';
15
16
  /**
@@ -63,7 +64,7 @@ import { InstanceSelectionStrategy } from '../models/server.model.js';
63
64
  export class HubToolsService {
64
65
  // Cache removed - listResources() now calls generateDynamicResources() directly
65
66
  constructor() {
66
- // No cache-related initialization needed
67
+ serverMetadataCache.initialize();
67
68
  }
68
69
  /**
69
70
  * Retrieves the complete list of system tools provided by this service.
@@ -371,86 +372,54 @@ export class HubToolsService {
371
372
  throw new McpError(-32801, `System tools cannot be called via 'call_tool'. Use 'tools/call' with the system tool name directly. ` +
372
373
  `Example: use 'list_servers' directly instead of call_tool(serverName: "mcp-hub-lite", toolName: "list_servers").`);
373
374
  }
374
- // Not a system tool - find it in all connected servers
375
- logger.info(`Looking for tool '${toolName}' in all connected servers (gateway mode)`, LOG_MODULES.HUB_TOOLS);
376
- // Find all servers that have this tool
377
- const matchingServers = [];
378
- const servers = hubManager.getAllServers();
379
- for (const server of servers) {
380
- if (!hasValidId(server)) {
381
- continue;
382
- }
383
- const serverInfo = selectBestInstance(server.name, requestOptions, true);
384
- if (serverInfo && serverInfo.instance.id) {
385
- const instanceIndex = serverInfo.instance.index;
386
- const tools = mcpConnectionManager.getTools(server.name, instanceIndex);
387
- if (tools.some((tool) => normalizeToolName(tool.name) === normalizeToolName(toolName))) {
388
- matchingServers.push(server.name);
389
- }
390
- }
391
- }
392
- if (matchingServers.length === 0) {
393
- logger.error(`Tool '${toolName}' not found in any connected server`, LOG_MODULES.HUB_TOOLS);
394
- throw new Error(`Tool '${toolName}' not found`);
395
- }
396
- if (matchingServers.length > 1) {
397
- logger.warn(`Tool '${toolName}' found in multiple servers: ${matchingServers.join(', ')}. Using first match.`, LOG_MODULES.HUB_TOOLS);
398
- }
399
- // Use the first matching server
400
- serverName = matchingServers[0];
375
+ // Not a system tool reject with actionable guidance
376
+ // The aggregated gateway tools (wrapped by tool-list-generator) already hardcode
377
+ // the correct serverName, so this path should only be hit when LLM manually fills
378
+ // "mcp-hub-lite" incorrectly. Guide them to use search_tools instead.
379
+ throw new McpError(-32602, `Cannot call external tool '${toolName}' with serverName "mcp-hub-lite". ` +
380
+ `Use 'search_tools' to find which server provides this tool, ` +
381
+ `then call it with the correct serverName.`);
401
382
  }
402
383
  logger.debug(`Tool call received: serverName=${serverName}, toolName=${toolName}, args=${stringifyForLogging(toolArgs)}`, LOG_MODULES.HUB_TOOLS);
403
- // Validate tool exists before doing strict instance selection
404
- // Use strictMode=false to get serverInfo without triggering tag-match-unique errors
405
- const validationServerInfo = selectBestInstance(serverName, requestOptions, false);
406
- let actualToolName;
407
- if (validationServerInfo && validationServerInfo.instance.id) {
408
- const instanceIndex = validationServerInfo.instance.index;
409
- const tools = mcpConnectionManager.getTools(serverName, instanceIndex);
410
- const matchedTool = tools.find((tool) => normalizeToolName(tool.name) === normalizeToolName(toolName));
411
- if (!matchedTool) {
412
- throw new Error(`Tool '${toolName}' not found in server '${serverName}'. ` +
413
- `Use list_tools(serverName: "${serverName}") to see available tools.`);
414
- }
415
- actualToolName = matchedTool.name;
384
+ // Validate tool exists using server-name-level aggregation (no instance selection needed)
385
+ const aggregatedTools = mcpConnectionManager.getToolsByServerName(serverName);
386
+ const matchedTool = aggregatedTools.find((tool) => normalizeToolName(tool.name) === normalizeToolName(toolName));
387
+ if (!matchedTool) {
388
+ throw new Error(`Tool '${toolName}' not found in server '${serverName}'. ` +
389
+ `Use list_tools(serverName: "${serverName}") to see available tools.`);
416
390
  }
417
- const serverInfo = selectBestInstance(serverName, requestOptions, true);
391
+ let actualToolName = matchedTool.name;
392
+ const serverInfo = selectBestInstance(serverName, requestOptions);
418
393
  const requestId = `tool-call-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
419
394
  if (!serverInfo) {
420
395
  // Server not found in hubManager, try direct call by name through mcpConnectionManager
421
396
  logger.debug(`Server not found in hubManager, trying direct call by name: ${serverName}`, LOG_MODULES.HUB_TOOLS);
422
- // Use selectBestInstance with non-strict mode to find an available instance
423
- let fallbackServerInfo = selectBestInstance(serverName, undefined, false);
424
- // If selectBestInstance returns undefined (e.g., TAG_MATCH_UNIQUE strategy without tags),
425
- // fall back to directly selecting an enabled instance with RANDOM strategy
426
- if (!fallbackServerInfo) {
427
- logger.debug(`selectBestInstance returned undefined for ${serverName}, trying direct instance selection with RANDOM strategy`, LOG_MODULES.HUB_TOOLS);
428
- const serverConfig = hubManager.getServerByName(serverName);
429
- if (serverConfig && serverConfig.instances.length > 0) {
430
- // Filter: use runtime connected status, NOT config enabled flag
431
- // enabled=false means "do not auto-start" but user can manually start it
432
- const connectedInstances = serverConfig.instances.filter((instance) => {
433
- if (instance.index === undefined)
434
- return false;
435
- const status = mcpConnectionManager.getStatus(serverName, instance.index);
436
- return status?.connected;
437
- });
438
- if (connectedInstances.length > 0) {
439
- // Use RANDOM strategy regardless of server's configured strategy
440
- const selectedInstance = InstanceSelector.selectInstance(serverName, {
441
- ...serverConfig,
442
- template: {
443
- ...serverConfig.template,
444
- instanceSelectionStrategy: InstanceSelectionStrategy.RANDOM
445
- }
446
- }, undefined);
447
- if (selectedInstance) {
448
- fallbackServerInfo = {
449
- name: serverName,
450
- config: serverConfig,
451
- instance: selectedInstance
452
- };
397
+ // Fallback: force RANDOM strategy on any connected instance
398
+ // (selectBestInstance may fail for TAG_MATCH_UNIQUE without tags)
399
+ let fallbackServerInfo;
400
+ const serverConfig = hubManager.getServerByName(serverName);
401
+ if (serverConfig && serverConfig.instances.length > 0) {
402
+ // Filter: use runtime connected status, NOT config enabled flag
403
+ const connectedInstances = serverConfig.instances.filter((instance) => {
404
+ if (instance.index === undefined)
405
+ return false;
406
+ const status = mcpConnectionManager.getStatus(serverName, instance.index);
407
+ return status?.connected;
408
+ });
409
+ if (connectedInstances.length > 0) {
410
+ const selectedInstance = InstanceSelector.selectInstance(serverName, {
411
+ ...serverConfig,
412
+ template: {
413
+ ...serverConfig.template,
414
+ instanceSelectionStrategy: InstanceSelectionStrategy.RANDOM
453
415
  }
416
+ }, undefined);
417
+ if (selectedInstance) {
418
+ fallbackServerInfo = {
419
+ name: serverName,
420
+ config: serverConfig,
421
+ instance: selectedInstance
422
+ };
454
423
  }
455
424
  }
456
425
  }
@@ -601,11 +570,11 @@ export class HubToolsService {
601
570
  * @returns {Promise<Record<string, { description: string; tools: ToolSummary[] }>>}
602
571
  * Object mapping server names to their descriptions and matching tools
603
572
  */
604
- async searchTools(query) {
573
+ async searchTools(query, limit = 5) {
605
574
  if (!query || typeof query !== 'string') {
606
575
  throw new Error('query is required and must be a non-empty string');
607
576
  }
608
- const normalizedQuery = query.toLowerCase();
577
+ const effectiveLimit = Math.min(Math.max(1, limit), 10);
609
578
  const servers = hubManager.getAllServers();
610
579
  const result = {};
611
580
  for (const server of servers) {
@@ -621,21 +590,26 @@ export class HubToolsService {
621
590
  if (tools.length === 0) {
622
591
  continue;
623
592
  }
624
- const matchingTools = tools
625
- .filter((tool) => {
626
- const nameMatch = tool.name.toLowerCase().includes(normalizedQuery);
627
- const descMatch = tool.description?.toLowerCase().includes(normalizedQuery);
628
- return nameMatch || descMatch;
593
+ const scored = tools
594
+ .map((tool) => {
595
+ const matchCount = countMatchingTokens(query, [tool.name, tool.description || '']);
596
+ return {
597
+ tool,
598
+ matchCount,
599
+ summary: {
600
+ name: tool.name,
601
+ description: tool.description,
602
+ serverName: server.name
603
+ }
604
+ };
629
605
  })
630
- .map((tool) => ({
631
- name: tool.name,
632
- description: tool.description,
633
- serverName: server.name
634
- }));
635
- if (matchingTools.length > 0) {
606
+ .filter((item) => item.matchCount > 0)
607
+ .sort((a, b) => b.matchCount - a.matchCount)
608
+ .slice(0, effectiveLimit);
609
+ if (scored.length > 0) {
636
610
  result[server.name] = {
637
611
  description,
638
- tools: matchingTools
612
+ tools: scored.map((item) => item.summary)
639
613
  };
640
614
  }
641
615
  }
@@ -63,7 +63,7 @@ export class SystemToolHandler {
63
63
  if (!searchArgs.query) {
64
64
  throw new McpError(-32802, 'query is required for search_tools');
65
65
  }
66
- result = await hubToolsService.searchTools(searchArgs.query);
66
+ result = await hubToolsService.searchTools(searchArgs.query, searchArgs.limit);
67
67
  break;
68
68
  }
69
69
  default:
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Counts how many tokens from a whitespace-delimited query match against any of the given fields.
3
+ * Returns a large value for empty or whitespace-only queries so they match all results.
4
+ */
5
+ export declare function countMatchingTokens(query: string, fields: string[]): number;
6
+ //# sourceMappingURL=search-matcher.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"search-matcher.d.ts","sourceRoot":"","sources":["../../../../src/utils/search-matcher.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,CAwB3E"}
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Counts how many tokens from a whitespace-delimited query match against any of the given fields.
3
+ * Returns a large value for empty or whitespace-only queries so they match all results.
4
+ */
5
+ export function countMatchingTokens(query, fields) {
6
+ if (!query || typeof query !== 'string') {
7
+ return 0;
8
+ }
9
+ const tokens = query
10
+ .toLowerCase()
11
+ .split(/[,\s;|]+/)
12
+ .filter(Boolean);
13
+ if (tokens.length === 0) {
14
+ return Number.MAX_SAFE_INTEGER;
15
+ }
16
+ const lowerFields = fields.map((f) => (f || '').toLowerCase());
17
+ let matchCount = 0;
18
+ for (const token of tokens) {
19
+ if (lowerFields.some((field) => field.includes(token))) {
20
+ matchCount++;
21
+ }
22
+ }
23
+ return matchCount;
24
+ }