php-api/src/Mod/Api/Controllers/McpApiController.php
2026-01-26 20:57:08 +00:00

625 lines
20 KiB
PHP

<?php
declare(strict_types=1);
namespace Core\Mod\Api\Controllers;
use Core\Front\Controller;
use Core\Mod\Api\Models\ApiKey;
use Core\Mod\Mcp\Models\McpApiRequest;
use Core\Mod\Mcp\Models\McpToolCall;
use Core\Mod\Mcp\Models\McpToolVersion;
use Core\Mod\Mcp\Services\McpWebhookDispatcher;
use Core\Mod\Mcp\Services\ToolVersionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\Yaml\Yaml;
/**
* MCP HTTP API Controller.
*
* Provides HTTP bridge to MCP servers for external integrations.
*/
class McpApiController extends Controller
{
/**
* List all available MCP servers.
*
* GET /api/v1/mcp/servers
*/
public function servers(Request $request): JsonResponse
{
$registry = $this->loadRegistry();
$servers = collect($registry['servers'] ?? [])
->map(fn ($ref) => $this->loadServerSummary($ref['id']))
->filter()
->values();
return response()->json([
'servers' => $servers,
'count' => $servers->count(),
]);
}
/**
* Get server details with tools and resources.
*
* GET /api/v1/mcp/servers/{id}
*/
public function server(Request $request, string $id): JsonResponse
{
$server = $this->loadServerFull($id);
if (! $server) {
return response()->json(['error' => 'Server not found'], 404);
}
return response()->json($server);
}
/**
* List tools for a specific server.
*
* GET /api/v1/mcp/servers/{id}/tools
*
* Query params:
* - include_versions: bool - include version info for each tool
*/
public function tools(Request $request, string $id): JsonResponse
{
$server = $this->loadServerFull($id);
if (! $server) {
return response()->json(['error' => 'Server not found'], 404);
}
$tools = $server['tools'] ?? [];
$includeVersions = $request->boolean('include_versions', false);
// Optionally enrich tools with version information
if ($includeVersions) {
$versionService = app(ToolVersionService::class);
$tools = collect($tools)->map(function ($tool) use ($id, $versionService) {
$toolName = $tool['name'] ?? '';
$latestVersion = $versionService->getLatestVersion($id, $toolName);
$tool['versioning'] = [
'latest_version' => $latestVersion?->version ?? ToolVersionService::DEFAULT_VERSION,
'is_versioned' => $latestVersion !== null,
'deprecated' => $latestVersion?->is_deprecated ?? false,
];
// If version exists, use its schema (may differ from YAML)
if ($latestVersion?->input_schema) {
$tool['inputSchema'] = $latestVersion->input_schema;
}
return $tool;
})->all();
}
return response()->json([
'server' => $id,
'tools' => $tools,
'count' => count($tools),
]);
}
/**
* Execute a tool on an MCP server.
*
* POST /api/v1/mcp/tools/call
*
* Request body:
* - server: string (required)
* - tool: string (required)
* - arguments: array (optional)
* - version: string (optional) - semver version to use, defaults to latest
*/
public function callTool(Request $request): JsonResponse
{
$validated = $request->validate([
'server' => 'required|string|max:64',
'tool' => 'required|string|max:128',
'arguments' => 'nullable|array',
'version' => 'nullable|string|max:32',
]);
$server = $this->loadServerFull($validated['server']);
if (! $server) {
return response()->json(['error' => 'Server not found'], 404);
}
// Verify tool exists in server definition
$toolDef = collect($server['tools'] ?? [])->firstWhere('name', $validated['tool']);
if (! $toolDef) {
return response()->json(['error' => 'Tool not found'], 404);
}
// Version resolution
$versionService = app(ToolVersionService::class);
$versionResult = $versionService->resolveVersion(
$validated['server'],
$validated['tool'],
$validated['version'] ?? null
);
// If version was requested but is sunset, block the call
if ($versionResult['error']) {
$error = $versionResult['error'];
// Sunset versions return 410 Gone
$status = ($error['code'] ?? '') === 'TOOL_VERSION_SUNSET' ? 410 : 400;
return response()->json([
'success' => false,
'error' => $error['message'] ?? 'Version error',
'error_code' => $error['code'] ?? 'VERSION_ERROR',
'server' => $validated['server'],
'tool' => $validated['tool'],
'requested_version' => $validated['version'] ?? null,
'latest_version' => $error['latest_version'] ?? null,
'migration_notes' => $error['migration_notes'] ?? null,
], $status);
}
/** @var McpToolVersion|null $toolVersion */
$toolVersion = $versionResult['version'];
$deprecationWarning = $versionResult['warning'];
// Use versioned schema if available for validation
$schemaForValidation = $toolVersion?->input_schema ?? $toolDef['inputSchema'] ?? null;
if ($schemaForValidation) {
$validationErrors = $this->validateToolArguments(
['inputSchema' => $schemaForValidation],
$validated['arguments'] ?? []
);
if (! empty($validationErrors)) {
return response()->json([
'success' => false,
'error' => 'Validation failed',
'error_code' => 'VALIDATION_ERROR',
'validation_errors' => $validationErrors,
'server' => $validated['server'],
'tool' => $validated['tool'],
'version' => $toolVersion?->version ?? 'unversioned',
], 422);
}
}
// Get API key for logging
$apiKey = $request->attributes->get('api_key');
$workspace = $apiKey?->workspace;
$startTime = microtime(true);
try {
// Execute the tool via artisan command
$result = $this->executeToolViaArtisan(
$validated['server'],
$validated['tool'],
$validated['arguments'] ?? []
);
$durationMs = (int) ((microtime(true) - $startTime) * 1000);
// Log the call
$this->logToolCall($apiKey, $validated, $result, $durationMs, true);
// Dispatch webhooks
$this->dispatchWebhook($apiKey, $validated, true, $durationMs);
$response = [
'success' => true,
'server' => $validated['server'],
'tool' => $validated['tool'],
'version' => $toolVersion?->version ?? ToolVersionService::DEFAULT_VERSION,
'result' => $result,
'duration_ms' => $durationMs,
];
// Include deprecation warning if applicable
if ($deprecationWarning) {
$response['_warnings'] = [$deprecationWarning];
}
// Log full request for debugging/replay
$this->logApiRequest($request, $validated, 200, $response, $durationMs, $apiKey);
// Build response with deprecation headers if needed
$jsonResponse = response()->json($response);
if ($deprecationWarning) {
$jsonResponse->header('X-MCP-Deprecation-Warning', $deprecationWarning['message'] ?? 'Version deprecated');
if (isset($deprecationWarning['sunset_at'])) {
$jsonResponse->header('X-MCP-Sunset-At', $deprecationWarning['sunset_at']);
}
if (isset($deprecationWarning['latest_version'])) {
$jsonResponse->header('X-MCP-Latest-Version', $deprecationWarning['latest_version']);
}
}
return $jsonResponse;
} catch (\Throwable $e) {
$durationMs = (int) ((microtime(true) - $startTime) * 1000);
$this->logToolCall($apiKey, $validated, null, $durationMs, false, $e->getMessage());
// Dispatch webhooks (even on failure)
$this->dispatchWebhook($apiKey, $validated, false, $durationMs, $e->getMessage());
$response = [
'success' => false,
'error' => $e->getMessage(),
'server' => $validated['server'],
'tool' => $validated['tool'],
'version' => $toolVersion?->version ?? ToolVersionService::DEFAULT_VERSION,
];
// Log full request for debugging/replay
$this->logApiRequest($request, $validated, 500, $response, $durationMs, $apiKey, $e->getMessage());
return response()->json($response, 500);
}
}
/**
* Validate tool arguments against a JSON schema.
*
* @return array<string> Validation error messages
*/
protected function validateToolArguments(array $toolDef, array $arguments): array
{
$inputSchema = $toolDef['inputSchema'] ?? null;
if (! $inputSchema || ! is_array($inputSchema)) {
return [];
}
$errors = [];
$properties = $inputSchema['properties'] ?? [];
$required = $inputSchema['required'] ?? [];
// Check required properties
foreach ($required as $requiredProp) {
if (! array_key_exists($requiredProp, $arguments)) {
$errors[] = "Missing required argument: {$requiredProp}";
}
}
// Type validation for provided arguments
foreach ($arguments as $key => $value) {
if (! isset($properties[$key])) {
if (($inputSchema['additionalProperties'] ?? true) === false) {
$errors[] = "Unknown argument: {$key}";
}
continue;
}
$propSchema = $properties[$key];
$expectedType = $propSchema['type'] ?? null;
if ($expectedType && ! $this->validateType($value, $expectedType)) {
$errors[] = "Argument '{$key}' must be of type {$expectedType}";
}
// Validate enum values
if (isset($propSchema['enum']) && ! in_array($value, $propSchema['enum'], true)) {
$allowedValues = implode(', ', $propSchema['enum']);
$errors[] = "Argument '{$key}' must be one of: {$allowedValues}";
}
}
return $errors;
}
/**
* Validate a value against a JSON Schema type.
*/
protected function validateType(mixed $value, string $type): bool
{
return match ($type) {
'string' => is_string($value),
'integer' => is_int($value) || (is_numeric($value) && floor((float) $value) == $value),
'number' => is_numeric($value),
'boolean' => is_bool($value),
'array' => is_array($value) && array_is_list($value),
'object' => is_array($value) && ! array_is_list($value),
'null' => is_null($value),
default => true,
};
}
/**
* Get version history for a specific tool.
*
* GET /api/v1/mcp/servers/{server}/tools/{tool}/versions
*/
public function toolVersions(Request $request, string $server, string $tool): JsonResponse
{
$serverConfig = $this->loadServerFull($server);
if (! $serverConfig) {
return response()->json(['error' => 'Server not found'], 404);
}
// Verify tool exists in server definition
$toolDef = collect($serverConfig['tools'] ?? [])->firstWhere('name', $tool);
if (! $toolDef) {
return response()->json(['error' => 'Tool not found'], 404);
}
$versionService = app(ToolVersionService::class);
$versions = $versionService->getVersionHistory($server, $tool);
return response()->json([
'server' => $server,
'tool' => $tool,
'versions' => $versions->map(fn (McpToolVersion $v) => $v->toApiArray())->values(),
'count' => $versions->count(),
]);
}
/**
* Get a specific version of a tool.
*
* GET /api/v1/mcp/servers/{server}/tools/{tool}/versions/{version}
*/
public function toolVersion(Request $request, string $server, string $tool, string $version): JsonResponse
{
$versionService = app(ToolVersionService::class);
$toolVersion = $versionService->getToolAtVersion($server, $tool, $version);
if (! $toolVersion) {
return response()->json(['error' => 'Version not found'], 404);
}
$response = response()->json($toolVersion->toApiArray());
// Add deprecation headers if applicable
if ($deprecationWarning = $toolVersion->getDeprecationWarning()) {
$response->header('X-MCP-Deprecation-Warning', $deprecationWarning['message'] ?? 'Version deprecated');
if (isset($deprecationWarning['sunset_at'])) {
$response->header('X-MCP-Sunset-At', $deprecationWarning['sunset_at']);
}
}
return $response;
}
/**
* Read a resource from an MCP server.
*
* GET /api/v1/mcp/resources/{uri}
*/
public function resource(Request $request, string $uri): JsonResponse
{
// Parse URI format: server://resource/path
if (! preg_match('/^([a-z0-9-]+):\/\/(.+)$/', $uri, $matches)) {
return response()->json(['error' => 'Invalid resource URI format'], 400);
}
$serverId = $matches[1];
$resourcePath = $matches[2];
$server = $this->loadServerFull($serverId);
if (! $server) {
return response()->json(['error' => 'Server not found'], 404);
}
try {
$result = $this->readResourceViaArtisan($serverId, $resourcePath);
return response()->json([
'uri' => $uri,
'content' => $result,
]);
} catch (\Throwable $e) {
return response()->json([
'error' => $e->getMessage(),
'uri' => $uri,
], 500);
}
}
/**
* Execute tool via artisan MCP server command.
*/
protected function executeToolViaArtisan(string $server, string $tool, array $arguments): mixed
{
$commandMap = [
'hosthub-agent' => 'mcp:agent-server',
'socialhost' => 'mcp:socialhost-server',
'biohost' => 'mcp:biohost-server',
'commerce' => 'mcp:commerce-server',
'supporthost' => 'mcp:support-server',
'upstream' => 'mcp:upstream-server',
];
$command = $commandMap[$server] ?? null;
if (! $command) {
throw new \RuntimeException("Unknown server: {$server}");
}
// Build MCP request
$mcpRequest = [
'jsonrpc' => '2.0',
'id' => uniqid(),
'method' => 'tools/call',
'params' => [
'name' => $tool,
'arguments' => $arguments,
],
];
// Execute via process
$process = proc_open(
['php', 'artisan', $command],
[
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
],
$pipes,
base_path()
);
if (! is_resource($process)) {
throw new \RuntimeException('Failed to start MCP server process');
}
fwrite($pipes[0], json_encode($mcpRequest)."\n");
fclose($pipes[0]);
$output = stream_get_contents($pipes[1]);
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
$response = json_decode($output, true);
if (isset($response['error'])) {
throw new \RuntimeException($response['error']['message'] ?? 'Tool execution failed');
}
return $response['result'] ?? null;
}
/**
* Read resource via artisan MCP server command.
*/
protected function readResourceViaArtisan(string $server, string $path): mixed
{
// Similar to executeToolViaArtisan but with resources/read method
// Simplified for now - can expand later
return ['path' => $path, 'content' => 'Resource reading not yet implemented'];
}
/**
* Log full API request for debugging and replay.
*/
protected function logApiRequest(
Request $request,
array $validated,
int $status,
array $response,
int $durationMs,
?ApiKey $apiKey,
?string $error = null
): void {
try {
McpApiRequest::log(
method: $request->method(),
path: '/tools/call',
requestBody: $validated,
responseStatus: $status,
responseBody: $response,
durationMs: $durationMs,
workspaceId: $apiKey?->workspace_id,
apiKeyId: $apiKey?->id,
serverId: $validated['server'],
toolName: $validated['tool'],
errorMessage: $error,
ipAddress: $request->ip(),
headers: $request->headers->all()
);
} catch (\Throwable $e) {
// Don't let logging failures affect API response
report($e);
}
}
/**
* Dispatch webhook for tool execution.
*/
protected function dispatchWebhook(
?ApiKey $apiKey,
array $request,
bool $success,
int $durationMs,
?string $error = null
): void {
if (! $apiKey?->workspace_id) {
return;
}
try {
$dispatcher = new McpWebhookDispatcher;
$dispatcher->dispatchToolExecuted(
workspaceId: $apiKey->workspace_id,
serverId: $request['server'],
toolName: $request['tool'],
arguments: $request['arguments'] ?? [],
success: $success,
durationMs: $durationMs,
errorMessage: $error
);
} catch (\Throwable $e) {
// Don't let webhook failures affect API response
report($e);
}
}
/**
* Log tool call for analytics.
*/
protected function logToolCall(
?ApiKey $apiKey,
array $request,
mixed $result,
int $durationMs,
bool $success,
?string $error = null
): void {
McpToolCall::log(
serverId: $request['server'],
toolName: $request['tool'],
params: $request['arguments'] ?? [],
success: $success,
durationMs: $durationMs,
errorMessage: $error,
workspaceId: $apiKey?->workspace_id
);
}
// Registry loading methods (shared with McpRegistryController)
protected function loadRegistry(): array
{
return Cache::remember('mcp:registry', 600, function () {
$path = resource_path('mcp/registry.yaml');
return file_exists($path) ? Yaml::parseFile($path) : ['servers' => []];
});
}
protected function loadServerFull(string $id): ?array
{
return Cache::remember("mcp:server:{$id}", 600, function () use ($id) {
$path = resource_path("mcp/servers/{$id}.yaml");
return file_exists($path) ? Yaml::parseFile($path) : null;
});
}
protected function loadServerSummary(string $id): ?array
{
$server = $this->loadServerFull($id);
if (! $server) {
return null;
}
return [
'id' => $server['id'],
'name' => $server['name'],
'tagline' => $server['tagline'] ?? '',
'status' => $server['status'] ?? 'available',
'tool_count' => count($server['tools'] ?? []),
'resource_count' => count($server['resources'] ?? []),
];
}
}