diff --git a/tests/Unit/ToolRegistryTest.php b/tests/Unit/ToolRegistryTest.php new file mode 100644 index 0000000..e7a7214 --- /dev/null +++ b/tests/Unit/ToolRegistryTest.php @@ -0,0 +1,793 @@ +makePartial()->shouldAllowMockingProtectedMethods(); + + $mock->shouldReceive('loadRegistry')->andReturn($registry); + + foreach ($servers as $id => $config) { + $mock->shouldReceive('loadServerFull')->with($id)->andReturn($config); + } + + // Default: unknown server IDs return null + $mock->shouldReceive('loadServerFull')->andReturn(null)->byDefault(); + + return $mock; +} + +/* +|-------------------------------------------------------------------------- +| Fixture data +|-------------------------------------------------------------------------- +*/ + +function sampleRegistry(): array +{ + return [ + 'servers' => [ + ['id' => 'workspace-tools'], + ['id' => 'billing-tools'], + ], + ]; +} + +function sampleServer(string $id = 'workspace-tools'): array +{ + return match ($id) { + 'workspace-tools' => [ + 'id' => 'workspace-tools', + 'name' => 'Workspace Tools', + 'tagline' => 'Database and system utilities', + 'tools' => [ + [ + 'name' => 'query_database', + 'description' => 'Execute a read-only SQL SELECT query', + 'category' => 'database', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'query' => ['type' => 'string'], + 'explain' => ['type' => 'boolean', 'default' => false], + ], + ], + ], + [ + 'name' => 'list_tables', + 'description' => 'List all database tables', + 'category' => 'database', + 'inputSchema' => ['type' => 'object', 'properties' => []], + ], + [ + 'name' => 'list_routes', + 'description' => 'List application routes', + 'category' => 'system', + 'inputSchema' => ['type' => 'object', 'properties' => []], + ], + ], + ], + 'billing-tools' => [ + 'id' => 'billing-tools', + 'name' => 'Billing Tools', + 'tagline' => 'Invoice and subscription management', + 'tools' => [ + [ + 'name' => 'list_invoices', + 'description' => 'List invoices by status', + 'category' => 'commerce', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'status' => ['type' => 'string', 'enum' => ['paid', 'unpaid', 'overdue']], + 'limit' => ['type' => 'integer', 'default' => 25], + ], + ], + ], + [ + 'name' => 'get_billing_status', + 'description' => 'Get current billing status', + 'inputSchema' => ['type' => 'object', 'properties' => []], + ], + ], + ], + default => [], + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('ToolRegistry', function () { + + beforeEach(function () { + // Ensure cache is bypassed for every test — return null so the + // callback inside Cache::remember always executes. + Cache::shouldReceive('remember')->andReturnUsing( + fn (string $key, int $ttl, Closure $callback) => $callback() + ); + Cache::shouldReceive('forget')->andReturn(true); + }); + + // ----------------------------------------------------------------------- + // getServers() + // ----------------------------------------------------------------------- + + describe('getServers', function () { + it('returns a collection of server summaries', function () { + $registry = makeRegistry( + sampleRegistry(), + [ + 'workspace-tools' => sampleServer('workspace-tools'), + 'billing-tools' => sampleServer('billing-tools'), + ] + ); + + $servers = $registry->getServers(); + + expect($servers)->toHaveCount(2); + expect($servers[0]['id'])->toBe('workspace-tools'); + expect($servers[0]['name'])->toBe('Workspace Tools'); + expect($servers[0]['tagline'])->toBe('Database and system utilities'); + expect($servers[0]['tool_count'])->toBe(3); + + expect($servers[1]['id'])->toBe('billing-tools'); + expect($servers[1]['tool_count'])->toBe(2); + }); + + it('returns empty collection when no servers are registered', function () { + $registry = makeRegistry(['servers' => []], []); + + $servers = $registry->getServers(); + + expect($servers)->toBeEmpty(); + }); + + it('returns empty collection when registry has no servers key', function () { + $registry = makeRegistry([], []); + + $servers = $registry->getServers(); + + expect($servers)->toBeEmpty(); + }); + + it('filters out servers whose YAML file is missing', function () { + // billing-tools has no corresponding server config + $registry = makeRegistry( + sampleRegistry(), + ['workspace-tools' => sampleServer('workspace-tools')] + ); + + // loadServerFull for billing-tools will return null (default) + $servers = $registry->getServers(); + + expect($servers)->toHaveCount(1); + expect($servers[0]['id'])->toBe('workspace-tools'); + }); + }); + + // ----------------------------------------------------------------------- + // getToolsForServer() + // ----------------------------------------------------------------------- + + describe('getToolsForServer', function () { + it('returns tools with expected shape', function () { + $registry = makeRegistry( + sampleRegistry(), + ['workspace-tools' => sampleServer('workspace-tools')] + ); + + $tools = $registry->getToolsForServer('workspace-tools'); + + expect($tools)->toHaveCount(3); + + $first = $tools[0]; + expect($first)->toHaveKeys(['name', 'description', 'category', 'inputSchema', 'examples', 'version']); + expect($first['name'])->toBe('query_database'); + expect($first['description'])->toBe('Execute a read-only SQL SELECT query'); + }); + + it('returns built-in examples for known tools', function () { + $registry = makeRegistry( + sampleRegistry(), + ['workspace-tools' => sampleServer('workspace-tools')] + ); + + $tools = $registry->getToolsForServer('workspace-tools'); + $queryDb = $tools->firstWhere('name', 'query_database'); + + // query_database has predefined examples in the registry + expect($queryDb['examples'])->toBe([ + 'query' => 'SELECT id, name FROM users LIMIT 10', + ]); + }); + + it('generates examples from schema when no built-in examples exist', function () { + $serverConfig = [ + 'id' => 'custom-server', + 'name' => 'Custom', + 'tagline' => '', + 'tools' => [ + [ + 'name' => 'custom_tool', + 'description' => 'A custom tool', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string', 'default' => 'hello'], + 'count' => ['type' => 'integer', 'minimum' => 5], + 'active' => ['type' => 'boolean'], + 'tags' => ['type' => 'array'], + ], + ], + ], + ], + ]; + + $registry = makeRegistry( + ['servers' => [['id' => 'custom-server']]], + ['custom-server' => $serverConfig] + ); + + $tools = $registry->getToolsForServer('custom-server'); + $examples = $tools[0]['examples']; + + expect($examples['name'])->toBe('hello'); // default + expect($examples['count'])->toBe(5); // minimum + expect($examples['active'])->toBeFalse(); // boolean default + expect($examples['tags'])->toBe([]); // array default + }); + + it('returns empty collection for unknown server', function () { + $registry = makeRegistry(sampleRegistry(), []); + + $tools = $registry->getToolsForServer('nonexistent'); + + expect($tools)->toBeEmpty(); + }); + + it('extracts category from tool definition', function () { + $registry = makeRegistry( + sampleRegistry(), + ['workspace-tools' => sampleServer('workspace-tools')] + ); + + $tools = $registry->getToolsForServer('workspace-tools'); + + expect($tools[0]['category'])->toBe('Database'); + expect($tools[2]['category'])->toBe('System'); + }); + + it('uses purpose field as description fallback', function () { + $serverConfig = [ + 'id' => 'test-server', + 'name' => 'Test', + 'tagline' => '', + 'tools' => [ + [ + 'name' => 'my_tool', + 'purpose' => 'A fallback description', + 'inputSchema' => ['type' => 'object', 'properties' => []], + ], + ], + ]; + + $registry = makeRegistry( + ['servers' => [['id' => 'test-server']]], + ['test-server' => $serverConfig] + ); + + $tools = $registry->getToolsForServer('test-server'); + + expect($tools[0]['description'])->toBe('A fallback description'); + }); + + it('builds inputSchema from parameters when inputSchema is absent', function () { + $serverConfig = [ + 'id' => 'test-server', + 'name' => 'Test', + 'tagline' => '', + 'tools' => [ + [ + 'name' => 'legacy_tool', + 'description' => 'Uses legacy parameters key', + 'parameters' => [ + 'query' => ['type' => 'string'], + ], + ], + ], + ]; + + $registry = makeRegistry( + ['servers' => [['id' => 'test-server']]], + ['test-server' => $serverConfig] + ); + + $tools = $registry->getToolsForServer('test-server'); + + expect($tools[0]['inputSchema'])->toBe([ + 'type' => 'object', + 'properties' => ['query' => ['type' => 'string']], + ]); + }); + }); + + // ----------------------------------------------------------------------- + // getTool() — single tool lookup + // ----------------------------------------------------------------------- + + describe('getTool', function () { + it('returns a single tool by name', function () { + $registry = makeRegistry( + sampleRegistry(), + ['workspace-tools' => sampleServer('workspace-tools')] + ); + + $tool = $registry->getTool('workspace-tools', 'list_tables'); + + expect($tool)->not->toBeNull(); + expect($tool['name'])->toBe('list_tables'); + expect($tool['description'])->toBe('List all database tables'); + }); + + it('returns null for unknown tool name', function () { + $registry = makeRegistry( + sampleRegistry(), + ['workspace-tools' => sampleServer('workspace-tools')] + ); + + $tool = $registry->getTool('workspace-tools', 'does_not_exist'); + + expect($tool)->toBeNull(); + }); + + it('returns null when server does not exist', function () { + $registry = makeRegistry(sampleRegistry(), []); + + $tool = $registry->getTool('nonexistent', 'query_database'); + + expect($tool)->toBeNull(); + }); + }); + + // ----------------------------------------------------------------------- + // getToolsByCategory() + // ----------------------------------------------------------------------- + + describe('getToolsByCategory', function () { + it('groups tools by their category', function () { + $registry = makeRegistry( + sampleRegistry(), + ['workspace-tools' => sampleServer('workspace-tools')] + ); + + $categories = $registry->getToolsByCategory('workspace-tools'); + + expect($categories)->toHaveKey('Database'); + expect($categories)->toHaveKey('System'); + expect($categories['Database'])->toHaveCount(2); + expect($categories['System'])->toHaveCount(1); + }); + + it('returns empty collection for unknown server', function () { + $registry = makeRegistry(sampleRegistry(), []); + + $categories = $registry->getToolsByCategory('nonexistent'); + + expect($categories)->toBeEmpty(); + }); + }); + + // ----------------------------------------------------------------------- + // searchTools() + // ----------------------------------------------------------------------- + + describe('searchTools', function () { + it('finds tools by name substring', function () { + $registry = makeRegistry( + sampleRegistry(), + ['workspace-tools' => sampleServer('workspace-tools')] + ); + + $results = $registry->searchTools('workspace-tools', 'list'); + + expect($results)->toHaveCount(2); + expect($results->pluck('name')->toArray())->toContain('list_tables', 'list_routes'); + }); + + it('finds tools by description substring', function () { + $registry = makeRegistry( + sampleRegistry(), + ['workspace-tools' => sampleServer('workspace-tools')] + ); + + $results = $registry->searchTools('workspace-tools', 'SQL'); + + expect($results)->toHaveCount(1); + expect($results[0]['name'])->toBe('query_database'); + }); + + it('finds tools by category substring', function () { + $registry = makeRegistry( + sampleRegistry(), + ['workspace-tools' => sampleServer('workspace-tools')] + ); + + $results = $registry->searchTools('workspace-tools', 'system'); + + expect($results)->toHaveCount(1); + expect($results[0]['name'])->toBe('list_routes'); + }); + + it('is case insensitive', function () { + $registry = makeRegistry( + sampleRegistry(), + ['workspace-tools' => sampleServer('workspace-tools')] + ); + + $results = $registry->searchTools('workspace-tools', 'QUERY'); + + expect($results)->toHaveCount(1); + expect($results[0]['name'])->toBe('query_database'); + }); + + it('returns all tools when query is empty', function () { + $registry = makeRegistry( + sampleRegistry(), + ['workspace-tools' => sampleServer('workspace-tools')] + ); + + $results = $registry->searchTools('workspace-tools', ''); + + expect($results)->toHaveCount(3); + }); + + it('returns all tools when query is whitespace', function () { + $registry = makeRegistry( + sampleRegistry(), + ['workspace-tools' => sampleServer('workspace-tools')] + ); + + $results = $registry->searchTools('workspace-tools', ' '); + + expect($results)->toHaveCount(3); + }); + + it('returns empty collection for no matches', function () { + $registry = makeRegistry( + sampleRegistry(), + ['workspace-tools' => sampleServer('workspace-tools')] + ); + + $results = $registry->searchTools('workspace-tools', 'zzz_nonexistent'); + + expect($results)->toBeEmpty(); + }); + }); + + // ----------------------------------------------------------------------- + // Example input management + // ----------------------------------------------------------------------- + + describe('example inputs', function () { + it('returns predefined examples for known tools', function () { + $registry = new ToolRegistry(); + + expect($registry->getExampleInputs('query_database'))->toBe([ + 'query' => 'SELECT id, name FROM users LIMIT 10', + ]); + + expect($registry->getExampleInputs('create_coupon'))->toBe([ + 'code' => 'SUMMER25', + 'discount_type' => 'percentage', + 'discount_value' => 25, + 'expires_at' => '2025-12-31', + ]); + }); + + it('returns empty array for unknown tools', function () { + $registry = new ToolRegistry(); + + expect($registry->getExampleInputs('totally_unknown'))->toBe([]); + }); + + it('allows setting custom examples', function () { + $registry = new ToolRegistry(); + + $registry->setExampleInputs('my_tool', ['foo' => 'bar', 'count' => 42]); + + expect($registry->getExampleInputs('my_tool'))->toBe(['foo' => 'bar', 'count' => 42]); + }); + + it('overwrites existing examples', function () { + $registry = new ToolRegistry(); + + $registry->setExampleInputs('query_database', ['query' => 'SELECT 1']); + + expect($registry->getExampleInputs('query_database'))->toBe(['query' => 'SELECT 1']); + }); + }); + + // ----------------------------------------------------------------------- + // getServerFull() + // ----------------------------------------------------------------------- + + describe('getServerFull', function () { + it('returns full server configuration', function () { + $registry = makeRegistry( + sampleRegistry(), + ['workspace-tools' => sampleServer('workspace-tools')] + ); + + $server = $registry->getServerFull('workspace-tools'); + + expect($server)->not->toBeNull(); + expect($server['id'])->toBe('workspace-tools'); + expect($server['tools'])->toHaveCount(3); + }); + + it('returns null for unknown server', function () { + $registry = makeRegistry(sampleRegistry(), []); + + $server = $registry->getServerFull('nonexistent'); + + expect($server)->toBeNull(); + }); + }); + + // ----------------------------------------------------------------------- + // Category inference + // ----------------------------------------------------------------------- + + describe('category extraction', function () { + it('uses explicit category when provided', function () { + $serverConfig = [ + 'id' => 'cat-server', + 'name' => 'Cat Server', + 'tagline' => '', + 'tools' => [ + [ + 'name' => 'some_tool', + 'description' => 'A tool', + 'category' => 'analytics', + 'inputSchema' => ['type' => 'object', 'properties' => []], + ], + ], + ]; + + $registry = makeRegistry( + ['servers' => [['id' => 'cat-server']]], + ['cat-server' => $serverConfig] + ); + + $tools = $registry->getToolsForServer('cat-server'); + + expect($tools[0]['category'])->toBe('Analytics'); + }); + + it('infers commerce category from tool name', function () { + $serverConfig = [ + 'id' => 'cat-server', + 'name' => 'Cat Server', + 'tagline' => '', + 'tools' => [ + [ + 'name' => 'create_invoice', + 'description' => 'Create an invoice', + 'inputSchema' => ['type' => 'object', 'properties' => []], + ], + ], + ]; + + $registry = makeRegistry( + ['servers' => [['id' => 'cat-server']]], + ['cat-server' => $serverConfig] + ); + + $tools = $registry->getToolsForServer('cat-server'); + + expect($tools[0]['category'])->toBe('Commerce'); + }); + + it('infers query category from tool name', function () { + $serverConfig = [ + 'id' => 'cat-server', + 'name' => 'Cat Server', + 'tagline' => '', + 'tools' => [ + [ + 'name' => 'search_users', + 'description' => 'Search users', + 'inputSchema' => ['type' => 'object', 'properties' => []], + ], + ], + ]; + + $registry = makeRegistry( + ['servers' => [['id' => 'cat-server']]], + ['cat-server' => $serverConfig] + ); + + $tools = $registry->getToolsForServer('cat-server'); + + expect($tools[0]['category'])->toBe('Query'); + }); + + it('falls back to General for unrecognised tool names', function () { + $serverConfig = [ + 'id' => 'cat-server', + 'name' => 'Cat Server', + 'tagline' => '', + 'tools' => [ + [ + 'name' => 'do_something_unique', + 'description' => 'A unique tool', + 'inputSchema' => ['type' => 'object', 'properties' => []], + ], + ], + ]; + + $registry = makeRegistry( + ['servers' => [['id' => 'cat-server']]], + ['cat-server' => $serverConfig] + ); + + $tools = $registry->getToolsForServer('cat-server'); + + expect($tools[0]['category'])->toBe('General'); + }); + }); + + // ----------------------------------------------------------------------- + // Schema-based example generation + // ----------------------------------------------------------------------- + + describe('generateExampleFromSchema', function () { + it('uses enum first value', function () { + $serverConfig = [ + 'id' => 'gen-server', + 'name' => 'Gen', + 'tagline' => '', + 'tools' => [ + [ + 'name' => 'enum_tool', + 'description' => 'Tool with enum', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'status' => ['type' => 'string', 'enum' => ['active', 'inactive']], + ], + ], + ], + ], + ]; + + $registry = makeRegistry( + ['servers' => [['id' => 'gen-server']]], + ['gen-server' => $serverConfig] + ); + + $tools = $registry->getToolsForServer('gen-server'); + + expect($tools[0]['examples']['status'])->toBe('active'); + }); + + it('uses example property from schema', function () { + $serverConfig = [ + 'id' => 'gen-server', + 'name' => 'Gen', + 'tagline' => '', + 'tools' => [ + [ + 'name' => 'example_tool', + 'description' => 'Tool with example values', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'email' => ['type' => 'string', 'example' => 'user@example.com'], + ], + ], + ], + ], + ]; + + $registry = makeRegistry( + ['servers' => [['id' => 'gen-server']]], + ['gen-server' => $serverConfig] + ); + + $tools = $registry->getToolsForServer('gen-server'); + + expect($tools[0]['examples']['email'])->toBe('user@example.com'); + }); + + it('generates type-appropriate defaults for properties without hints', function () { + $serverConfig = [ + 'id' => 'gen-server', + 'name' => 'Gen', + 'tagline' => '', + 'tools' => [ + [ + 'name' => 'typed_tool', + 'description' => 'Tool with various types', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'label' => ['type' => 'string'], + 'count' => ['type' => 'integer'], + 'amount' => ['type' => 'number'], + 'active' => ['type' => 'boolean'], + 'items' => ['type' => 'array'], + 'meta' => ['type' => 'object'], + ], + ], + ], + ], + ]; + + $registry = makeRegistry( + ['servers' => [['id' => 'gen-server']]], + ['gen-server' => $serverConfig] + ); + + $tools = $registry->getToolsForServer('gen-server'); + $examples = $tools[0]['examples']; + + expect($examples['label'])->toBe(''); + expect($examples['count'])->toBe(0); + expect($examples['amount'])->toBe(0); + expect($examples['active'])->toBeFalse(); + expect($examples['items'])->toBe([]); + expect($examples['meta'])->toBeInstanceOf(stdClass::class); + }); + }); + + // ----------------------------------------------------------------------- + // clearCache() + // ----------------------------------------------------------------------- + + describe('clearCache', function () { + it('calls Cache::forget for server keys', function () { + $registry = makeRegistry( + sampleRegistry(), + [ + 'workspace-tools' => sampleServer('workspace-tools'), + 'billing-tools' => sampleServer('billing-tools'), + ] + ); + + // Pre-load servers so clearCache knows which keys to forget + $registry->getServers(); + + // clearCache should call forget on the main key and each server key + Cache::shouldReceive('forget')->with('mcp:playground:servers')->once()->andReturn(true); + Cache::shouldReceive('forget')->with('mcp:playground:tools:workspace-tools')->once()->andReturn(true); + Cache::shouldReceive('forget')->with('mcp:playground:tools:billing-tools')->once()->andReturn(true); + + $registry->clearCache(); + }); + }); +});