Skip to main content
Glama
search-tools.test.ts12.9 kB
/** * Search Tools Meta-Tool Tests * * Tests for wpnav_search_tools - semantic tool search functionality. * * @package WP_Navigator_MCP * @since 2.7.0 */ import { describe, it, expect, beforeEach } from 'vitest'; import { searchToolsHandler, searchToolsDefinition } from './search-tools.js'; import { _resetState, _setToolVectors, type ToolEmbedding } from '../../embeddings/index.js'; // Mock context (not used by search tools but required by handler signature) const mockContext = { wpRequest: async () => ({}), config: {}, logger: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} }, clampText: (text: string) => text, }; // Test tool data matching the actual categories const TEST_TOOLS: ToolEmbedding[] = [ { name: 'wpnav_list_posts', description: 'List WordPress posts with filtering, pagination, and field selection', category: 'content', keywords: ['list', 'post', 'wordpress', 'filter', 'paginat', 'field', 'select', 'blog'], }, { name: 'wpnav_create_post', description: 'Create a new WordPress blog post with title, content, and metadata', category: 'content', keywords: ['creat', 'post', 'wordpress', 'titl', 'content', 'metadata', 'blog'], }, { name: 'wpnav_update_post', description: 'Update an existing WordPress post', category: 'content', keywords: ['updat', 'post', 'wordpress', 'edit'], }, { name: 'wpnav_list_pages', description: 'List WordPress pages with hierarchical structure', category: 'content', keywords: ['list', 'page', 'wordpress', 'hierarch', 'structur'], }, { name: 'wpnav_list_plugins', description: 'List installed WordPress plugins with status information', category: 'plugins', keywords: ['list', 'plugin', 'wordpress', 'install', 'status', 'informat', 'manag'], }, { name: 'wpnav_activate_plugin', description: 'Activate a WordPress plugin by slug', category: 'plugins', keywords: ['activ', 'plugin', 'wordpress', 'slug', 'enabl'], }, { name: 'wpnav_deactivate_plugin', description: 'Deactivate a WordPress plugin by slug', category: 'plugins', keywords: ['deactiv', 'plugin', 'wordpress', 'slug', 'disabl'], }, { name: 'wpnav_list_themes', description: 'List installed WordPress themes with activation status', category: 'themes', keywords: ['list', 'theme', 'wordpress', 'install', 'activ', 'status'], }, { name: 'wpnav_activate_theme', description: 'Activate a WordPress theme by stylesheet', category: 'themes', keywords: ['activ', 'theme', 'wordpress', 'stylesheet', 'switch'], }, { name: 'wpnav_list_users', description: 'List WordPress users with role filtering', category: 'users', keywords: ['list', 'user', 'wordpress', 'role', 'filter'], }, { name: 'wpnav_introspect', description: 'Get WP Navigator API capabilities and site information', category: 'core', keywords: ['introspect', 'capabil', 'site', 'informat', 'api'], }, { name: 'wpnav_help', description: 'Get help and quickstart guide for WP Navigator', category: 'core', keywords: ['help', 'quickstart', 'guid', 'start'], }, ]; describe('searchToolsDefinition', () => { it('has correct name', () => { expect(searchToolsDefinition.name).toBe('wpnav_search_tools'); }); it('has description mentioning natural language query', () => { expect(searchToolsDefinition.description).toContain('natural language'); }); it('has inputSchema with query, category, and limit properties', () => { const props = searchToolsDefinition.inputSchema.properties; expect(props).toHaveProperty('query'); expect(props).toHaveProperty('category'); expect(props).toHaveProperty('limit'); }); it('has valid category enum', () => { const categoryEnum = searchToolsDefinition.inputSchema.properties.category.enum; expect(categoryEnum).toContain('content'); expect(categoryEnum).toContain('plugins'); expect(categoryEnum).toContain('themes'); expect(categoryEnum).toContain('users'); expect(categoryEnum).toContain('core'); }); it('has limit with maximum of 25', () => { expect(searchToolsDefinition.inputSchema.properties.limit.maximum).toBe(25); }); }); describe('searchToolsHandler', () => { beforeEach(() => { _resetState(); _setToolVectors(TEST_TOOLS); }); describe('input validation', () => { it('returns error when neither query nor category provided', async () => { const result = await searchToolsHandler({}, mockContext); const response = JSON.parse(result.content[0].text); expect(response.error).toBe('At least one of query or category is required'); expect(response.available_categories).toBeDefined(); }); it('returns error for invalid category', async () => { const result = await searchToolsHandler({ category: 'nonexistent' }, mockContext); const response = JSON.parse(result.content[0].text); expect(response.error).toContain('Invalid category'); expect(response.available_categories).toBeDefined(); }); }); describe('semantic search (query only)', () => { it('finds relevant tools for "create blog post"', async () => { const result = await searchToolsHandler({ query: 'create blog post' }, mockContext); const response = JSON.parse(result.content[0].text); expect(response.tools).toBeDefined(); expect(response.tools.length).toBeGreaterThan(0); const names = response.tools.map((t: any) => t.name); expect(names.some((n: string) => n.includes('post'))).toBe(true); }); it('finds plugin tools for "manage plugins"', async () => { const result = await searchToolsHandler({ query: 'manage plugins' }, mockContext); const response = JSON.parse(result.content[0].text); const names = response.tools.map((t: any) => t.name); expect(names.some((n: string) => n.includes('plugin'))).toBe(true); }); it('returns results sorted by relevance', async () => { const result = await searchToolsHandler({ query: 'list posts' }, mockContext); const response = JSON.parse(result.content[0].text); // First result should be most relevant expect(response.tools[0].name).toContain('post'); }); }); describe('category filter (category only)', () => { it('returns only tools from specified category', async () => { const result = await searchToolsHandler({ category: 'plugins' }, mockContext); const response = JSON.parse(result.content[0].text); expect(response.tools.length).toBeGreaterThan(0); for (const tool of response.tools) { expect(tool.category).toBe('plugins'); } }); it('handles case-insensitive category', async () => { const result1 = await searchToolsHandler({ category: 'plugins' }, mockContext); const result2 = await searchToolsHandler({ category: 'PLUGINS' }, mockContext); // Both should work (category validation is case-sensitive in enum, but lowercase expected) // Only lowercase should work based on our implementation const response1 = JSON.parse(result1.content[0].text); expect(response1.tools).toBeDefined(); }); it('returns content tools correctly', async () => { const result = await searchToolsHandler({ category: 'content' }, mockContext); const response = JSON.parse(result.content[0].text); expect(response.tools.length).toBeGreaterThan(0); const names = response.tools.map((t: any) => t.name); expect(names).toContain('wpnav_list_posts'); expect(names).toContain('wpnav_create_post'); }); }); describe('combined query + category', () => { it('filters semantic search results by category', async () => { const result = await searchToolsHandler({ query: 'list', category: 'plugins' }, mockContext); const response = JSON.parse(result.content[0].text); // Should only return plugin tools that match "list" for (const tool of response.tools) { expect(tool.category).toBe('plugins'); } const names = response.tools.map((t: any) => t.name); expect(names).toContain('wpnav_list_plugins'); }); it('returns empty results when query has no matches in category', async () => { const result = await searchToolsHandler( { query: 'posts pages content', category: 'users' }, mockContext ); const response = JSON.parse(result.content[0].text); // Should return user tools or empty if no semantic match for (const tool of response.tools) { expect(tool.category).toBe('users'); } }); }); describe('response format', () => { it('returns tools array with name, description, category (no schemas)', async () => { const result = await searchToolsHandler({ query: 'posts' }, mockContext); const response = JSON.parse(result.content[0].text); expect(response.tools).toBeDefined(); for (const tool of response.tools) { expect(tool).toHaveProperty('name'); expect(tool).toHaveProperty('description'); expect(tool).toHaveProperty('category'); // Should NOT have schema expect(tool).not.toHaveProperty('inputSchema'); expect(tool).not.toHaveProperty('schema'); } }); it('includes total_matching count', async () => { const result = await searchToolsHandler({ category: 'content' }, mockContext); const response = JSON.parse(result.content[0].text); expect(response.total_matching).toBeDefined(); expect(typeof response.total_matching).toBe('number'); expect(response.total_matching).toBe(response.tools.length); }); it('includes hint about wpnav_describe_tools', async () => { const result = await searchToolsHandler({ query: 'posts' }, mockContext); const response = JSON.parse(result.content[0].text); expect(response.hint).toBeDefined(); expect(response.hint).toContain('wpnav_describe_tools'); }); }); describe('limit parameter', () => { it('respects limit parameter', async () => { const result = await searchToolsHandler({ query: 'list', limit: 3 }, mockContext); const response = JSON.parse(result.content[0].text); expect(response.tools.length).toBeLessThanOrEqual(3); }); it('uses default limit of 10', async () => { const result = await searchToolsHandler({ category: 'content' }, mockContext); const response = JSON.parse(result.content[0].text); // Our test data has 4 content tools, so this tests that default doesn't limit below actual expect(response.tools.length).toBeLessThanOrEqual(10); }); it('clamps limit to maximum of 25', async () => { const result = await searchToolsHandler({ query: 'wordpress', limit: 100 }, mockContext); const response = JSON.parse(result.content[0].text); expect(response.tools.length).toBeLessThanOrEqual(25); }); it('clamps limit to minimum of 1', async () => { const result = await searchToolsHandler({ query: 'posts', limit: 0 }, mockContext); const response = JSON.parse(result.content[0].text); // Should return at least 1 result (clamped from 0 to 1) expect(response.tools.length).toBeGreaterThanOrEqual(0); }); }); describe('edge cases', () => { it('handles empty search results gracefully', async () => { const result = await searchToolsHandler({ query: 'xyznonexistentterm123' }, mockContext); const response = JSON.parse(result.content[0].text); expect(response.tools).toBeDefined(); expect(Array.isArray(response.tools)).toBe(true); expect(response.total_matching).toBe(0); }); it('returns valid JSON response', async () => { const result = await searchToolsHandler({ query: 'posts' }, mockContext); expect(result.content).toHaveLength(1); expect(result.content[0].type).toBe('text'); expect(() => JSON.parse(result.content[0].text)).not.toThrow(); }); }); }); describe('integration with embeddings module', () => { beforeEach(() => { _resetState(); _setToolVectors(TEST_TOOLS); }); it('uses TF-IDF search by default (fast)', async () => { // This tests that the handler works without requiring neural embeddings const result = await searchToolsHandler({ query: 'create blog' }, mockContext); const response = JSON.parse(result.content[0].text); expect(response.tools.length).toBeGreaterThan(0); }); it('category filter uses searchByCategory from embeddings', async () => { const result = await searchToolsHandler({ category: 'core' }, mockContext); const response = JSON.parse(result.content[0].text); expect(response.tools.length).toBeGreaterThan(0); expect(response.tools.every((t: any) => t.category === 'core')).toBe(true); }); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/littlebearapps/wp-navigator-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server