Major additions: - Storage abstraction layer with filesystem and in-memory implementations - Gitea API integration with MCP tools for repository management - Console dialog mode with interactive command execution - WireGuard VPN DNS fix implementation and documentation - HTTP client streaming response support - Router generic result type - Parameter type validator for framework core Framework enhancements: - Console command registry improvements - Console dialog components - Method signature analyzer updates - Route mapper refinements - MCP server and tool mapper updates - Queue job chain and dependency commands - Discovery tokenizer improvements Infrastructure: - Deployment architecture documentation - Ansible playbook updates for WireGuard client regeneration - Production environment configuration updates - Docker Compose local configuration updates - Remove obsolete docker-compose.yml (replaced by environment-specific configs) Documentation: - PERMISSIONS.md for access control guidelines - WireGuard DNS fix implementation details - Console dialog mode usage guide - Deployment architecture overview Testing: - Multi-purpose attribute tests - Gitea Actions integration tests (typed and untyped)
351 lines
11 KiB
PHP
351 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Framework\Mcp;
|
|
|
|
use App\Framework\Console\ConsoleCommand;
|
|
use App\Framework\Core\AttributeMapper;
|
|
use App\Framework\Core\ParameterTypeValidator;
|
|
use App\Framework\Attributes\Route;
|
|
use App\Framework\Mcp\Core\ValueObjects\OutputFormat;
|
|
use App\Framework\Reflection\WrappedReflectionClass;
|
|
use App\Framework\Reflection\WrappedReflectionMethod;
|
|
use ReflectionEnum;
|
|
use ReflectionIntersectionType;
|
|
use ReflectionNamedType;
|
|
use ReflectionUnionType;
|
|
|
|
final readonly class McpToolMapper implements AttributeMapper
|
|
{
|
|
private ParameterTypeValidator $typeValidator;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->typeValidator = new ParameterTypeValidator();
|
|
}
|
|
|
|
public function getAttributeClass(): string
|
|
{
|
|
return McpTool::class;
|
|
}
|
|
|
|
public function map(WrappedReflectionClass|WrappedReflectionMethod $reflectionTarget, object $attributeInstance): ?array
|
|
{
|
|
if (! $reflectionTarget instanceof WrappedReflectionMethod || ! $attributeInstance instanceof McpTool) {
|
|
return null;
|
|
}
|
|
|
|
$class = $reflectionTarget->getDeclaringClass();
|
|
|
|
// Check if method has multiple attributes (multi-purpose)
|
|
$hasMultipleAttributes = $this->hasMultiplePurposeAttributes($reflectionTarget);
|
|
|
|
// If multi-purpose, validate that all parameters are builtin types
|
|
if ($hasMultipleAttributes) {
|
|
$parameters = $reflectionTarget->getParameters()->toArray();
|
|
$reflectionParameters = [];
|
|
foreach ($parameters as $param) {
|
|
$reflectionParameters[] = $param->getType();
|
|
}
|
|
|
|
if (! $this->typeValidator->hasOnlyBuiltinParameters($reflectionParameters)) {
|
|
// Skip this attribute if parameters are not all builtin
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Get other attributes for metadata
|
|
$otherAttributes = $this->getOtherPurposeAttributes($reflectionTarget);
|
|
|
|
return [
|
|
'name' => $attributeInstance->name,
|
|
'description' => $attributeInstance->description,
|
|
'inputSchema' => $this->generateInputSchema($reflectionTarget, $attributeInstance),
|
|
'class' => $class->getFullyQualified(),
|
|
'method' => $reflectionTarget->getName(),
|
|
'parameters' => $this->extractParameters($reflectionTarget),
|
|
'multi_purpose' => $hasMultipleAttributes,
|
|
'other_attributes' => $otherAttributes,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Check if method has multiple purpose attributes (McpTool, ConsoleCommand, Route)
|
|
*/
|
|
private function hasMultiplePurposeAttributes(WrappedReflectionMethod $method): bool
|
|
{
|
|
$attributes = $method->getAttributes();
|
|
$purposeAttributeCount = 0;
|
|
|
|
foreach ($attributes as $attribute) {
|
|
$attributeName = $attribute->getName();
|
|
if (in_array($attributeName, [
|
|
McpTool::class,
|
|
ConsoleCommand::class,
|
|
Route::class,
|
|
], true)) {
|
|
$purposeAttributeCount++;
|
|
}
|
|
}
|
|
|
|
return $purposeAttributeCount > 1;
|
|
}
|
|
|
|
/**
|
|
* Get other purpose attributes on the same method
|
|
*
|
|
* @return array<string>
|
|
*/
|
|
private function getOtherPurposeAttributes(WrappedReflectionMethod $method): array
|
|
{
|
|
$attributes = $method->getAttributes();
|
|
$otherAttributes = [];
|
|
|
|
foreach ($attributes as $attribute) {
|
|
$attributeName = $attribute->getName();
|
|
if (in_array($attributeName, [
|
|
ConsoleCommand::class,
|
|
Route::class,
|
|
], true)) {
|
|
$otherAttributes[] = $attributeName;
|
|
}
|
|
}
|
|
|
|
return $otherAttributes;
|
|
}
|
|
|
|
private function generateInputSchema(WrappedReflectionMethod $method, McpTool $tool): array
|
|
{
|
|
$schema = [
|
|
'type' => 'object',
|
|
'properties' => [],
|
|
'required' => [],
|
|
'title' => $tool->name,
|
|
'description' => $tool->description,
|
|
];
|
|
|
|
if (! empty($tool->category)) {
|
|
$schema['category'] = $tool->category;
|
|
}
|
|
|
|
if (! empty($tool->tags)) {
|
|
$schema['tags'] = $tool->tags;
|
|
}
|
|
|
|
foreach ($method->getParameters() as $param) {
|
|
$paramName = $param->getName();
|
|
$paramSchema = $this->generateParameterSchema($param);
|
|
|
|
$schema['properties'][$paramName] = $paramSchema;
|
|
|
|
if (! $param->isOptional()) {
|
|
$schema['required'][] = $paramName;
|
|
}
|
|
}
|
|
|
|
return $schema;
|
|
}
|
|
|
|
private function generateParameterSchema(\ReflectionParameter $param): array
|
|
{
|
|
$type = $param->getType();
|
|
$schema = $this->mapPhpTypeToJsonSchema($type);
|
|
|
|
// Add description based on parameter name
|
|
$schema['description'] = $this->generateParameterDescription($param);
|
|
|
|
// Add default value if parameter is optional
|
|
if ($param->isOptional()) {
|
|
try {
|
|
$defaultValue = $param->getDefaultValue();
|
|
$schema['default'] = $defaultValue;
|
|
} catch (\ReflectionException) {
|
|
// Some defaults (like class constants) can't be retrieved
|
|
$schema['default'] = null;
|
|
}
|
|
}
|
|
|
|
return $schema;
|
|
}
|
|
|
|
private function mapPhpTypeToJsonSchema(?\ReflectionType $type): array
|
|
{
|
|
if ($type === null) {
|
|
return ['type' => 'string', 'description' => 'Mixed type parameter'];
|
|
}
|
|
|
|
if ($type instanceof ReflectionNamedType) {
|
|
return $this->mapNamedTypeToSchema($type);
|
|
}
|
|
|
|
if ($type instanceof ReflectionUnionType) {
|
|
return $this->mapUnionTypeToSchema($type);
|
|
}
|
|
|
|
if ($type instanceof ReflectionIntersectionType) {
|
|
return ['type' => 'object', 'description' => 'Intersection type'];
|
|
}
|
|
|
|
return ['type' => 'string', 'description' => 'Unknown type'];
|
|
}
|
|
|
|
private function mapNamedTypeToSchema(ReflectionNamedType $type): array
|
|
{
|
|
$typeName = $type->getName();
|
|
|
|
return match ($typeName) {
|
|
'string' => ['type' => 'string'],
|
|
'int' => ['type' => 'integer'],
|
|
'float' => ['type' => 'number'],
|
|
'bool' => ['type' => 'boolean'],
|
|
'array' => ['type' => 'array', 'items' => ['type' => 'string']],
|
|
'null' => ['type' => 'null'],
|
|
'mixed' => ['description' => 'Mixed type - can be any value'],
|
|
default => $this->mapComplexTypeToSchema($typeName)
|
|
};
|
|
}
|
|
|
|
private function mapComplexTypeToSchema(string $typeName): array
|
|
{
|
|
// Handle enums
|
|
if (class_exists($typeName) && enum_exists($typeName)) {
|
|
return $this->mapEnumToSchema($typeName);
|
|
}
|
|
|
|
// Handle OutputFormat specifically
|
|
if ($typeName === OutputFormat::class || str_ends_with($typeName, 'OutputFormat')) {
|
|
return [
|
|
'type' => 'string',
|
|
'enum' => ['array', 'json', 'table', 'tree', 'text', 'mermaid', 'plantuml'],
|
|
'default' => 'array',
|
|
'description' => 'Output format for the response',
|
|
];
|
|
}
|
|
|
|
// Handle other classes
|
|
if (class_exists($typeName)) {
|
|
return [
|
|
'type' => 'object',
|
|
'description' => "Instance of {$typeName}",
|
|
];
|
|
}
|
|
|
|
// Fallback for unknown types
|
|
return [
|
|
'type' => 'string',
|
|
'description' => "Parameter of type {$typeName}",
|
|
];
|
|
}
|
|
|
|
private function mapEnumToSchema(string $enumClass): array
|
|
{
|
|
try {
|
|
$reflection = new ReflectionEnum($enumClass);
|
|
$cases = [];
|
|
|
|
foreach ($reflection->getCases() as $case) {
|
|
$cases[] = $case->getValue();
|
|
}
|
|
|
|
return [
|
|
'type' => 'string',
|
|
'enum' => $cases,
|
|
'description' => "Enum values from {$enumClass}",
|
|
];
|
|
} catch (\ReflectionException) {
|
|
return [
|
|
'type' => 'string',
|
|
'description' => "Enum type {$enumClass}",
|
|
];
|
|
}
|
|
}
|
|
|
|
private function mapUnionTypeToSchema(ReflectionUnionType $type): array
|
|
{
|
|
$types = [];
|
|
$hasNull = false;
|
|
|
|
foreach ($type->getTypes() as $unionType) {
|
|
if ($unionType instanceof ReflectionNamedType && $unionType->getName() === 'null') {
|
|
$hasNull = true;
|
|
|
|
continue;
|
|
}
|
|
|
|
$schema = $this->mapPhpTypeToJsonSchema($unionType);
|
|
$types[] = $schema;
|
|
}
|
|
|
|
if (count($types) === 1) {
|
|
$schema = $types[0];
|
|
if ($hasNull) {
|
|
$schema['nullable'] = true;
|
|
}
|
|
|
|
return $schema;
|
|
}
|
|
|
|
return [
|
|
'anyOf' => $types,
|
|
'nullable' => $hasNull,
|
|
'description' => 'Union type parameter',
|
|
];
|
|
}
|
|
|
|
private function generateParameterDescription(\ReflectionParameter $param): string
|
|
{
|
|
$name = $param->getName();
|
|
$type = $param->getType();
|
|
|
|
// Generate human-readable descriptions based on parameter names
|
|
$descriptions = [
|
|
'path' => 'File or directory path',
|
|
'file' => 'File path',
|
|
'directory' => 'Directory path',
|
|
'format' => 'Output format for the response',
|
|
'includeHidden' => 'Whether to include hidden files',
|
|
'includeAnalysis' => 'Whether to include detailed analysis',
|
|
'includeSecurity' => 'Whether to include security assessment',
|
|
'includeMetrics' => 'Whether to include performance metrics',
|
|
'includeHealthCheck' => 'Whether to include health check information',
|
|
'controller' => 'Controller class name to filter by',
|
|
'method' => 'HTTP method to filter by',
|
|
'focus' => 'Area of focus for analysis',
|
|
'task' => 'Task description for the agent',
|
|
'sortBy' => 'Field to sort results by',
|
|
'sortOrder' => 'Sort order (asc or desc)',
|
|
'pattern' => 'Search pattern or regex',
|
|
'limit' => 'Maximum number of results to return',
|
|
'offset' => 'Number of results to skip',
|
|
'recursive' => 'Whether to search recursively',
|
|
'caseSensitive' => 'Whether search should be case sensitive',
|
|
];
|
|
|
|
if (isset($descriptions[$name])) {
|
|
return $descriptions[$name];
|
|
}
|
|
|
|
$typeName = $type instanceof ReflectionNamedType ? $type->getName() : 'mixed';
|
|
|
|
return "Parameter of type {$typeName}";
|
|
}
|
|
|
|
private function extractParameters(WrappedReflectionMethod $method): array
|
|
{
|
|
$parameters = [];
|
|
|
|
foreach ($method->getParameters() as $param) {
|
|
$type = $param->getType();
|
|
$parameters[] = [
|
|
'name' => $param->getName(),
|
|
'type' => $type ? ($type instanceof \ReflectionNamedType ? $type->getName() : 'mixed') : 'mixed',
|
|
'required' => ! $param->isOptional(),
|
|
'default' => $param->isOptional() ? $param->getDefaultValue() : null,
|
|
];
|
|
}
|
|
|
|
return $parameters;
|
|
}
|
|
}
|