refactor: move Api and Mcp frontages to their packages
Some checks failed
CI / PHP 8.4 (push) Failing after 2m9s
CI / PHP 8.3 (push) Failing after 2m25s

Move Core\Front\Api\* to php-api and Core\Front\Mcp\* to php-mcp.
Inline the api/mcp middleware group definitions in Front\Boot since
middleware() runs before package providers load. Simplify
McpToolsRegistering to just collect class names without validating
the McpToolHandler interface (avoids framework→package dependency).

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-04 13:05:22 +00:00
parent 7c20a5f905
commit 2c598f022d
14 changed files with 23 additions and 1654 deletions

View file

@ -11,8 +11,6 @@ declare(strict_types=1);
namespace Core\Events;
use Core\Front\Mcp\Contracts\McpToolHandler;
/**
* Fired when MCP (Model Context Protocol) tools are being registered.
*
@ -27,8 +25,9 @@ use Core\Front\Mcp\Contracts\McpToolHandler;
*
* ## Handler Requirements
*
* Each handler class must implement `McpToolHandler` interface. Handlers
* define the tools, their input schemas, and execution logic.
* Each handler class must implement `McpToolHandler` interface (from php-mcp).
* Handlers define the tools, their input schemas, and execution logic.
* Validation is performed by the MCP package at runtime.
*
* ## Usage Example
*
@ -43,9 +42,6 @@ use Core\Front\Mcp\Contracts\McpToolHandler;
* $event->handler(InventoryQueryHandler::class);
* }
* ```
*
*
* @see \Core\Front\Mcp\Contracts\McpToolHandler
*/
class McpToolsRegistering extends LifecycleEvent
{
@ -56,14 +52,9 @@ class McpToolsRegistering extends LifecycleEvent
* Register an MCP tool handler class.
*
* @param string $handlerClass Fully qualified class name implementing McpToolHandler
*
* @throws \InvalidArgumentException If class doesn't implement McpToolHandler
*/
public function handler(string $handlerClass): void
{
if (! is_a($handlerClass, McpToolHandler::class, true)) {
throw new \InvalidArgumentException("{$handlerClass} must implement McpToolHandler");
}
$this->handlers[] = $handlerClass;
}

View file

@ -1,253 +0,0 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Front\Api;
use Illuminate\Http\Request;
/**
* API Version Service.
*
* Provides helper methods for working with API versions in controllers
* and other application code.
*
* ## Usage in Controllers
*
* ```php
* use Core\Front\Api\ApiVersionService;
*
* class UserController
* {
* public function __construct(
* protected ApiVersionService $versions
* ) {}
*
* public function index(Request $request)
* {
* if ($this->versions->isV2($request)) {
* return $this->indexV2($request);
* }
* return $this->indexV1($request);
* }
* }
* ```
*
* ## Version Negotiation
*
* The service supports version negotiation where controllers can provide
* different responses based on the requested version:
*
* ```php
* return $this->versions->negotiate($request, [
* 1 => fn() => $this->responseV1(),
* 2 => fn() => $this->responseV2(),
* ]);
* ```
*/
class ApiVersionService
{
/**
* Get the current API version from the request.
*
* Returns null if no version middleware has processed the request.
*/
public function current(?Request $request = null): ?int
{
$request ??= request();
return $request->attributes->get('api_version');
}
/**
* Get the current API version as a string (e.g., 'v1').
*/
public function currentString(?Request $request = null): ?string
{
$request ??= request();
return $request->attributes->get('api_version_string');
}
/**
* Check if the request is for a specific version.
*/
public function is(int $version, ?Request $request = null): bool
{
return $this->current($request) === $version;
}
/**
* Check if the request is for version 1.
*/
public function isV1(?Request $request = null): bool
{
return $this->is(1, $request);
}
/**
* Check if the request is for version 2.
*/
public function isV2(?Request $request = null): bool
{
return $this->is(2, $request);
}
/**
* Check if the request version is at least the given version.
*/
public function isAtLeast(int $version, ?Request $request = null): bool
{
$current = $this->current($request);
return $current !== null && $current >= $version;
}
/**
* Check if the current version is deprecated.
*/
public function isDeprecated(?Request $request = null): bool
{
$current = $this->current($request);
$deprecated = config('api.versioning.deprecated', []);
return $current !== null && in_array($current, $deprecated, true);
}
/**
* Get the configured default version.
*/
public function defaultVersion(): int
{
return (int) config('api.versioning.default', 1);
}
/**
* Get the current/latest version.
*/
public function latestVersion(): int
{
return (int) config('api.versioning.current', 1);
}
/**
* Get all supported versions.
*
* @return array<int>
*/
public function supportedVersions(): array
{
return config('api.versioning.supported', [1]);
}
/**
* Get all deprecated versions.
*
* @return array<int>
*/
public function deprecatedVersions(): array
{
return config('api.versioning.deprecated', []);
}
/**
* Get sunset dates for versions.
*
* @return array<int, string>
*/
public function sunsetDates(): array
{
return config('api.versioning.sunset', []);
}
/**
* Check if a version is supported.
*/
public function isSupported(int $version): bool
{
return in_array($version, $this->supportedVersions(), true);
}
/**
* Negotiate response based on API version.
*
* Calls the appropriate handler based on the request's API version.
* Falls back to lower version handlers if exact match not found.
*
* ```php
* return $versions->negotiate($request, [
* 1 => fn() => ['format' => 'v1'],
* 2 => fn() => ['format' => 'v2', 'extra' => 'field'],
* ]);
* ```
*
* @param array<int, callable> $handlers Version handlers keyed by version number
* @return mixed Result from the appropriate handler
*
* @throws \InvalidArgumentException If no suitable handler found
*/
public function negotiate(Request $request, array $handlers): mixed
{
$version = $this->current($request) ?? $this->defaultVersion();
// Try exact match first
if (isset($handlers[$version])) {
return $handlers[$version]();
}
// Fall back to highest version that's <= requested version
krsort($handlers);
foreach ($handlers as $handlerVersion => $handler) {
if ($handlerVersion <= $version) {
return $handler();
}
}
// No suitable handler found
throw new \InvalidArgumentException(
"No handler found for API version {$version}. Available versions: ".implode(', ', array_keys($handlers))
);
}
/**
* Transform response data based on API version.
*
* Useful for removing or adding fields based on version.
*
* ```php
* return $versions->transform($request, $data, [
* 1 => fn($data) => Arr::except($data, ['new_field']),
* 2 => fn($data) => $data,
* ]);
* ```
*
* @param array<int, callable> $transformers Version transformers
*/
public function transform(Request $request, mixed $data, array $transformers): mixed
{
$version = $this->current($request) ?? $this->defaultVersion();
// Try exact match first
if (isset($transformers[$version])) {
return $transformers[$version]($data);
}
// Fall back to highest version that's <= requested version
krsort($transformers);
foreach ($transformers as $transformerVersion => $transformer) {
if ($transformerVersion <= $version) {
return $transformer($data);
}
}
// No transformer, return data unchanged
return $data;
}
}

View file

@ -1,111 +0,0 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Front\Api;
use Core\Front\Api\Middleware\ApiSunset;
use Core\Front\Api\Middleware\ApiVersion;
use Core\LifecycleEventProvider;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
use Illuminate\Routing\Router;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
/**
* API frontage - API stage.
*
* Provides api middleware group for API routes and API versioning support.
*
* ## API Versioning
*
* This provider registers middleware for API versioning:
* - `api.version` - Parses and validates API version from URL or headers
* - `api.sunset` - Adds deprecation/sunset headers to endpoints
*
* Configure versioning in config/api.php:
* ```php
* 'versioning' => [
* 'default' => 1, // Default version when none specified
* 'current' => 1, // Current/latest version
* 'supported' => [1], // List of supported versions
* 'deprecated' => [], // Deprecated but still supported versions
* 'sunset' => [], // Sunset dates: [1 => '2025-06-01']
* ],
* ```
*
* @see ApiVersion Middleware for version parsing
* @see ApiVersionService Service for programmatic version checks
* @see VersionedRoutes Helper for version-based route registration
*/
class Boot extends ServiceProvider
{
/**
* Configure api middleware group.
*/
public static function middleware(Middleware $middleware): void
{
$middleware->group('api', [
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
]);
// Register versioning middleware aliases
$middleware->alias([
'api.version' => ApiVersion::class,
'api.sunset' => ApiSunset::class,
]);
}
public function register(): void
{
// Merge API configuration
$this->mergeConfigFrom(__DIR__.'/config.php', 'api');
// Register API version service as singleton
$this->app->singleton(ApiVersionService::class);
}
public function boot(): void
{
$this->configureRateLimiting();
$this->registerMiddlewareAliases();
// Fire ApiRoutesRegistering event for lazy-loaded modules
LifecycleEventProvider::fireApiRoutes();
}
/**
* Register middleware aliases via router.
*
* This ensures aliases are available even if the static middleware()
* method isn't called (e.g., in testing or custom bootstrap).
*/
protected function registerMiddlewareAliases(): void
{
/** @var Router $router */
$router = $this->app->make(Router::class);
$router->aliasMiddleware('api.version', ApiVersion::class);
$router->aliasMiddleware('api.sunset', ApiSunset::class);
}
/**
* Configure API rate limiting.
*/
protected function configureRateLimiting(): void
{
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
}
}

View file

@ -1,112 +0,0 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Front\Api\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* API Sunset Middleware.
*
* Adds the HTTP Sunset header to responses to indicate when an endpoint
* will be deprecated or removed.
*
* The Sunset header is defined in RFC 8594 and indicates that a resource
* will become unresponsive at the specified date.
*
* ## Usage
*
* Apply to routes that will be sunset:
*
* ```php
* Route::middleware('api.sunset:2025-06-01')->group(function () {
* Route::get('/legacy-endpoint', LegacyController::class);
* });
* ```
*
* Or with a replacement link:
*
* ```php
* Route::middleware('api.sunset:2025-06-01,/api/v2/new-endpoint')->group(function () {
* Route::get('/old-endpoint', OldController::class);
* });
* ```
*
* ## Response Headers
*
* The middleware adds these headers:
* - Sunset: <date in RFC7231 format>
* - Deprecation: true
* - Link: <replacement-url>; rel="successor-version" (if replacement provided)
*
* @see https://datatracker.ietf.org/doc/html/rfc8594 RFC 8594: The "Sunset" HTTP Header Field
*/
class ApiSunset
{
/**
* Handle an incoming request.
*
* @param string $sunsetDate The sunset date (YYYY-MM-DD or RFC7231 format)
* @param string|null $replacement Optional replacement endpoint URL
*/
public function handle(Request $request, Closure $next, string $sunsetDate, ?string $replacement = null): Response
{
/** @var Response $response */
$response = $next($request);
// Convert date to RFC7231 format if needed
$formattedDate = $this->formatSunsetDate($sunsetDate);
// Add Sunset header
$response->headers->set('Sunset', $formattedDate);
// Add Deprecation header
$response->headers->set('Deprecation', 'true');
// Add warning header
$version = $request->attributes->get('api_version', 'unknown');
$response->headers->set(
'X-API-Warn',
"This endpoint is deprecated and will be removed on {$sunsetDate}."
);
// Add Link header for replacement if provided
if ($replacement !== null) {
$response->headers->set('Link', "<{$replacement}>; rel=\"successor-version\"");
}
return $response;
}
/**
* Format the sunset date to RFC7231 format.
*
* Accepts dates in YYYY-MM-DD format or already-formatted RFC7231 dates.
*/
protected function formatSunsetDate(string $date): string
{
// Check if already in RFC7231 format (contains comma, day name)
if (str_contains($date, ',')) {
return $date;
}
try {
return (new \DateTimeImmutable($date))
->setTimezone(new \DateTimeZone('GMT'))
->format(\DateTimeInterface::RFC7231);
} catch (\Exception) {
// If parsing fails, return as-is
return $date;
}
}
}

View file

@ -1,246 +0,0 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Front\Api\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* API Version Middleware.
*
* Parses the API version from the request and sets it on the request attributes.
* Supports version extraction from:
*
* 1. URL path prefix: /api/v1/users, /api/v2/users
* 2. Accept-Version header: Accept-Version: v1, Accept-Version: 2
* 3. Accept header with vendor type: Accept: application/vnd.hosthub.v1+json
*
* The resolved version is stored in request attributes and can be accessed via:
* - $request->attributes->get('api_version') - returns integer (e.g., 1, 2)
* - $request->attributes->get('api_version_string') - returns string (e.g., 'v1', 'v2')
*
* ## Configuration
*
* Configure in config/api.php:
* ```php
* 'versioning' => [
* 'default' => 1, // Default version when none specified
* 'current' => 1, // Current/latest version
* 'supported' => [1], // List of supported versions
* 'deprecated' => [], // List of deprecated (but still supported) versions
* 'sunset' => [], // Versions with sunset dates: [1 => '2025-06-01']
* ],
* ```
*
* ## Usage in Routes
*
* ```php
* // Apply to specific routes
* Route::middleware('api.version')->group(function () {
* Route::get('/users', [UserController::class, 'index']);
* });
*
* // Or with version constraint
* Route::middleware('api.version:2')->group(function () {
* // Only accepts v2 requests
* });
* ```
*
* ## Deprecation Headers
*
* When a request uses a deprecated API version, the response includes:
* - Deprecation: true
* - Sunset: <date> (if configured)
* - X-API-Warn: "API version X is deprecated..."
*
* @see ApiVersionService For programmatic version checks
*/
class ApiVersion
{
/**
* Handle an incoming request.
*
* @param int|null $requiredVersion Minimum version required (optional)
*/
public function handle(Request $request, Closure $next, ?int $requiredVersion = null): Response
{
$version = $this->resolveVersion($request);
$versionConfig = config('api.versioning', []);
$default = $versionConfig['default'] ?? 1;
$current = $versionConfig['current'] ?? 1;
$supported = $versionConfig['supported'] ?? [1];
$deprecated = $versionConfig['deprecated'] ?? [];
$sunset = $versionConfig['sunset'] ?? [];
// Use default if no version specified
if ($version === null) {
$version = $default;
}
// Validate version is supported
if (! in_array($version, $supported, true)) {
return $this->unsupportedVersion($version, $supported, $current);
}
// Check minimum version requirement
if ($requiredVersion !== null && $version < $requiredVersion) {
return $this->versionTooLow($version, $requiredVersion);
}
// Store version in request
$request->attributes->set('api_version', $version);
$request->attributes->set('api_version_string', "v{$version}");
/** @var Response $response */
$response = $next($request);
// Add version header to response
$response->headers->set('X-API-Version', (string) $version);
// Add deprecation headers if applicable
if (in_array($version, $deprecated, true)) {
$response->headers->set('Deprecation', 'true');
$response->headers->set('X-API-Warn', "API version {$version} is deprecated. Please upgrade to v{$current}.");
// Add Sunset header if configured
if (isset($sunset[$version])) {
$sunsetDate = $sunset[$version];
// Convert to HTTP date format if not already
if (! str_contains($sunsetDate, ',')) {
$sunsetDate = (new \DateTimeImmutable($sunsetDate))->format(\DateTimeInterface::RFC7231);
}
$response->headers->set('Sunset', $sunsetDate);
}
}
return $response;
}
/**
* Resolve the API version from the request.
*
* Priority order:
* 1. URL path (/api/v1/...)
* 2. Accept-Version header
* 3. Accept header vendor type
*/
protected function resolveVersion(Request $request): ?int
{
// 1. Check URL path for version prefix
$version = $this->versionFromPath($request);
if ($version !== null) {
return $version;
}
// 2. Check Accept-Version header
$version = $this->versionFromHeader($request);
if ($version !== null) {
return $version;
}
// 3. Check Accept header for vendor type
return $this->versionFromAcceptHeader($request);
}
/**
* Extract version from URL path.
*
* Matches: /api/v1/..., /api/v2/...
*/
protected function versionFromPath(Request $request): ?int
{
$path = $request->path();
// Match /api/v{n}/ or /v{n}/ at the start
if (preg_match('#^(?:api/)?v(\d+)(?:/|$)#', $path, $matches)) {
return (int) $matches[1];
}
return null;
}
/**
* Extract version from Accept-Version header.
*
* Accepts: v1, v2, 1, 2
*/
protected function versionFromHeader(Request $request): ?int
{
$header = $request->header('Accept-Version');
if ($header === null) {
return null;
}
// Strip 'v' prefix if present
$version = ltrim($header, 'vV');
if (is_numeric($version)) {
return (int) $version;
}
return null;
}
/**
* Extract version from Accept header vendor type.
*
* Matches: application/vnd.hosthub.v1+json
*/
protected function versionFromAcceptHeader(Request $request): ?int
{
$accept = $request->header('Accept', '');
// Match vendor media type: application/vnd.{name}.v{n}+json
if (preg_match('#application/vnd\.[^.]+\.v(\d+)\+#', $accept, $matches)) {
return (int) $matches[1];
}
return null;
}
/**
* Return 400 response for unsupported version.
*
* @param array<int> $supported
*/
protected function unsupportedVersion(int $requested, array $supported, int $current): Response
{
return response()->json([
'error' => 'unsupported_api_version',
'message' => "API version {$requested} is not supported.",
'requested_version' => $requested,
'supported_versions' => $supported,
'current_version' => $current,
'hint' => 'Use Accept-Version header or URL prefix (e.g., /api/v1/) to specify version.',
], 400, [
'X-API-Version' => (string) $current,
]);
}
/**
* Return 400 response when version is too low.
*/
protected function versionTooLow(int $requested, int $required): Response
{
return response()->json([
'error' => 'api_version_too_low',
'message' => "This endpoint requires API version {$required} or higher.",
'requested_version' => $requested,
'minimum_version' => $required,
], 400, [
'X-API-Version' => (string) $requested,
]);
}
}

View file

@ -1,266 +0,0 @@
# API Versioning
Core PHP Framework provides built-in API versioning support with deprecation handling and sunset headers.
## Quick Start
### 1. Configure Versions
Add to your `config/api.php`:
```php
'versioning' => [
'default' => 1, // Version when none specified
'current' => 2, // Latest/current version
'supported' => [1, 2], // All supported versions
'deprecated' => [1], // Deprecated but still working
'sunset' => [ // Removal dates
1 => '2025-12-31',
],
],
```
### 2. Apply Middleware
The `api.version` middleware is automatically available. Apply it to routes:
```php
// Version-agnostic routes (uses default version)
Route::middleware('api.version')->group(function () {
Route::get('/status', StatusController::class);
});
// Version-specific routes with URL prefix
use Core\Front\Api\VersionedRoutes;
VersionedRoutes::v1(function () {
Route::get('/users', [UserController::class, 'indexV1']);
});
VersionedRoutes::v2(function () {
Route::get('/users', [UserController::class, 'indexV2']);
});
```
### 3. Version Negotiation in Controllers
```php
use Core\Front\Api\ApiVersionService;
class UserController
{
public function __construct(
protected ApiVersionService $versions
) {}
public function index(Request $request)
{
return $this->versions->negotiate($request, [
1 => fn() => $this->indexV1(),
2 => fn() => $this->indexV2(),
]);
}
}
```
## Version Resolution
The middleware resolves the API version from (in priority order):
1. **URL Path**: `/api/v1/users` or `/v2/users`
2. **Accept-Version Header**: `Accept-Version: v1` or `Accept-Version: 2`
3. **Accept Header**: `Accept: application/vnd.hosthub.v1+json`
4. **Default**: Falls back to configured default version
## Response Headers
Successful responses include:
```
X-API-Version: 2
```
Deprecated versions also include:
```
Deprecation: true
X-API-Warn: API version 1 is deprecated. Please upgrade to v2.
Sunset: Wed, 31 Dec 2025 00:00:00 GMT
```
## Error Responses
### Unsupported Version (400)
```json
{
"error": "unsupported_api_version",
"message": "API version 99 is not supported.",
"requested_version": 99,
"supported_versions": [1, 2],
"current_version": 2,
"hint": "Use Accept-Version header or URL prefix (e.g., /api/v1/) to specify version."
}
```
### Version Too Low (400)
```json
{
"error": "api_version_too_low",
"message": "This endpoint requires API version 2 or higher.",
"requested_version": 1,
"minimum_version": 2
}
```
## Versioned Routes Helper
The `VersionedRoutes` class provides a fluent API for registering version-specific routes:
```php
use Core\Front\Api\VersionedRoutes;
// Simple version registration
VersionedRoutes::v1(function () {
Route::get('/users', UserController::class);
});
// With URL prefix (default)
VersionedRoutes::v2(function () {
Route::get('/users', UserControllerV2::class);
}); // Accessible at /api/v2/users
// Header-only versioning (no URL prefix)
VersionedRoutes::version(2)
->withoutPrefix()
->routes(function () {
Route::get('/users', UserControllerV2::class);
}); // Accessible at /api/users with Accept-Version: 2
// Multiple versions for the same routes
VersionedRoutes::versions([1, 2], function () {
Route::get('/health', HealthController::class);
});
// Deprecated version with sunset
VersionedRoutes::v1()
->deprecated('2025-06-01')
->routes(function () {
Route::get('/legacy', LegacyController::class);
});
```
## ApiVersionService
Inject `ApiVersionService` for programmatic version checks:
```php
use Core\Front\Api\ApiVersionService;
class UserController
{
public function __construct(
protected ApiVersionService $versions
) {}
public function show(Request $request, User $user)
{
$data = $user->toArray();
// Version-specific transformations
return $this->versions->transform($request, $data, [
1 => fn($d) => Arr::except($d, ['created_at', 'metadata']),
2 => fn($d) => $d,
]);
}
}
```
### Available Methods
| Method | Description |
|--------|-------------|
| `current($request)` | Get version number (e.g., 1, 2) |
| `currentString($request)` | Get version string (e.g., 'v1') |
| `is($version, $request)` | Check exact version |
| `isV1($request)` | Check if version 1 |
| `isV2($request)` | Check if version 2 |
| `isAtLeast($version, $request)` | Check minimum version |
| `isDeprecated($request)` | Check if version is deprecated |
| `defaultVersion()` | Get configured default |
| `latestVersion()` | Get current/latest version |
| `supportedVersions()` | Get all supported versions |
| `deprecatedVersions()` | Get deprecated versions |
| `sunsetDates()` | Get sunset dates map |
| `isSupported($version)` | Check if version is supported |
| `negotiate($request, $handlers)` | Call version-specific handler |
| `transform($request, $data, $transformers)` | Transform data per version |
## Sunset Middleware
For endpoint-specific deprecation, use the `api.sunset` middleware:
```php
Route::middleware('api.sunset:2025-06-01')->group(function () {
Route::get('/legacy-endpoint', LegacyController::class);
});
// With replacement hint
Route::middleware('api.sunset:2025-06-01,/api/v2/new-endpoint')->group(function () {
Route::get('/old-endpoint', OldController::class);
});
```
Adds headers:
```
Sunset: Sun, 01 Jun 2025 00:00:00 GMT
Deprecation: true
X-API-Warn: This endpoint is deprecated and will be removed on 2025-06-01.
Link: </api/v2/new-endpoint>; rel="successor-version"
```
## Versioning Strategy
### Guidelines
1. **Add, don't remove**: New fields can be added to any version
2. **New version for breaking changes**: Removing/renaming fields requires new version
3. **Deprecate before removal**: Give clients time to migrate
4. **Document changes**: Maintain changelog per version
### Version Lifecycle
```
v1: Active -> Deprecated (with sunset) -> Removed from supported
v2: Active (current)
v3: Future
```
### Environment Variables
```env
API_VERSION_DEFAULT=1
API_VERSION_CURRENT=2
API_VERSIONS_SUPPORTED=1,2
API_VERSIONS_DEPRECATED=1
```
## Testing
Test versioned endpoints by setting the Accept-Version header:
```php
$response = $this->withHeaders([
'Accept-Version' => 'v2',
])->getJson('/api/users');
$response->assertHeader('X-API-Version', '2');
```
Or use URL prefix:
```php
$response = $this->getJson('/api/v2/users');
```

View file

@ -1,248 +0,0 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Front\Api;
use Illuminate\Support\Facades\Route;
/**
* Versioned Routes Helper.
*
* Provides fluent helpers for registering version-based API routes.
*
* ## Basic Usage
*
* Register routes for a specific version:
*
* ```php
* use Core\Front\Api\VersionedRoutes;
*
* VersionedRoutes::v1(function () {
* Route::get('/users', [UserController::class, 'index']);
* });
*
* VersionedRoutes::v2(function () {
* Route::get('/users', [UserControllerV2::class, 'index']);
* });
* ```
*
* ## URL Prefixed Versions
*
* By default, routes are prefixed with the version (e.g., /api/v1/users):
*
* ```php
* VersionedRoutes::v1(function () {
* Route::get('/users', ...); // Accessible at /api/v1/users
* });
* ```
*
* ## Header-Only Versions
*
* For routes that use header-based versioning only:
*
* ```php
* VersionedRoutes::version(1)
* ->withoutPrefix()
* ->routes(function () {
* Route::get('/users', ...); // Accessible at /api/users with Accept-Version: 1
* });
* ```
*
* ## Multiple Versions
*
* Register the same routes for multiple versions:
*
* ```php
* VersionedRoutes::versions([1, 2], function () {
* Route::get('/status', [StatusController::class, 'index']);
* });
* ```
*
* ## Deprecation
*
* Mark a version as deprecated with custom sunset date:
*
* ```php
* VersionedRoutes::v1()
* ->deprecated('2025-06-01')
* ->routes(function () {
* Route::get('/legacy', ...);
* });
* ```
*/
class VersionedRoutes
{
protected int $version;
protected bool $usePrefix = true;
protected ?string $sunsetDate = null;
protected bool $isDeprecated = false;
/**
* @var array<string>
*/
protected array $middleware = [];
/**
* Create a new versioned routes instance.
*/
public function __construct(int $version)
{
$this->version = $version;
}
/**
* Create routes for version 1.
*/
public static function v1(?callable $routes = null): static
{
$instance = new static(1);
if ($routes !== null) {
$instance->routes($routes);
}
return $instance;
}
/**
* Create routes for version 2.
*/
public static function v2(?callable $routes = null): static
{
$instance = new static(2);
if ($routes !== null) {
$instance->routes($routes);
}
return $instance;
}
/**
* Create routes for a specific version.
*/
public static function version(int $version): static
{
return new static($version);
}
/**
* Register routes for multiple versions.
*
* @param array<int> $versions
*/
public static function versions(array $versions, callable $routes): void
{
foreach ($versions as $version) {
(new static($version))->routes($routes);
}
}
/**
* Don't use URL prefix for this version.
*
* Routes will be accessible without /v{n} prefix but will
* still require version header for version-specific behaviour.
*/
public function withoutPrefix(): static
{
$this->usePrefix = false;
return $this;
}
/**
* Use URL prefix for this version.
*
* This is the default behaviour.
*/
public function withPrefix(): static
{
$this->usePrefix = true;
return $this;
}
/**
* Mark this version as deprecated.
*
* @param string|null $sunsetDate Optional sunset date (YYYY-MM-DD or RFC7231 format)
*/
public function deprecated(?string $sunsetDate = null): static
{
$this->isDeprecated = true;
$this->sunsetDate = $sunsetDate;
return $this;
}
/**
* Add additional middleware to the version routes.
*
* @param array<string>|string $middleware
*/
public function middleware(array|string $middleware): static
{
$this->middleware = array_merge(
$this->middleware,
is_array($middleware) ? $middleware : [$middleware]
);
return $this;
}
/**
* Register the routes for this version.
*/
public function routes(callable $routes): void
{
$attributes = $this->buildRouteAttributes();
Route::group($attributes, $routes);
}
/**
* Build the route group attributes.
*
* @return array<string, mixed>
*/
protected function buildRouteAttributes(): array
{
$attributes = [
'middleware' => $this->buildMiddleware(),
];
if ($this->usePrefix) {
$attributes['prefix'] = "v{$this->version}";
}
return $attributes;
}
/**
* Build the middleware stack for this version.
*
* @return array<string>
*/
protected function buildMiddleware(): array
{
$middleware = ["api.version:{$this->version}"];
if ($this->isDeprecated && $this->sunsetDate) {
$middleware[] = "api.sunset:{$this->sunsetDate}";
}
return array_merge($middleware, $this->middleware);
}
}

View file

@ -1,78 +0,0 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
/**
* API Configuration.
*
* Settings for the REST API infrastructure including versioning,
* rate limiting, and deprecation handling.
*/
return [
/*
|--------------------------------------------------------------------------
| API Versioning
|--------------------------------------------------------------------------
|
| Configure how API versions are handled. The middleware supports:
| - URL path versioning: /api/v1/users
| - Header versioning: Accept-Version: v1
| - Accept header: application/vnd.hosthub.v1+json
|
| Version Strategy:
| - Add new fields to existing versions (backwards compatible)
| - Use new version for breaking changes (removing/renaming fields)
| - Deprecate old versions with sunset dates before removal
|
*/
'versioning' => [
// Default version when no version specified in request
// Clients should always specify version explicitly
'default' => (int) env('API_VERSION_DEFAULT', 1),
// Current/latest API version
// Used in deprecation warnings to suggest upgrade path
'current' => (int) env('API_VERSION_CURRENT', 1),
// Supported API versions (all still functional)
// Remove versions from this list to disable them entirely
'supported' => array_map('intval', array_filter(
explode(',', env('API_VERSIONS_SUPPORTED', '1'))
)),
// Deprecated versions (still work but warn clients)
// Responses include Deprecation: true header
'deprecated' => array_map('intval', array_filter(
explode(',', env('API_VERSIONS_DEPRECATED', ''))
)),
// Sunset dates for deprecated versions
// Format: [version => 'YYYY-MM-DD']
// After this date, version should be removed from 'supported'
'sunset' => [
// Example: 1 => '2025-12-31',
],
],
/*
|--------------------------------------------------------------------------
| Response Headers
|--------------------------------------------------------------------------
|
| Standard headers added to API responses.
|
*/
'headers' => [
// Add X-API-Version header to all responses
'include_version' => true,
// Add deprecation warnings for old versions
'include_deprecation' => true,
],
];

View file

@ -17,15 +17,17 @@ use Illuminate\Support\AggregateServiceProvider;
/**
* Core front-end module - I/O translation layer.
*
* Seven frontages, each translating a transport protocol:
* Six frontages bundled in the framework, each translating a transport protocol:
* Web - HTTP HTML (public marketing)
* Client - HTTP HTML (namespace owner dashboard)
* Admin - HTTP HTML (backend admin dashboard)
* Api - HTTP JSON (REST API)
* Mcp - HTTP JSON-RPC (MCP protocol)
* Cli - Artisan commands (console context)
* Stdio - stdin/stdout (CLI pipes, MCP stdio)
* Components - View namespaces (shared across HTTP frontages)
*
* Additional frontages provided by their packages (auto-discovered):
* Api - HTTP JSON (REST API) php-api
* Mcp - HTTP JSON-RPC (MCP protocol) php-mcp
*/
class Boot extends AggregateServiceProvider
{
@ -33,8 +35,6 @@ class Boot extends AggregateServiceProvider
Web\Boot::class,
Client\Boot::class,
Admin\Boot::class,
Api\Boot::class,
Mcp\Boot::class,
Cli\Boot::class,
Stdio\Boot::class,
Components\Boot::class,
@ -49,7 +49,17 @@ class Boot extends AggregateServiceProvider
Web\Boot::middleware($middleware);
Client\Boot::middleware($middleware);
Admin\Boot::middleware($middleware);
Api\Boot::middleware($middleware);
Mcp\Boot::middleware($middleware);
// API and MCP groups — inlined because middleware() runs during
// Application::configure(), before package providers load.
// Packages add their own aliases during boot via lifecycle events.
$middleware->group('api', [
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
]);
$middleware->group('mcp', [
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
]);
}
}

