agent/php/tests/Feature/Mcp/Services/ToolRegistryTest.php
Snider 09054fbdab feat(mcp): implement §3 Services (ToolRegistry + McpQuotaService + QueryAuditService + ToolDependencyService) (#851)
Additive-only — no existing files modified.

- ToolRegistry: register/resolve/listTools/buildDependencyGraph
  - Singleton via registerSingleton() entry point (no Boot.php wire-in
    per scope; tests cover the binding path)
- McpQuotaService: workspace-scoped checkQuota/consume/reset
- QueryAuditService: log/query/aggregate (expects mcp_audit_entries
  table; tests create inline as migration was out-of-scope)
- ToolDependencyService: validateDependencies via graph traversal

Data DTOs: ToolMetadata, QuotaResult, AuditEntry as readonly.
Pest Feature tests _Good/_Bad/_Ugly per AX-10.
pest skipped (vendor binaries missing).

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=851
2026-04-25 05:14:15 +01:00

93 lines
3 KiB
PHP

<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
use Core\Mod\Agentic\Mcp\Services\ToolRegistry;
function mcpToolRegistryFixture(string $name, array $dependencies = []): object
{
return new class($name, $dependencies)
{
public function __construct(
private readonly string $toolName,
private readonly array $toolDependencies,
) {}
public function name(): string
{
return $this->toolName;
}
public function description(): string
{
return 'Fixture tool';
}
public function inputSchema(): array
{
return ['type' => 'object'];
}
public function dependencies(): array
{
return $this->toolDependencies;
}
public function handle(array $arguments, array $context = []): array
{
return [
'arguments' => $arguments,
'context' => $context,
'tool' => $this->toolName,
];
}
};
}
test('ToolRegistry_register_resolve_listTools_buildDependencyGraph_Good_binds_the_registry_as_a_singleton', function (): void {
$registry = ToolRegistry::registerSingleton($this->app);
$registry->register(mcpToolRegistryFixture('session_start'));
$registry->register(mcpToolRegistryFixture('report_generate', [
['type' => 'tool', 'tool' => 'session_start'],
['type' => 'context_exists', 'key' => 'workspace_id'],
]));
$resolved = $this->app->make(ToolRegistry::class);
$graph = $registry->buildDependencyGraph();
expect($resolved)->toBe($registry)
->and($registry->resolve('report_generate')?->name)->toBe('report_generate')
->and(array_map(
static fn ($tool): string => $tool->name,
$registry->listTools(),
))->toBe(['session_start', 'report_generate'])
->and($graph)->toBe([
'session_start' => [],
'report_generate' => ['session_start', 'workspace_id'],
])
->and($registry->call('report_generate', ['draft' => true], ['workspace_id' => 'ws-1']))
->toBe([
'arguments' => ['draft' => true],
'context' => ['workspace_id' => 'ws-1'],
'tool' => 'report_generate',
]);
});
test('ToolRegistry_register_Bad_rejects_duplicate_tool_names', function (): void {
$registry = new ToolRegistry;
$registry->register(mcpToolRegistryFixture('session_start'));
$registry->register(mcpToolRegistryFixture('session_start'));
})->throws(InvalidArgumentException::class, 'Tool [session_start] is already registered.');
test('ToolRegistry_register_Ugly_rejects_payloads_without_a_callable_handler', function (): void {
$registry = new ToolRegistry;
$registry->register([
'name' => 'broken_tool',
'description' => 'No callable handler',
]);
})->throws(InvalidArgumentException::class, 'A callable handler is required');