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(); }); }); });