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)
This commit is contained in:
2025-11-04 20:39:48 +01:00
parent 700fe8118b
commit 3ed2685e74
80 changed files with 9891 additions and 850 deletions

View File

@@ -4,12 +4,14 @@ declare(strict_types=1);
namespace App\Framework\Mcp\Core\ValueObjects;
use App\Framework\Router\ActionResult;
/**
* Value Object für MCP Tool Ergebnisse
*
* Standardisiert die Rückgabe aller MCP Tools
*/
final readonly class ToolResult
final readonly class ToolResult implements ActionResult
{
public function __construct(
public mixed $data,

View File

@@ -110,13 +110,24 @@ final readonly class McpServer
$result = $instance->$method(...$this->prepareArguments($tool['parameters'], $arguments));
// Handle ActionResult - convert to ToolResult if needed
if ($result instanceof \App\Framework\Router\ActionResult) {
$toolResult = $this->convertActionResultToToolResult($result);
} elseif ($result instanceof \App\Framework\Mcp\Core\ValueObjects\ToolResult) {
$toolResult = $result;
} else {
// Convert plain result to ToolResult
$toolResult = \App\Framework\Mcp\Core\ValueObjects\ToolResult::success($result);
}
// Convert ToolResult to MCP response
$response = [
'jsonrpc' => '2.0',
'result' => [
'content' => [
[
'type' => 'text',
'text' => is_string($result) ? $result : json_encode($result, JSON_PRETTY_PRINT),
'text' => json_encode($toolResult->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE),
],
],
],
@@ -130,6 +141,35 @@ final readonly class McpServer
}
}
/**
* Convert ActionResult to ToolResult
*/
private function convertActionResultToToolResult(\App\Framework\Router\ActionResult $result): \App\Framework\Mcp\Core\ValueObjects\ToolResult
{
// If it's already a ToolResult, return it
if ($result instanceof \App\Framework\Mcp\Core\ValueObjects\ToolResult) {
return $result;
}
// Convert JsonResult to ToolResult
if ($result instanceof \App\Framework\Router\Result\JsonResult) {
return \App\Framework\Mcp\Core\ValueObjects\ToolResult::success($result->data);
}
// Convert ConsoleResult to ToolResult
if ($result instanceof \App\Framework\Console\Result\ConsoleResult) {
$success = $result->exitCode->value === 0;
$message = $result->data['message'] ?? ($success ? 'Command executed successfully' : 'Command failed');
return $success
? \App\Framework\Mcp\Core\ValueObjects\ToolResult::success($result->data, ['message' => $message])
: \App\Framework\Mcp\Core\ValueObjects\ToolResult::failure($message, $result->data);
}
// Default: convert to success ToolResult
return \App\Framework\Mcp\Core\ValueObjects\ToolResult::success(['result' => 'Operation completed']);
}
private function listResources($requestId = null): string
{
$resources = [];

View File

@@ -4,7 +4,10 @@ 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;
@@ -15,6 +18,13 @@ 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;
@@ -28,6 +38,26 @@ final readonly class McpToolMapper implements AttributeMapper
$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,
@@ -35,9 +65,56 @@ final readonly class McpToolMapper implements AttributeMapper
'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 = [
@@ -262,7 +339,7 @@ final readonly class McpToolMapper implements AttributeMapper
$type = $param->getType();
$parameters[] = [
'name' => $param->getName(),
'type' => $type ? $type->getName() : 'mixed',
'type' => $type ? ($type instanceof \ReflectionNamedType ? $type->getName() : 'mixed') : 'mixed',
'required' => ! $param->isOptional(),
'default' => $param->isOptional() ? $param->getDefaultValue() : null,
];

View File

@@ -4,26 +4,29 @@ declare(strict_types=1);
namespace App\Framework\Mcp\Tools;
use App\Framework\HttpClient\HttpClient;
use App\Framework\HttpClient\HttpMethod;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Mcp\McpTool;
use App\Framework\Router\GenericResult;
use App\Infrastructure\Api\Gitea\GiteaClient;
/**
* Gitea Repository Management MCP Tools
*
* Provides AI-accessible Gitea API operations for repository management,
* SSH key setup, and deployment automation.
* issue tracking, CI/CD workflows, and deployment automation.
*
* Architecture: Leverages Infrastructure/Api/Gitea service classes for
* clean separation and better maintainability.
*/
final readonly class GiteaTools
{
public function __construct(
private HttpClient $httpClient,
private string $giteaUrl,
private string $giteaUsername,
private string $giteaPassword
private GiteaClient $giteaClient
) {
}
// ==================== Repository Management ====================
#[McpTool(
name: 'gitea_create_repository',
description: 'Create a new repository in Gitea'
@@ -35,67 +38,51 @@ final readonly class GiteaTools
bool $autoInit = false,
string $defaultBranch = 'main'
): array {
$url = "{$this->giteaUrl}/api/v1/user/repos";
try {
$data = [
'name' => $name,
'description' => $description,
'private' => $private,
'auto_init' => $autoInit,
'default_branch' => $defaultBranch,
];
$data = [
'name' => $name,
'description' => $description,
'private' => $private,
'auto_init' => $autoInit,
'default_branch' => $defaultBranch,
];
$repository = $this->giteaClient->repositories->create($data);
$result = $this->makeRequest(HttpMethod::POST, $url, $data);
if ($result['success']) {
return [
'success' => true,
'repository' => [
'name' => $result['response']['name'] ?? $name,
'full_name' => $result['response']['full_name'] ?? "{$this->giteaUsername}/$name",
'clone_url' => $result['response']['clone_url'] ?? null,
'ssh_url' => $result['response']['ssh_url'] ?? null,
'html_url' => $result['response']['html_url'] ?? null,
'private' => $result['response']['private'] ?? $private,
'id' => $result['response']['id'] ?? null,
],
'repository' => $this->formatRepository($repository),
];
} catch (\Exception $e) {
return $this->formatError($e, 'Failed to create repository');
}
return $result;
}
#[McpTool(
name: 'gitea_list_repositories',
description: 'List all repositories for the authenticated user'
)]
public function listRepositories(): array
public function listRepositories(int $page = 1, int $limit = 30): array
{
$url = "{$this->giteaUrl}/api/v1/user/repos";
$result = $this->makeRequest(HttpMethod::GET, $url);
if ($result['success']) {
$repos = array_map(function ($repo) {
return [
'name' => $repo['name'] ?? 'unknown',
'full_name' => $repo['full_name'] ?? 'unknown',
'description' => $repo['description'] ?? '',
'private' => $repo['private'] ?? false,
'clone_url' => $repo['clone_url'] ?? null,
'ssh_url' => $repo['ssh_url'] ?? null,
'html_url' => $repo['html_url'] ?? null,
];
}, $result['response'] ?? []);
try {
$repositories = $this->giteaClient->repositories->list([
'page' => $page,
'limit' => $limit,
]);
return [
'success' => true,
'repositories' => $repos,
'count' => count($repos),
'repositories' => array_map(
fn($repo) => $this->formatRepository($repo),
$repositories
),
'count' => count($repositories),
'page' => $page,
'limit' => $limit,
];
} catch (\Exception $e) {
return $this->formatError($e, 'Failed to list repositories');
}
return $result;
}
#[McpTool(
@@ -104,198 +91,374 @@ final readonly class GiteaTools
)]
public function getRepository(string $owner, string $repo): array
{
$url = "{$this->giteaUrl}/api/v1/repos/$owner/$repo";
$result = $this->makeRequest(HttpMethod::GET, $url);
if ($result['success']) {
$repo = $result['response'];
try {
$repository = $this->giteaClient->repositories->get($owner, $repo);
return [
'success' => true,
'repository' => [
'name' => $repo['name'] ?? 'unknown',
'full_name' => $repo['full_name'] ?? 'unknown',
'description' => $repo['description'] ?? '',
'private' => $repo['private'] ?? false,
'clone_url' => $repo['clone_url'] ?? null,
'ssh_url' => $repo['ssh_url'] ?? null,
'html_url' => $repo['html_url'] ?? null,
'default_branch' => $repo['default_branch'] ?? 'main',
'created_at' => $repo['created_at'] ?? null,
'updated_at' => $repo['updated_at'] ?? null,
],
'repository' => $this->formatRepository($repository),
];
} catch (\Exception $e) {
return $this->formatError($e, "Failed to get repository {$owner}/{$repo}");
}
}
return $result;
#[McpTool(
name: 'gitea_update_repository',
description: 'Update repository settings'
)]
public function updateRepository(
string $owner,
string $repo,
?string $description = null,
?bool $private = null,
?string $website = null,
?bool $hasIssues = null,
?bool $hasWiki = null,
?string $defaultBranch = null
): array {
try {
$data = array_filter([
'description' => $description,
'private' => $private,
'website' => $website,
'has_issues' => $hasIssues,
'has_wiki' => $hasWiki,
'default_branch' => $defaultBranch,
], fn($value) => $value !== null);
$repository = $this->giteaClient->repositories->update($owner, $repo, $data);
return [
'success' => true,
'repository' => $this->formatRepository($repository),
];
} catch (\Exception $e) {
return $this->formatError($e, "Failed to update repository {$owner}/{$repo}");
}
}
#[McpTool(
name: 'gitea_delete_repository',
description: 'Delete a repository'
description: 'Delete a repository (DANGEROUS - permanent deletion)'
)]
public function deleteRepository(string $owner, string $repo): array
{
$url = "{$this->giteaUrl}/api/v1/repos/$owner/$repo";
try {
$this->giteaClient->repositories->delete($owner, $repo);
return $this->makeRequest(HttpMethod::DELETE, $url);
return [
'success' => true,
'message' => "Repository {$owner}/{$repo} deleted successfully",
];
} catch (\Exception $e) {
return $this->formatError($e, "Failed to delete repository {$owner}/{$repo}");
}
}
// ==================== Issue Management ====================
#[McpTool(
name: 'gitea_add_deploy_key',
description: 'Add an SSH deploy key to a repository'
name: 'gitea_create_issue',
description: 'Create a new issue in a repository'
)]
public function addDeployKey(
public function createIssue(
string $owner,
string $repo,
string $title,
string $key,
bool $readOnly = true
string $body = '',
?array $labels = null,
?array $assignees = null,
?int $milestone = null
): array {
$url = "{$this->giteaUrl}/api/v1/repos/$owner/$repo/keys";
try {
$data = array_filter([
'title' => $title,
'body' => $body,
'labels' => $labels,
'assignees' => $assignees,
'milestone' => $milestone,
], fn($value) => $value !== null);
$data = [
'title' => $title,
'key' => $key,
'read_only' => $readOnly,
];
$result = $this->makeRequest(HttpMethod::POST, $url, $data);
if ($result['success']) {
return [
'success' => true,
'deploy_key' => [
'id' => $result['response']['id'] ?? null,
'title' => $result['response']['title'] ?? $title,
'key' => $result['response']['key'] ?? $key,
'read_only' => $result['response']['read_only'] ?? $readOnly,
'created_at' => $result['response']['created_at'] ?? null,
],
];
}
return $result;
}
#[McpTool(
name: 'gitea_list_deploy_keys',
description: 'List all deploy keys for a repository'
)]
public function listDeployKeys(string $owner, string $repo): array
{
$url = "{$this->giteaUrl}/api/v1/repos/$owner/$repo/keys";
$result = $this->makeRequest(HttpMethod::GET, $url);
if ($result['success']) {
$keys = array_map(function ($key) {
return [
'id' => $key['id'] ?? null,
'title' => $key['title'] ?? 'unknown',
'key' => $key['key'] ?? '',
'read_only' => $key['read_only'] ?? true,
'created_at' => $key['created_at'] ?? null,
];
}, $result['response'] ?? []);
$issue = $this->giteaClient->issues->create($owner, $repo, $data);
return [
'success' => true,
'deploy_keys' => $keys,
'count' => count($keys),
'issue' => $this->formatIssue($issue),
];
} catch (\Exception $e) {
return $this->formatError($e, "Failed to create issue in {$owner}/{$repo}");
}
return $result;
}
#[McpTool(
name: 'gitea_delete_deploy_key',
description: 'Delete a deploy key from a repository'
name: 'gitea_list_issues',
description: 'List issues in a repository'
)]
public function deleteDeployKey(string $owner, string $repo, int $keyId): array
{
$url = "{$this->giteaUrl}/api/v1/repos/$owner/$repo/keys/$keyId";
public function listIssues(
string $owner,
string $repo,
string $state = 'open',
?array $labels = null,
int $page = 1,
int $limit = 30
): array {
try {
$options = array_filter([
'state' => $state,
'labels' => $labels ? implode(',', $labels) : null,
'page' => $page,
'limit' => $limit,
], fn($value) => $value !== null);
return $this->makeRequest(HttpMethod::DELETE, $url);
}
#[McpTool(
name: 'gitea_add_user_ssh_key',
description: 'Add an SSH key to the authenticated user'
)]
public function addUserSshKey(string $title, string $key, bool $readOnly = false): array
{
$url = "{$this->giteaUrl}/api/v1/user/keys";
$data = [
'title' => $title,
'key' => $key,
'read_only' => $readOnly,
];
$result = $this->makeRequest(HttpMethod::POST, $url, $data);
if ($result['success']) {
return [
'success' => true,
'ssh_key' => [
'id' => $result['response']['id'] ?? null,
'title' => $result['response']['title'] ?? $title,
'key' => $result['response']['key'] ?? $key,
'read_only' => $result['response']['read_only'] ?? $readOnly,
'created_at' => $result['response']['created_at'] ?? null,
],
];
}
return $result;
}
#[McpTool(
name: 'gitea_list_user_ssh_keys',
description: 'List all SSH keys for the authenticated user'
)]
public function listUserSshKeys(): array
{
$url = "{$this->giteaUrl}/api/v1/user/keys";
$result = $this->makeRequest(HttpMethod::GET, $url);
if ($result['success']) {
$keys = array_map(function ($key) {
return [
'id' => $key['id'] ?? null,
'title' => $key['title'] ?? 'unknown',
'key' => $key['key'] ?? '',
'fingerprint' => $key['fingerprint'] ?? '',
'read_only' => $key['read_only'] ?? false,
'created_at' => $key['created_at'] ?? null,
];
}, $result['response'] ?? []);
$issues = $this->giteaClient->issues->list($owner, $repo, $options);
return [
'success' => true,
'ssh_keys' => $keys,
'count' => count($keys),
'issues' => array_map(
fn($issue) => $this->formatIssue($issue),
$issues
),
'count' => count($issues),
'state' => $state,
'page' => $page,
'limit' => $limit,
];
} catch (\Exception $e) {
return $this->formatError($e, "Failed to list issues in {$owner}/{$repo}");
}
return $result;
}
#[McpTool(
name: 'gitea_delete_user_ssh_key',
description: 'Delete an SSH key from the authenticated user'
name: 'gitea_get_issue',
description: 'Get details of a specific issue'
)]
public function deleteUserSshKey(int $keyId): array
public function getIssue(string $owner, string $repo, int $index): array
{
$url = "{$this->giteaUrl}/api/v1/user/keys/$keyId";
try {
$issue = $this->giteaClient->issues->get($owner, $repo, $index);
return $this->makeRequest(HttpMethod::DELETE, $url);
return [
'success' => true,
'issue' => $this->formatIssue($issue),
];
} catch (\Exception $e) {
return $this->formatError($e, "Failed to get issue #{$index} in {$owner}/{$repo}");
}
}
#[McpTool(
name: 'gitea_update_issue',
description: 'Update an existing issue'
)]
public function updateIssue(
string $owner,
string $repo,
int $index,
?string $title = null,
?string $body = null,
?string $state = null,
?array $labels = null,
?array $assignees = null
): array {
try {
$data = array_filter([
'title' => $title,
'body' => $body,
'state' => $state,
'labels' => $labels,
'assignees' => $assignees,
], fn($value) => $value !== null);
$issue = $this->giteaClient->issues->update($owner, $repo, $index, $data);
return [
'success' => true,
'issue' => $this->formatIssue($issue),
];
} catch (\Exception $e) {
return $this->formatError($e, "Failed to update issue #{$index} in {$owner}/{$repo}");
}
}
#[McpTool(
name: 'gitea_close_issue',
description: 'Close an issue'
)]
public function closeIssue(string $owner, string $repo, int $index): array
{
try {
$issue = $this->giteaClient->issues->close($owner, $repo, $index);
return [
'success' => true,
'issue' => $this->formatIssue($issue),
'message' => "Issue #{$index} closed successfully",
];
} catch (\Exception $e) {
return $this->formatError($e, "Failed to close issue #{$index} in {$owner}/{$repo}");
}
}
// ==================== CI/CD & Actions ====================
#[McpTool(
name: 'gitea_list_workflows',
description: 'List all workflows in a repository'
)]
#[ConsoleCommand(
name: 'gitea:workflows:list',
description: 'List all workflows in a Gitea repository'
)]
public function listWorkflows(string $owner, string $repo): GenericResult
{
try {
$workflows = $this->giteaClient->actions->listWorkflows($owner, $repo);
return GenericResult::success([
'workflows' => $workflows['workflows'] ?? [],
'count' => count($workflows['workflows'] ?? []),
]);
} catch (\Exception $e) {
return GenericResult::fromException($e);
}
}
#[McpTool(
name: 'gitea_trigger_workflow',
description: 'Manually trigger a workflow'
)]
public function triggerWorkflow(
string $owner,
string $repo,
string $workflowId,
?string $ref = null,
?array $inputs = null
): array {
try {
$this->giteaClient->actions->triggerWorkflow(
$owner,
$repo,
$workflowId,
$inputs ?? [],
$ref
);
return [
'success' => true,
'message' => "Workflow {$workflowId} triggered successfully",
'workflow_id' => $workflowId,
'ref' => $ref,
];
} catch (\Exception $e) {
return $this->formatError($e, "Failed to trigger workflow {$workflowId}");
}
}
#[McpTool(
name: 'gitea_list_workflow_runs',
description: 'List workflow runs for a repository'
)]
#[ConsoleCommand(
name: 'gitea:workflows:runs',
description: 'List workflow runs for a Gitea repository'
)]
public function listWorkflowRuns(
string $owner,
string $repo,
?string $status = null,
?int $workflowId = null,
int $page = 1,
int $limit = 30
): GenericResult {
try {
$options = array_filter([
'status' => $status,
'workflow_id' => $workflowId,
'page' => $page,
'limit' => $limit,
], fn($value) => $value !== null);
$runs = $this->giteaClient->actions->listRuns($owner, $repo, $options);
return GenericResult::success([
'runs' => array_map(
fn($run) => $this->formatWorkflowRun($run),
$runs['workflow_runs'] ?? []
),
'count' => count($runs['workflow_runs'] ?? []),
'page' => $page,
'limit' => $limit,
]);
} catch (\Exception $e) {
return GenericResult::fromException($e);
}
}
#[McpTool(
name: 'gitea_get_workflow_run',
description: 'Get details of a specific workflow run'
)]
#[ConsoleCommand(
name: 'gitea:workflows:run',
description: 'Get details of a specific Gitea workflow run'
)]
public function getWorkflowRun(string $owner, string $repo, int $runId): GenericResult
{
try {
$run = $this->giteaClient->actions->getRun($owner, $repo, $runId);
return GenericResult::success([
'run' => $this->formatWorkflowRun($run),
]);
} catch (\Exception $e) {
return GenericResult::fromException($e, [
'context' => "Failed to get workflow run #{$runId}",
]);
}
}
#[McpTool(
name: 'gitea_cancel_workflow_run',
description: 'Cancel a running workflow'
)]
public function cancelWorkflowRun(string $owner, string $repo, int $runId): array
{
try {
$this->giteaClient->actions->cancelRun($owner, $repo, $runId);
return [
'success' => true,
'message' => "Workflow run #{$runId} cancelled successfully",
'run_id' => $runId,
];
} catch (\Exception $e) {
return $this->formatError($e, "Failed to cancel workflow run #{$runId}");
}
}
#[McpTool(
name: 'gitea_get_workflow_logs',
description: 'Get logs of a workflow run'
)]
public function getWorkflowLogs(string $owner, string $repo, int $runId): array
{
try {
$logs = $this->giteaClient->actions->getLogs($owner, $repo, $runId);
return [
'success' => true,
'logs' => $logs,
'run_id' => $runId,
'size' => strlen($logs),
];
} catch (\Exception $e) {
return $this->formatError($e, "Failed to get workflow logs for run #{$runId}");
}
}
// ==================== Git Remote Integration ====================
#[McpTool(
name: 'gitea_add_remote',
description: 'Add Gitea repository as git remote'
@@ -306,150 +469,130 @@ final readonly class GiteaTools
string $repo,
bool $useSsh = true
): array {
// Get repository info first
$repoInfo = $this->getRepository($owner, $repo);
try {
// Get repository info first
$repository = $this->giteaClient->repositories->get($owner, $repo);
if (! $repoInfo['success']) {
return $repoInfo;
}
$url = $useSsh
? $repository['ssh_url']
: $repository['clone_url'];
$url = $useSsh
? $repoInfo['repository']['ssh_url']
: $repoInfo['repository']['clone_url'];
if (! $url) {
return [
'success' => false,
'error' => 'Repository URL not found',
];
}
// Add remote via git command
$output = [];
$exitCode = 0;
$command = sprintf(
'git remote add %s %s 2>&1',
escapeshellarg($remoteName),
escapeshellarg($url)
);
exec($command, $output, $exitCode);
if ($exitCode !== 0) {
// Check if remote already exists
if (str_contains(implode("\n", $output), 'already exists')) {
if (!$url) {
return [
'success' => false,
'error' => 'Remote already exists',
'suggestion' => "Use 'git remote set-url $remoteName $url' to update",
'error' => 'Repository URL not found',
];
}
// Add remote via git command
$output = [];
$exitCode = 0;
$command = sprintf(
'git remote add %s %s 2>&1',
escapeshellarg($remoteName),
escapeshellarg($url)
);
exec($command, $output, $exitCode);
if ($exitCode !== 0) {
// Check if remote already exists
if (str_contains(implode("\n", $output), 'already exists')) {
return [
'success' => false,
'error' => 'Remote already exists',
'suggestion' => "Use 'git remote set-url {$remoteName} {$url}' to update",
];
}
return [
'success' => false,
'error' => 'Failed to add remote',
'output' => implode("\n", $output),
'exit_code' => $exitCode,
];
}
return [
'success' => false,
'error' => 'Failed to add remote',
'output' => implode("\n", $output),
'exit_code' => $exitCode,
];
}
return [
'success' => true,
'remote_name' => $remoteName,
'url' => $url,
'use_ssh' => $useSsh,
];
}
#[McpTool(
name: 'gitea_webhook_create',
description: 'Create a webhook for a repository'
)]
public function createWebhook(
string $owner,
string $repo,
string $url,
string $contentType = 'json',
array $events = ['push'],
bool $active = true,
?string $secret = null
): array {
$hookUrl = "{$this->giteaUrl}/api/v1/repos/$owner/$repo/hooks";
$data = [
'type' => 'gitea',
'config' => [
'url' => $url,
'content_type' => $contentType,
'secret' => $secret ?? '',
],
'events' => $events,
'active' => $active,
];
$result = $this->makeRequest(HttpMethod::POST, $hookUrl, $data);
if ($result['success']) {
return [
'success' => true,
'webhook' => [
'id' => $result['response']['id'] ?? null,
'url' => $result['response']['config']['url'] ?? $url,
'events' => $result['response']['events'] ?? $events,
'active' => $result['response']['active'] ?? $active,
'created_at' => $result['response']['created_at'] ?? null,
],
'remote_name' => $remoteName,
'url' => $url,
'use_ssh' => $useSsh,
];
} catch (\Exception $e) {
return $this->formatError($e, "Failed to add remote {$remoteName}");
}
return $result;
}
// ==================== Private Helper Methods ====================
private function makeRequest(HttpMethod $method, string $url, ?array $data = null): array
private function formatRepository(array $repo): array
{
try {
$options = [
'headers' => [
'Content-Type' => 'application/json',
'Accept' => 'application/json',
'Authorization' => 'Basic ' . base64_encode("{$this->giteaUsername}:{$this->giteaPassword}"),
],
'verify_ssl' => false, // For self-signed certificates
];
return [
'id' => $repo['id'] ?? null,
'name' => $repo['name'] ?? 'unknown',
'full_name' => $repo['full_name'] ?? 'unknown',
'description' => $repo['description'] ?? '',
'private' => $repo['private'] ?? false,
'clone_url' => $repo['clone_url'] ?? null,
'ssh_url' => $repo['ssh_url'] ?? null,
'html_url' => $repo['html_url'] ?? null,
'default_branch' => $repo['default_branch'] ?? 'main',
'created_at' => $repo['created_at'] ?? null,
'updated_at' => $repo['updated_at'] ?? null,
'stars_count' => $repo['stars_count'] ?? 0,
'forks_count' => $repo['forks_count'] ?? 0,
'open_issues_count' => $repo['open_issues_count'] ?? 0,
];
}
if ($data !== null) {
$options['json'] = $data;
}
private function formatIssue(array $issue): array
{
return [
'id' => $issue['id'] ?? null,
'number' => $issue['number'] ?? null,
'title' => $issue['title'] ?? 'Untitled',
'body' => $issue['body'] ?? '',
'state' => $issue['state'] ?? 'open',
'labels' => array_map(
fn($label) => $label['name'] ?? 'unknown',
$issue['labels'] ?? []
),
'assignees' => array_map(
fn($assignee) => $assignee['username'] ?? 'unknown',
$issue['assignees'] ?? []
),
'html_url' => $issue['html_url'] ?? null,
'created_at' => $issue['created_at'] ?? null,
'updated_at' => $issue['updated_at'] ?? null,
'closed_at' => $issue['closed_at'] ?? null,
];
}
$response = $this->httpClient->request($method, $url, $options);
private function formatWorkflowRun(array $run): array
{
return [
'id' => $run['id'] ?? null,
'name' => $run['name'] ?? 'Unnamed Workflow',
'status' => $run['status'] ?? 'unknown',
'conclusion' => $run['conclusion'] ?? null,
'workflow_id' => $run['workflow_id'] ?? null,
'event' => $run['event'] ?? null,
'head_branch' => $run['head_branch'] ?? null,
'head_sha' => $run['head_sha'] ?? null,
'html_url' => $run['html_url'] ?? null,
'created_at' => $run['created_at'] ?? null,
'updated_at' => $run['updated_at'] ?? null,
'run_started_at' => $run['run_started_at'] ?? null,
];
}
$statusCode = $response->getStatusCode();
$body = $response->getBody();
// Decode JSON response
$decoded = json_decode($body, true);
if ($statusCode >= 200 && $statusCode < 300) {
return [
'success' => true,
'response' => $decoded,
'http_code' => $statusCode,
];
}
return [
'success' => false,
'error' => $decoded['message'] ?? 'HTTP error ' . $statusCode,
'response' => $decoded,
'http_code' => $statusCode,
];
} catch (\Exception $e) {
return [
'success' => false,
'error' => 'Request failed: ' . $e->getMessage(),
'exception' => get_class($e),
];
}
private function formatError(\Exception $e, string $context): array
{
return [
'success' => false,
'error' => $context,
'message' => $e->getMessage(),
'exception' => get_class($e),
];
}
}

View File

@@ -4,34 +4,25 @@ declare(strict_types=1);
namespace App\Framework\Mcp\Tools;
use App\Framework\Config\Environment;
use App\Framework\DI\Initializer;
use App\Framework\HttpClient\HttpClient;
use App\Infrastructure\Api\Gitea\GiteaClient;
/**
* Initializer for Gitea MCP Tools
*
* Registers GiteaTools with the DI container using the GiteaClient service.
* The GiteaClient is initialized separately with proper configuration via GiteaClientInitializer.
*/
final readonly class GiteaToolsInitializer
{
public function __construct(
private HttpClient $httpClient,
private Environment $environment
private GiteaClient $giteaClient
) {
}
#[Initializer]
public function __invoke(): GiteaTools
{
// Get Gitea configuration from environment
$giteaUrl = $this->environment->get('GITEA_URL', 'https://localhost:9443');
$giteaUsername = $this->environment->get('GITEA_USERNAME', 'michael');
$giteaPassword = $this->environment->get('GITEA_PASSWORD', 'GiteaAdmin2024');
return new GiteaTools(
$this->httpClient,
$giteaUrl,
$giteaUsername,
$giteaPassword
);
return new GiteaTools($this->giteaClient);
}
}