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:
@@ -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,
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user