View file

@ -1,87 +0,0 @@
@php
$appName = config('core.app.name', 'Core PHP');
$appUrl = config('app.url', 'https://core.test');
$privacyUrl = config('core.urls.privacy', '/privacy');
$termsUrl = config('core.urls.terms', '/terms');
@endphp
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ?? 'MCP Portal' }} - {{ $appName }}</title>
<meta name="description" content="{{ $description ?? 'Connect AI agents via Model Context Protocol' }}">
@vite(['resources/css/app.css', 'resources/js/app.js'])
@fluxAppearance
</head>
<body class="min-h-screen bg-white dark:bg-zinc-900">
<!-- Header -->
<header class="border-b border-zinc-200 dark:border-zinc-800">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<div class="flex items-center space-x-8">
<a href="{{ route('mcp.landing') }}" class="flex items-center space-x-2">
<span class="text-xl font-bold text-zinc-900 dark:text-white">MCP Portal</span>
</a>
<nav class="hidden md:flex items-center space-x-6 text-sm">
<a href="{{ route('mcp.servers.index') }}" class="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white">
Servers
</a>
<a href="{{ route('mcp.connect') }}" class="text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white">
Setup Guide
</a>
</nav>
</div>
<div class="flex items-center space-x-4">
@php
$workspace = request()->attributes->get('mcp_workspace');
@endphp
@if($workspace)
<a href="{{ route('mcp.dashboard') }}" class="text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white">
Dashboard
</a>
<a href="{{ route('mcp.keys') }}" class="text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white">
API Keys
</a>
<span class="text-sm text-zinc-500 dark:text-zinc-400">
{{ $workspace->name }}
</span>
@else
<a href="{{ route('login') }}" class="text-sm text-cyan-600 hover:text-cyan-700 dark:text-cyan-400 dark:hover:text-cyan-300">
Sign in
</a>
@endif
<a href="{{ $appUrl }}" class="text-sm text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white">
&larr; {{ $appName }}
</a>
</div>
</div>
</div>
</header>
<!-- Main Content -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{{ $slot }}
</main>
<!-- Footer -->
<footer class="border-t border-zinc-200 dark:border-zinc-800 mt-auto">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="flex items-center justify-between">
<p class="text-sm text-zinc-500 dark:text-zinc-400">
&copy; {{ date('Y') }} {{ $appName }}. All rights reserved.
</p>
<div class="flex items-center space-x-6 text-sm text-zinc-500 dark:text-zinc-400">
<a href="{{ $privacyUrl }}" class="hover:text-zinc-900 dark:hover:text-white">Privacy</a>
<a href="{{ $termsUrl }}" class="hover:text-zinc-900 dark:hover:text-white">Terms</a>
</div>
</div>
</div>
</footer>
@fluxScripts
</body>
</html>

