php-mcp/tests/Unit/ToolRegistryTest.php
Claude c908fff193
test: add comprehensive tests for ToolRegistry service
Covers getServers(), getToolsForServer(), getTool() lookup,
getToolsByCategory(), searchTools(), example input management,
category extraction, schema-based example generation, and cache
clearing. Uses Mockery partial mocks to isolate YAML loading.

Fixes #4

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:09:22 +00:00

793 lines
28 KiB
PHP

<?php
/*
* Core MCP Package
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
use Core\Mcp\Services\ToolRegistry;
use Illuminate\Support\Facades\Cache;
/*
|--------------------------------------------------------------------------
| Helper: build a ToolRegistry partial mock
|--------------------------------------------------------------------------
|
| The registry loads YAML from disk via protected methods. We mock those
| methods so we can inject deterministic data without touching the
| filesystem. Cache::shouldReceive is used to bypass the cache layer.
|
*/
function makeRegistry(array $registry = [], array $servers = []): ToolRegistry
{
$mock = Mockery::mock(ToolRegistry::class)->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();
});
});
});