Files
michaelschiemer/src/Framework/Mcp/McpToolMapper.php
Michael Schiemer 3ed2685e74 feat: add comprehensive framework features and deployment improvements
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)
2025-11-04 20:39:48 +01:00

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