View file

@ -1,50 +0,0 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Front\Mcp;
use Core\LifecycleEventProvider;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Support\ServiceProvider;
/**
* MCP frontage - MCP API stage.
*
* Provides mcp middleware group for MCP protocol routes.
* Authentication middleware should be added by the core-mcp package.
*/
class Boot extends ServiceProvider
{
/**
* Configure mcp middleware group.
*/
public static function middleware(Middleware $middleware): void
{
$middleware->group('mcp', [
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
]);
}
public function register(): void
{
//
}
public function boot(): void
{
// Fire McpRoutesRegistering event for lazy-loaded modules
LifecycleEventProvider::fireMcpRoutes();
// Fire McpToolsRegistering so modules can register tool handlers
LifecycleEventProvider::fireMcpTools();
}
}

View file

@ -1,48 +0,0 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Front\Mcp\Contracts;
use Core\Front\Mcp\McpContext;
/**
* Interface for MCP tool handlers.
*
* Each MCP tool is implemented as a handler class that provides:
* - A JSON schema describing the tool for Claude
* - A handle method that processes tool invocations
*
* Tool handlers are registered via the McpToolsRegistering event
* and can be used by both stdio and HTTP MCP transports.
*/
interface McpToolHandler
{
/**
* Get the JSON schema describing this tool.
*
* The schema follows the MCP tool specification:
* - name: Tool identifier (snake_case)
* - description: What the tool does (for Claude)
* - inputSchema: JSON Schema for parameters
*
* @return array{name: string, description: string, inputSchema: array}
*/
public static function schema(): array;
/**
* Handle a tool invocation.
*
* @param array $args Arguments from the tool call
* @param McpContext $context Server context (session, notifications, etc.)
* @return array Result to return to Claude
*/
public function handle(array $args, McpContext $context): array;
}

View file

@ -1,133 +0,0 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Front\Mcp;
use Closure;
/**
* Context object passed to MCP tool handlers.
*
* Abstracts the transport layer (stdio vs HTTP) so tool handlers
* can work with either transport without modification.
*
* Provides access to:
* - Current session tracking
* - Current plan context
* - Notification sending
* - Session logging
*/
class McpContext
{
/**
* @param object|null $currentPlan AgentPlan model instance when Agentic module installed
*/
public function __construct(
private ?string $sessionId = null,
private ?object $currentPlan = null,
private ?Closure $notificationCallback = null,
private ?Closure $logCallback = null,
) {}
/**
* Get the current session ID if one is active.
*/
public function getSessionId(): ?string
{
return $this->sessionId;
}
/**
* Set the current session ID.
*/
public function setSessionId(?string $sessionId): void
{
$this->sessionId = $sessionId;
}
/**
* Get the current plan if one is active.
*
* @return object|null AgentPlan model instance when Agentic module installed
*/
public function getCurrentPlan(): ?object
{
return $this->currentPlan;
}
/**
* Set the current plan.
*
* @param object|null $plan AgentPlan model instance
*/
public function setCurrentPlan(?object $plan): void
{
$this->currentPlan = $plan;
}
/**
* Send an MCP notification to the client.
*
* Notifications are one-way messages that don't expect a response.
* Common notifications include progress updates, log messages, etc.
*/
public function sendNotification(string $method, array $params = []): void
{
if ($this->notificationCallback) {
($this->notificationCallback)($method, $params);
}
}
/**
* Log a message to the current session.
*
* Messages are recorded in the session log for handoff context
* and audit trail purposes.
*/
public function logToSession(string $message, string $type = 'info', array $data = []): void
{
if ($this->logCallback) {
($this->logCallback)($message, $type, $data);
}
}
/**
* Set the notification callback.
*/
public function setNotificationCallback(?Closure $callback): void
{
$this->notificationCallback = $callback;
}
/**
* Set the log callback.
*/
public function setLogCallback(?Closure $callback): void
{
$this->logCallback = $callback;
}
/**
* Check if a session is currently active.
*/
public function hasSession(): bool
{
return $this->sessionId !== null;
}
/**
* Check if a plan is currently active.
*/
public function hasPlan(): bool
{
return $this->currentPlan !== null;
}
}

View file

@ -73,7 +73,7 @@ use Livewire\Livewire;
* Processes: views, translations, livewire, routes
* ('admin' middleware)
*
* ├─── Front/Api/Boot ──────────────────────────────────────────────────
* ├─── Front/Api/Boot (php-api package) ───────────────────────────────
* └── LifecycleEventProvider::fireApiRoutes()
* Fires: ApiRoutesRegistering
* Processes: routes ('api' middleware)
@ -88,7 +88,7 @@ use Livewire\Livewire;
* Fires: ConsoleBooting
* Processes: command classes
*
* └─── Front/Mcp/Boot ──────────────────────────────────────────────────
* └─── Front/Mcp/Boot (php-mcp package) ───────────────────────────────
* ├── LifecycleEventProvider::fireMcpRoutes()
* Fires: McpRoutesRegistering
* Processes: routes ('mcp' middleware)
@ -456,7 +456,7 @@ class LifecycleEventProvider extends ServiceProvider
*
* @return array<string> Fully qualified class names of McpToolHandler implementations
*
* @see \Core\Front\Mcp\Contracts\McpToolHandler
* @see \Core\Front\Mcp\Contracts\McpToolHandler (in php-mcp package)
*/
public static function fireMcpTools(): array
{