From 8022c5c026a78e56b851bb11f0163374ce49c898 Mon Sep 17 00:00:00 2001 From: butschster Date: Fri, 26 Sep 2025 14:17:06 +0400 Subject: [PATCH 1/8] feat: add HttpTransportBootloader with CORS and Proxy Middleware configuration --- .github/workflows/build-linux-release.yml | 2 +- src/McpServer/ActionsBootloader.php | 1 + src/McpServer/HttpTransportBootloader.php | 40 +++++++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 src/McpServer/HttpTransportBootloader.php diff --git a/.github/workflows/build-linux-release.yml b/.github/workflows/build-linux-release.yml index 113ea23e..79027dd6 100644 --- a/.github/workflows/build-linux-release.yml +++ b/.github/workflows/build-linux-release.yml @@ -214,7 +214,7 @@ jobs: type=ref,event=branch type=ref,event=pr type=semver,pattern={{version}} - type=raw,value=latest + type=raw,value=latest,enable=${{ !github.event.release.prerelease }} - name: Create manifest list and push working-directory: ${{ runner.temp }}/digests diff --git a/src/McpServer/ActionsBootloader.php b/src/McpServer/ActionsBootloader.php index 5f494b1c..597331e6 100644 --- a/src/McpServer/ActionsBootloader.php +++ b/src/McpServer/ActionsBootloader.php @@ -66,6 +66,7 @@ public function defineDependencies(): array { return [ McpServerBootloader::class, + HttpTransportBootloader::class, ]; } diff --git a/src/McpServer/HttpTransportBootloader.php b/src/McpServer/HttpTransportBootloader.php new file mode 100644 index 00000000..fbb528de --- /dev/null +++ b/src/McpServer/HttpTransportBootloader.php @@ -0,0 +1,40 @@ + \is_string($values) ? \array_map(\trim(...), \explode(',', $values)) : []; + + // Get allowed origins from env or use wildcard + $allowedOrigins = $convertValues($env->get('MCP_CORS_ALLOWED_ORIGINS', '*')); + $allowedMethods = $convertValues($env->get('MCP_CORS_ALLOWED_METHODS', 'GET,POST,PUT,DELETE,OPTIONS')); + $allowedHeaders = $convertValues($env->get('MCP_CORS_ALLOWED_HEADERS', 'Content-Type,Authorization')); + + $registry->register( + new CorsMiddleware( + allowedOrigins: $allowedOrigins, + allowedMethods: $allowedMethods, + allowedHeaders: $allowedHeaders, + ), + ); + + $registry->register( + new ProxyAwareMiddleware( + trustProxy: (bool) $env->get('MCP_TRUST_PROXY', true), + ), + ); + } +} From 6a5d3c24359c01c53142a247f08e27141f787f9f Mon Sep 17 00:00:00 2001 From: butschster Date: Fri, 26 Sep 2025 15:17:56 +0400 Subject: [PATCH 2/8] refactor: update `ctx/mcp-server` dependency and simplify ProjectsListToolAction initialization --- composer.json | 2 +- src/McpServer/Projects/Actions/ProjectsListToolAction.php | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index 14366780..c4b23af7 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,7 @@ "ext-curl": "*", "ctx/module-research": "^1.0", "ctx/module-config-templates": "^1.0", - "ctx/mcp-server": "^1.1", + "ctx/mcp-server": "^1.1.3", "league/html-to-markdown": "^5.1", "psr/log": "^3.0", "symfony/finder": "^6.0 | ^7.0 | ^8.0", diff --git a/src/McpServer/Projects/Actions/ProjectsListToolAction.php b/src/McpServer/Projects/Actions/ProjectsListToolAction.php index 2edd81aa..fe65e30b 100644 --- a/src/McpServer/Projects/Actions/ProjectsListToolAction.php +++ b/src/McpServer/Projects/Actions/ProjectsListToolAction.php @@ -4,16 +4,15 @@ namespace Butschster\ContextGenerator\McpServer\Projects\Actions; -use Butschster\ContextGenerator\McpServer\Attribute\InputSchema; use Butschster\ContextGenerator\McpServer\Attribute\Tool; use Butschster\ContextGenerator\McpServer\Projects\Actions\Dto\CurrentProjectResponse; use Butschster\ContextGenerator\McpServer\Projects\Actions\Dto\ProjectInfoResponse; -use Butschster\ContextGenerator\McpServer\Projects\Actions\Dto\ProjectListRequest; use Butschster\ContextGenerator\McpServer\Projects\Actions\Dto\ProjectsListResponse; use Butschster\ContextGenerator\McpServer\Projects\ProjectServiceInterface; use Butschster\ContextGenerator\McpServer\Action\ToolResult; use Butschster\ContextGenerator\McpServer\Routing\Attribute\Post; use PhpMcp\Schema\Result\CallToolResult; +use Psr\Http\Message\ServerRequestInterface; use Psr\Log\LoggerInterface; #[Tool( @@ -21,7 +20,6 @@ description: 'List all registered projects with their paths, aliases, and configuration details', title: 'Projects List', )] -#[InputSchema(class: ProjectListRequest::class)] final readonly class ProjectsListToolAction { public function __construct( @@ -30,7 +28,7 @@ public function __construct( ) {} #[Post(path: '/tools/call/projects-list', name: 'tools.projects-list')] - public function __invoke(ProjectListRequest $request): CallToolResult + public function __invoke(ServerRequestInterface $request): CallToolResult { $this->logger->info('Processing projects-list tool'); From d2d6f49c60c637f647c25aaaca3e1721bdeab906 Mon Sep 17 00:00:00 2001 From: butschster Date: Fri, 26 Sep 2025 20:56:16 +0400 Subject: [PATCH 3/8] fix: handle case where schema properties are an object in ToolRunCommand --- src/McpServer/Tool/Console/ToolRunCommand.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/McpServer/Tool/Console/ToolRunCommand.php b/src/McpServer/Tool/Console/ToolRunCommand.php index 735f1be1..8a9bb1f2 100644 --- a/src/McpServer/Tool/Console/ToolRunCommand.php +++ b/src/McpServer/Tool/Console/ToolRunCommand.php @@ -333,6 +333,9 @@ private function validateArguments(ToolSchema $schema, array $args): array { $required = $schema->getRequiredProperties(); $properties = $schema->getProperties(); + if (\is_object($properties)) { + return []; + } // Check all required properties are provided foreach ($required as $prop) { From a401ac95174a37969ac18718737c1de14efa386b Mon Sep 17 00:00:00 2001 From: butschster Date: Fri, 26 Sep 2025 21:08:40 +0400 Subject: [PATCH 4/8] Move out some tests in a proper package --- .../Template/Console/InitCommandTest.php | 71 -------- .../Template/Console/ListCommandTest.php | 164 ------------------ 2 files changed, 235 deletions(-) delete mode 100644 tests/src/Feature/Template/Console/InitCommandTest.php delete mode 100644 tests/src/Feature/Template/Console/ListCommandTest.php diff --git a/tests/src/Feature/Template/Console/InitCommandTest.php b/tests/src/Feature/Template/Console/InitCommandTest.php deleted file mode 100644 index 53499a05..00000000 --- a/tests/src/Feature/Template/Console/InitCommandTest.php +++ /dev/null @@ -1,71 +0,0 @@ - ['context.yaml', 'context.yaml']; - yield 'yml' => ['test.yml', 'test.yaml']; - yield 'json' => ['context.json', 'context.json']; - } - - #[Test] - #[DataProvider('configFileFormat')] - public function config_file_should_be_created(string $filename, $resultFilename): void - { - $result = $this->runCommand('init', [ - '--config-file' => $filename, - ]); - - $this->assertStringContainsString('[OK] Configuration created:', $result); - $this->assertStringContainsString($this->getConfigPath($resultFilename), $result); - - $this->assertFileExists($this->getConfigPath($resultFilename), 'Config file should exist'); - - $content = \file_get_contents($this->getConfigPath($resultFilename)); - $this->assertNotEmpty($content, 'Config file should not be empty'); - - $this->assertStringContainsString('$schema', $content); - $this->assertStringContainsString('PHP Project Structure', $content); - $this->assertStringContainsString('docs/php-structure.md', $content); - } - - #[Test] - public function invalid_format(): void - { - $result = $this->runCommand('init', [ - '--config-file' => 'context.txt', - ]); - - $this->assertStringContainsString( - '[ERROR] Unsupported config type: txt', - $result, - ); - } - - #[\Override] - protected function tearDown(): void - { - parent::tearDown(); - - foreach (self::configFileFormat() as $format) { - $filePath = $this->getConfigPath($format[1]); - if (\file_exists($filePath)) { - \unlink($filePath); - } - } - } - - protected function getConfigPath(string $config): string - { - return $this->rootDirectory() . '/' . $config; - } -} diff --git a/tests/src/Feature/Template/Console/ListCommandTest.php b/tests/src/Feature/Template/Console/ListCommandTest.php deleted file mode 100644 index 4cc792a4..00000000 --- a/tests/src/Feature/Template/Console/ListCommandTest.php +++ /dev/null @@ -1,164 +0,0 @@ -runCommand('template:list'); - - $this->assertStringContainsString('Available Templates', $result); - $this->assertStringContainsString('Name', $result); - $this->assertStringContainsString('Description', $result); - $this->assertStringContainsString('Tags', $result); - $this->assertStringContainsString('Priority', $result); - - // Should contain at least some built-in templates - $this->assertStringContainsString('laravel', $result); - $this->assertStringContainsString('symfony', $result); - $this->assertStringContainsString('generic-php', $result); - - // Should show usage note - $this->assertStringContainsString('ctx init ', $result); - } - - #[Test] - public function lists_templates_in_detailed_format(): void - { - $result = $this->runCommand('template:list', ['--detailed' => true]); - - $this->assertStringContainsString('Available Templates', $result); - - // Should show detailed information - $this->assertStringContainsString('Priority', $result); - $this->assertStringContainsString('Tags', $result); - $this->assertStringContainsString('Detection Criteria', $result); - $this->assertStringContainsString('Generated Documents', $result); - - // Should contain template names as sections - $this->assertStringContainsString('laravel', $result); - $this->assertStringContainsString('symfony', $result); - - // Should show usage notes - $this->assertStringContainsString('ctx init ', $result); - $this->assertStringContainsString('ctx init --show-all', $result); - } - - #[Test] - public function filters_templates_by_single_tag(): void - { - $result = $this->runCommand('template:list', ['--tag' => 'php']); - - $this->assertStringContainsString('Available Templates', $result); - - // Should contain PHP-related templates - $this->assertStringContainsString('laravel', $result); - $this->assertStringContainsString('symfony', $result); - $this->assertStringContainsString('generic-php', $result); - - // Should not contain JavaScript-only templates if they exist - // (This depends on the actual templates available) - } - - #[Test] - public function filters_templates_by_multiple_tags(): void - { - $result = $this->runCommand('template:list', ['--tag' => ['php', 'framework']]); - - $this->assertStringContainsString('Available Templates', $result); - - // Should contain framework templates that have both php and framework tags - $this->assertStringContainsString('laravel', $result); - $this->assertStringContainsString('symfony', $result); - } - - #[Test] - public function shows_warning_when_no_templates_match_filter(): void - { - $result = $this->runCommand('template:list', ['--tag' => 'nonexistent-tag']); - - $this->assertStringContainsString('No templates found with tag(s): nonexistent-tag', $result); - } - - #[Test] - public function shows_warning_when_no_templates_available(): void - { - // This test would require mocking the template registry to return empty results - // For now, we'll skip this as it requires dependency injection setup - $this->markTestSkipped('Requires mocking TemplateRegistry which has complex dependencies'); - } - - #[Test] - public function detailed_view_shows_detection_criteria(): void - { - $result = $this->runCommand('template:list', ['--detailed' => true]); - - // Should show detection criteria details - $this->assertStringContainsString('Detection Criteria', $result); - $this->assertStringContainsString('Required Files', $result); - $this->assertStringContainsString('Expected Directories', $result); - $this->assertStringContainsString('Required Packages', $result); - } - - #[Test] - public function detailed_view_shows_generated_documents(): void - { - $result = $this->runCommand('template:list', ['--detailed' => true]); - - // Should show information about generated documents - $this->assertStringContainsString('Generated Documents', $result); - - // Should show document mappings (description → output path) - $this->assertStringContainsString('→', $result); - } - - #[Test] - public function basic_and_detailed_views_both_show_usage_notes(): void - { - $basicResult = $this->runCommand('template:list'); - $detailedResult = $this->runCommand('template:list', ['--detailed' => true]); - - // Both should show usage information - $this->assertStringContainsString('ctx init ', $basicResult); - $this->assertStringContainsString('ctx init ', $detailedResult); - - // Detailed view should show additional options - $this->assertStringContainsString('ctx init --show-all', $detailedResult); - } - - #[Test] - public function can_use_shortcut_options(): void - { - // Test shortcut for detailed - $detailedResult = $this->runCommand('template:list', ['-d' => true]); - $this->assertStringContainsString('Detection Criteria', $detailedResult); - - // Test shortcut for tag filter - $tagResult = $this->runCommand('template:list', ['-t' => 'php']); - $this->assertStringContainsString('Available Templates', $tagResult); - } - - #[Test] - public function combines_tag_filter_with_detailed_view(): void - { - $result = $this->runCommand('template:list', [ - '--tag' => 'php', - '--detailed' => true, - ]); - - $this->assertStringContainsString('Available Templates', $result); - $this->assertStringContainsString('Detection Criteria', $result); - $this->assertStringContainsString('Generated Documents', $result); - - // Should contain PHP templates - $this->assertStringContainsString('laravel', $result); - $this->assertStringContainsString('symfony', $result); - } -} From 61cce196b970c7d6072ef60d399fdc5279ffe06f Mon Sep 17 00:00:00 2001 From: butschster Date: Sat, 27 Sep 2025 17:00:16 +0400 Subject: [PATCH 5/8] feat: add OAuth support --- src/McpServer/HttpTransportBootloader.php | 123 +++++++++++++++++++++- 1 file changed, 122 insertions(+), 1 deletion(-) diff --git a/src/McpServer/HttpTransportBootloader.php b/src/McpServer/HttpTransportBootloader.php index fbb528de..2423a82a 100644 --- a/src/McpServer/HttpTransportBootloader.php +++ b/src/McpServer/HttpTransportBootloader.php @@ -4,6 +4,22 @@ namespace Butschster\ContextGenerator\McpServer; +use GuzzleHttp\Client; +use Laminas\Diactoros\RequestFactory; +use Laminas\Diactoros\ResponseFactory; +use Laminas\Diactoros\StreamFactory; +use Mcp\Server\Authentication\Contract\OAuthRegisteredClientsStoreInterface; +use Mcp\Server\Authentication\Contract\OAuthServerProviderInterface; +use Mcp\Server\Authentication\Contract\OAuthTokenVerifierInterface; +use Mcp\Server\Authentication\Dto\OAuthClientInformation; +use Mcp\Server\Authentication\Dto\OAuthMetadata; +use Mcp\Server\Authentication\Middleware\ClientAuthMiddleware; +use Mcp\Server\Authentication\Provider\ProxyEndpoints; +use Mcp\Server\Authentication\Provider\ProxyProvider; +use Mcp\Server\Authentication\Router\AuthRouterOptions; +use Mcp\Server\Authentication\Router\McpAuthRouter; +use Mcp\Server\Authentication\Storage\InMemoryClientRepository; +use Mcp\Server\Authentication\Storage\JwtTokenValidator; use Mcp\Server\Transports\Middleware\CorsMiddleware; use Mcp\Server\Transports\Middleware\ProxyAwareMiddleware; use Spiral\Boot\Bootloader\Bootloader; @@ -12,8 +28,95 @@ final class HttpTransportBootloader extends Bootloader { - public function boot(MiddlewareRegistryInterface $registry, EnvironmentInterface $env): void + public function defineSingletons(): array { + return [ + // OAuth Token Validator + OAuthTokenVerifierInterface::class => fn() => new JwtTokenValidator( + publicKey: $this->getPublicKey(), + algorithm: 'RS256', + ), + + OAuthRegisteredClientsStoreInterface::class => static function ( + EnvironmentInterface $env, + ) { + $store = new InMemoryClientRepository(); + $store->registerClient(new OAuthClientInformation( + clientId: $env->get('OAUTH_CLIENT_ID'), + clientSecret: $env->get('OAUTH_CLIENT_SECRET'), + clientIdIssuedAt: \time(), + clientSecretExpiresAt: null, // Never expires + )); + + return $store; + }, + + // OAuth Server Provider - using proxy to external OAuth server + OAuthServerProviderInterface::class => static fn( + EnvironmentInterface $env, + OAuthTokenVerifierInterface $tokenVerifier, + OAuthRegisteredClientsStoreInterface $clientStore, + ) => new ProxyProvider( + endpoints: new ProxyEndpoints( + authorizationUrl: $env->get('OAUTH_AUTHORIZATION_URL', 'https://github.com/login/oauth/authorize'), + tokenUrl: $env->get('OAUTH_TOKEN_URL', 'https://github.com/login/oauth/access_token'), + revocationUrl: $env->get('OAUTH_REVOCATION_URL'), + registrationUrl: $env->get('OAUTH_REGISTRATION_URL'), + ), + verifyAccessToken: static fn(string $token) => $tokenVerifier->verifyAccessToken($token), + getClient: static fn(string $clientId) => $clientStore->getClient($clientId), + httpClient: new Client(), + requestFactory: new RequestFactory(), + streamFactory: new StreamFactory(), + ), + + // OAuth Metadata + OAuthMetadata::class => static fn(EnvironmentInterface $env) => new OAuthMetadata( + issuer: $env->get('OAUTH_ISSUER_URL', 'http://127.0.0.1:8090'), + authorizationEndpoint: $env->get('OAUTH_AUTHORIZATION_URL', 'https://github.com/login/oauth/authorize'), + tokenEndpoint: $env->get('OAUTH_TOKEN_URL', 'https://github.com/login/oauth/access_token'), + responseTypesSupported: ['code'], + registrationEndpoint: $env->get('OAUTH_REGISTRATION_URL'), + scopesSupported: ['read', 'write'], + grantTypesSupported: ['authorization_code', 'refresh_token'], + tokenEndpointAuthMethodsSupported: ['client_secret_post', 'client_secret_basic'], + codeChallengeMethodsSupported: ['S256'], + ), + + // OAuth Router + McpAuthRouter::class => static fn( + EnvironmentInterface $env, + OAuthServerProviderInterface $provider, + ) => new McpAuthRouter( + options: new AuthRouterOptions( + provider: $provider, + issuerUrl: $env->get('OAUTH_ISSUER_URL', 'http://127.0.0.1:8090'), + baseUrl: $env->get('OAUTH_SERVER_URL', 'http://127.0.0.1:8090'), + serviceDocumentationUrl: $env->get('OAUTH_SERVICE_DOCUMENTATION_URL'), + scopesSupported: ['read', 'write'], + resourceName: 'MCP Server', + ), + responseFactory: new ResponseFactory(), + streamFactory: new StreamFactory(), + ), + + // Client Authentication Middleware + ClientAuthMiddleware::class => static fn( + OAuthRegisteredClientsStoreInterface $clientStore, + ) => new ClientAuthMiddleware( + clientsStore: $clientStore, + responseFactory: new ResponseFactory(), + streamFactory: new StreamFactory(), + ), + ]; + } + + public function boot( + MiddlewareRegistryInterface $registry, + EnvironmentInterface $env, + McpAuthRouter $oauthRouter, + OAuthServerProviderInterface $oauthProvider, + ): void { $convertValues = static fn( string|null|bool $values, ) => \is_string($values) ? \array_map(\trim(...), \explode(',', $values)) : []; @@ -36,5 +139,23 @@ public function boot(MiddlewareRegistryInterface $registry, EnvironmentInterface trustProxy: (bool) $env->get('MCP_TRUST_PROXY', true), ), ); + + // Register OAuth router middleware if enabled + if ((bool) $env->get('OAUTH_ENABLED', false)) { + $registry->register($oauthRouter); + } + } + + /** + * @return non-empty-string + */ + private function getPublicKey(): string + { + return << Date: Mon, 29 Sep 2025 18:19:46 +0400 Subject: [PATCH 6/8] refactor: replace console logger with file logger --- .gitignore | 2 +- src/Application/Logger/LoggerFactory.php | 19 +++++++++++++----- src/Console/BaseCommand.php | 6 ++++++ src/McpServer/Console/MCPServerCommand.php | 23 ++++------------------ 4 files changed, 25 insertions(+), 25 deletions(-) diff --git a/.gitignore b/.gitignore index 38dd47a6..0d275ede 100644 --- a/.gitignore +++ b/.gitignore @@ -13,5 +13,5 @@ node_modules .templates .researches runtime -mcp-*.log +ctx-*.log .env diff --git a/src/Application/Logger/LoggerFactory.php b/src/Application/Logger/LoggerFactory.php index c9544883..e619c02c 100644 --- a/src/Application/Logger/LoggerFactory.php +++ b/src/Application/Logger/LoggerFactory.php @@ -4,14 +4,17 @@ namespace Butschster\ContextGenerator\Application\Logger; +use Butschster\ContextGenerator\Application\FSPath; +use Monolog\Level; use Psr\Log\LoggerInterface; +use Symfony\Component\Console\Output\NullOutput; use Symfony\Component\Console\Output\OutputInterface; final class LoggerFactory { public static function create( + FSPath $logsPath, ?OutputInterface $output = null, - ?FormatterInterface $formatter = null, bool $loggingEnabled = true, ): LoggerInterface { // If logging is disabled, return a NullLogger @@ -21,13 +24,19 @@ public static function create( // If no output is provided, return a NullLogger if ($output === null) { - return new NullLogger(); + $output = new NullOutput(); } // Create the output logger with the formatter - return new ConsoleLogger( - output: $output, - formatter: $formatter ?? new SimpleFormatter(), + return new FileLogger( + name: 'ctx', + filePath: (string) $logsPath->join('ctx.log'), + level: match (true) { + $output->isVeryVerbose() => Level::Debug, + $output->isVerbose() => Level::Info, + $output->isQuiet() => Level::Error, + default => Level::Debug, + }, ); } } diff --git a/src/Console/BaseCommand.php b/src/Console/BaseCommand.php index 9b3c936f..f744cb05 100644 --- a/src/Console/BaseCommand.php +++ b/src/Console/BaseCommand.php @@ -6,6 +6,7 @@ use Butschster\ContextGenerator\Application\Logger\HasPrefixLoggerInterface; use Butschster\ContextGenerator\Application\Logger\LoggerFactory; +use Butschster\ContextGenerator\DirectoriesInterface; use Psr\Log\LoggerInterface; use Spiral\Console\Command; use Spiral\Core\BinderInterface; @@ -30,7 +31,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->input = $input; $this->output = $output; + $logsPath = $this->container + ->get(DirectoriesInterface::class) + ->getRootPath(); + $logger = LoggerFactory::create( + logsPath: $logsPath, output: $output, loggingEnabled: $output->isVerbose() || $output->isDebug() || $output->isVeryVerbose(), ); diff --git a/src/McpServer/Console/MCPServerCommand.php b/src/McpServer/Console/MCPServerCommand.php index 5bd318b1..3c42c212 100644 --- a/src/McpServer/Console/MCPServerCommand.php +++ b/src/McpServer/Console/MCPServerCommand.php @@ -6,7 +6,6 @@ use Butschster\ContextGenerator\Application\Application; use Butschster\ContextGenerator\Application\AppScope; -use Butschster\ContextGenerator\Application\Logger\FileLogger; use Butschster\ContextGenerator\Application\Logger\HasPrefixLoggerInterface; use Butschster\ContextGenerator\Config\ConfigurationProvider; use Butschster\ContextGenerator\Config\Exception\ConfigLoaderException; @@ -17,7 +16,7 @@ use Butschster\ContextGenerator\McpServer\ServerRunnerInterface; use Butschster\ContextGenerator\McpServer\Tool\Command\CommandExecutor; use Butschster\ContextGenerator\McpServer\Tool\Command\CommandExecutorInterface; -use Monolog\Level; +use Psr\Log\LoggerInterface; use Spiral\Console\Attribute\Option; use Spiral\Core\Container; use Spiral\Core\Scope; @@ -68,28 +67,13 @@ public function __invoke( ->determineRootPath($this->configPath) ->withEnvFile($this->envFileName); - $logger = new FileLogger( - name: 'mcp', - filePath: (string) $dirs->getRootPath()->join('mcp.log'), - level: match (true) { - $this->output->isVeryVerbose() => Level::Debug, - $this->output->isVerbose() => Level::Info, - $this->output->isQuiet() => Level::Error, - default => Level::Warning, - }, - ); - $binder = $container->getBinder('root'); - $binder->bind( - HasPrefixLoggerInterface::class, - $logger, - ); $binder->bind( DirectoriesInterface::class, $dirs, ); - $logger->info('Starting MCP server...'); + $this->logger->info('Starting MCP server...'); return $container->runScope( bindings: new Scope( @@ -100,7 +84,8 @@ public function __invoke( scope: static function ( Container $container, ConfigurationProvider $configProvider, - ) use ($logger, $dirs, $app) { + LoggerInterface $logger, + ) use ($dirs, $app) { $rootPathStr = (string) $dirs->getRootPath(); $logger->info(\sprintf('Using root path: %s', $rootPathStr)); From f8273382520d93b4c1bb5a244d8487bc072507c1 Mon Sep 17 00:00:00 2001 From: butschster Date: Mon, 29 Sep 2025 18:20:54 +0400 Subject: [PATCH 7/8] feat: add MCP server middlewares for authentication, logging, and exception handling --- src/Application/AppScope.php | 1 + src/McpServer/Middleware/AuthMiddleware.php | 39 +++++++++++++++++++ .../Middleware/ExceptionHandlerMiddleware.php | 30 ++++++++++++++ src/McpServer/Middleware/LoggerMiddleware.php | 35 +++++++++++++++++ 4 files changed, 105 insertions(+) create mode 100644 src/McpServer/Middleware/AuthMiddleware.php create mode 100644 src/McpServer/Middleware/ExceptionHandlerMiddleware.php create mode 100644 src/McpServer/Middleware/LoggerMiddleware.php diff --git a/src/Application/AppScope.php b/src/Application/AppScope.php index bed90e8c..7a2c425f 100644 --- a/src/Application/AppScope.php +++ b/src/Application/AppScope.php @@ -8,6 +8,7 @@ enum AppScope: string { case Compiler = 'compiler'; case Mcp = 'mcp'; + case McpOauth = 'mcp-oauth'; case McpServer = 'mcp-server'; case McpServerRequest = 'mcp-server-request'; } diff --git a/src/McpServer/Middleware/AuthMiddleware.php b/src/McpServer/Middleware/AuthMiddleware.php new file mode 100644 index 00000000..1358c638 --- /dev/null +++ b/src/McpServer/Middleware/AuthMiddleware.php @@ -0,0 +1,39 @@ +getAttribute('auth'); + + return $this->scope->runScope( + bindings: new Scope( + name: AppScope::McpOauth, + bindings: [ + UserProviderInterface::class => new InMemoryUserProvider($auth), + ], + ), + scope: static fn() => $handler->handle($request), + ); + } +} diff --git a/src/McpServer/Middleware/ExceptionHandlerMiddleware.php b/src/McpServer/Middleware/ExceptionHandlerMiddleware.php new file mode 100644 index 00000000..c6b3f95d --- /dev/null +++ b/src/McpServer/Middleware/ExceptionHandlerMiddleware.php @@ -0,0 +1,30 @@ +handle($request); + } catch (\Throwable $e) { + $this->reporter->report($e); + throw $e; + } + } +} diff --git a/src/McpServer/Middleware/LoggerMiddleware.php b/src/McpServer/Middleware/LoggerMiddleware.php new file mode 100644 index 00000000..3cefbecf --- /dev/null +++ b/src/McpServer/Middleware/LoggerMiddleware.php @@ -0,0 +1,35 @@ +logger->debug('Request received', [ + 'request' => $request, + ]); + + $response = $handler->handle($request); + + $this->logger->debug('Response sent', [ + 'response' => $response, + ]); + + return $response; + } +} From 1c9eb305f127936c3d8f2b03f484653f186d5a9a Mon Sep 17 00:00:00 2001 From: butschster Date: Mon, 29 Sep 2025 18:22:45 +0400 Subject: [PATCH 8/8] refactor: update HttpTransportBootloader --- src/McpServer/HttpTransportBootloader.php | 182 ++++++++++++++-------- 1 file changed, 121 insertions(+), 61 deletions(-) diff --git a/src/McpServer/HttpTransportBootloader.php b/src/McpServer/HttpTransportBootloader.php index 2423a82a..0667ce99 100644 --- a/src/McpServer/HttpTransportBootloader.php +++ b/src/McpServer/HttpTransportBootloader.php @@ -4,6 +4,9 @@ namespace Butschster\ContextGenerator\McpServer; +use Butschster\ContextGenerator\McpServer\Middleware\AuthMiddleware; +use Butschster\ContextGenerator\McpServer\Middleware\ExceptionHandlerMiddleware; +use Butschster\ContextGenerator\McpServer\Middleware\LoggerMiddleware; use GuzzleHttp\Client; use Laminas\Diactoros\RequestFactory; use Laminas\Diactoros\ResponseFactory; @@ -13,13 +16,20 @@ use Mcp\Server\Authentication\Contract\OAuthTokenVerifierInterface; use Mcp\Server\Authentication\Dto\OAuthClientInformation; use Mcp\Server\Authentication\Dto\OAuthMetadata; -use Mcp\Server\Authentication\Middleware\ClientAuthMiddleware; +use Mcp\Server\Authentication\Dto\OAuthProtectedResourceMetadata; +use Mcp\Server\Authentication\Handler\AuthorizeHandler; +use Mcp\Server\Authentication\Handler\MetadataHandler; +use Mcp\Server\Authentication\Handler\RegisterHandler; +use Mcp\Server\Authentication\Handler\RevokeHandler; +use Mcp\Server\Authentication\Handler\TokenHandler; +use Mcp\Server\Authentication\Provider\GenericTokenVerifier; use Mcp\Server\Authentication\Provider\ProxyEndpoints; use Mcp\Server\Authentication\Provider\ProxyProvider; +use Mcp\Server\Authentication\Provider\TokenIntrospectionClient; +use Mcp\Server\Authentication\Provider\TokenIntrospectionConfig; use Mcp\Server\Authentication\Router\AuthRouterOptions; use Mcp\Server\Authentication\Router\McpAuthRouter; use Mcp\Server\Authentication\Storage\InMemoryClientRepository; -use Mcp\Server\Authentication\Storage\JwtTokenValidator; use Mcp\Server\Transports\Middleware\CorsMiddleware; use Mcp\Server\Transports\Middleware\ProxyAwareMiddleware; use Spiral\Boot\Bootloader\Bootloader; @@ -28,25 +38,37 @@ final class HttpTransportBootloader extends Bootloader { + #[\Override] public function defineSingletons(): array { + $requestFactory = new RequestFactory(); + $streamFactory = new StreamFactory(); + $responseFactory = new ResponseFactory(); + $httpClient = new Client(); + return [ // OAuth Token Validator - OAuthTokenVerifierInterface::class => fn() => new JwtTokenValidator( - publicKey: $this->getPublicKey(), - algorithm: 'RS256', + OAuthTokenVerifierInterface::class => static fn() => new GenericTokenVerifier( + config: TokenIntrospectionConfig::forGitHub(), + client: new TokenIntrospectionClient( + httpClient: $httpClient, + requestFactory: $requestFactory, + streamFactory: $streamFactory, + ), ), OAuthRegisteredClientsStoreInterface::class => static function ( EnvironmentInterface $env, ) { $store = new InMemoryClientRepository(); - $store->registerClient(new OAuthClientInformation( - clientId: $env->get('OAUTH_CLIENT_ID'), - clientSecret: $env->get('OAUTH_CLIENT_SECRET'), - clientIdIssuedAt: \time(), - clientSecretExpiresAt: null, // Never expires - )); + $store->registerClient( + new OAuthClientInformation( + clientId: $env->get('OAUTH_CLIENT_ID'), + clientSecret: $env->get('OAUTH_CLIENT_SECRET'), + clientIdIssuedAt: \time(), + clientSecretExpiresAt: null, // Never expires + ), + ); return $store; }, @@ -56,57 +78,101 @@ public function defineSingletons(): array EnvironmentInterface $env, OAuthTokenVerifierInterface $tokenVerifier, OAuthRegisteredClientsStoreInterface $clientStore, + OAuthMetadata $authMetadata, ) => new ProxyProvider( endpoints: new ProxyEndpoints( - authorizationUrl: $env->get('OAUTH_AUTHORIZATION_URL', 'https://github.com/login/oauth/authorize'), - tokenUrl: $env->get('OAUTH_TOKEN_URL', 'https://github.com/login/oauth/access_token'), - revocationUrl: $env->get('OAUTH_REVOCATION_URL'), - registrationUrl: $env->get('OAUTH_REGISTRATION_URL'), + authorizationUrl: $authMetadata->authorizationEndpoint, + tokenUrl: $authMetadata->tokenEndpoint, + revocationUrl: $authMetadata->revocationEndpoint, + registrationUrl: $authMetadata->registrationEndpoint, ), verifyAccessToken: static fn(string $token) => $tokenVerifier->verifyAccessToken($token), getClient: static fn(string $clientId) => $clientStore->getClient($clientId), - httpClient: new Client(), - requestFactory: new RequestFactory(), - streamFactory: new StreamFactory(), + httpClient: $httpClient, + requestFactory: $requestFactory, + streamFactory: $streamFactory, + ), + + AuthRouterOptions::class => static fn( + EnvironmentInterface $env, + ) => new AuthRouterOptions( + issuerUrl: $env->get('OAUTH_ISSUER_URL', 'http://127.0.0.1:8090'), + baseUrl: $env->get('OAUTH_SERVER_URL', 'http://127.0.0.1:8090'), + resourceName: 'CTX MCP Server', ), // OAuth Metadata - OAuthMetadata::class => static fn(EnvironmentInterface $env) => new OAuthMetadata( - issuer: $env->get('OAUTH_ISSUER_URL', 'http://127.0.0.1:8090'), - authorizationEndpoint: $env->get('OAUTH_AUTHORIZATION_URL', 'https://github.com/login/oauth/authorize'), - tokenEndpoint: $env->get('OAUTH_TOKEN_URL', 'https://github.com/login/oauth/access_token'), - responseTypesSupported: ['code'], - registrationEndpoint: $env->get('OAUTH_REGISTRATION_URL'), - scopesSupported: ['read', 'write'], - grantTypesSupported: ['authorization_code', 'refresh_token'], - tokenEndpointAuthMethodsSupported: ['client_secret_post', 'client_secret_basic'], - codeChallengeMethodsSupported: ['S256'], + OAuthMetadata::class => static fn( + AuthRouterOptions $options, + ) => OAuthMetadata::forGithub( + issuer: $options->issuerUrl, ), - // OAuth Router - McpAuthRouter::class => static fn( - EnvironmentInterface $env, + AuthorizeHandler::class => static fn( OAuthServerProviderInterface $provider, - ) => new McpAuthRouter( - options: new AuthRouterOptions( - provider: $provider, - issuerUrl: $env->get('OAUTH_ISSUER_URL', 'http://127.0.0.1:8090'), - baseUrl: $env->get('OAUTH_SERVER_URL', 'http://127.0.0.1:8090'), - serviceDocumentationUrl: $env->get('OAUTH_SERVICE_DOCUMENTATION_URL'), - scopesSupported: ['read', 'write'], - resourceName: 'MCP Server', - ), - responseFactory: new ResponseFactory(), - streamFactory: new StreamFactory(), + ) => new AuthorizeHandler( + provider: $provider, + responseFactory: $responseFactory, + streamFactory: $streamFactory, ), - - // Client Authentication Middleware - ClientAuthMiddleware::class => static fn( + RegisterHandler::class => static fn( OAuthRegisteredClientsStoreInterface $clientStore, - ) => new ClientAuthMiddleware( + ) => new RegisterHandler( clientsStore: $clientStore, - responseFactory: new ResponseFactory(), - streamFactory: new StreamFactory(), + responseFactory: $responseFactory, + streamFactory: $streamFactory, + ), + TokenHandler::class => static fn( + OAuthServerProviderInterface $provider, + OAuthRegisteredClientsStoreInterface $clientStore, + ) => new TokenHandler( + provider: $provider, + responseFactory: $responseFactory, + streamFactory: $streamFactory, + ), + RevokeHandler::class => static fn( + OAuthServerProviderInterface $provider, + ) => new RevokeHandler( + provider: $provider, + responseFactory: $responseFactory, + streamFactory: $streamFactory, + ), + MetadataHandler::class => static fn( + AuthRouterOptions $options, + OAuthMetadata $oauthMetadata, + ) => new MetadataHandler( + oauthMetadata: $oauthMetadata, + protectedResourceMetadata: new OAuthProtectedResourceMetadata( + resource: $options->baseUrl ?? $oauthMetadata->issuer, + authorizationServers: [$oauthMetadata->issuer], + jwksUri: null, + scopesSupported: empty($options->scopesSupported) ? null : $options->scopesSupported, + bearerMethodsSupported: null, + resourceSigningAlgValuesSupported: null, + resourceName: $options->resourceName, + resourceDocumentation: $options->serviceDocumentationUrl, + ), + responseFactory: $responseFactory, + streamFactory: $streamFactory, + ), + + // OAuth Router + McpAuthRouter::class => static fn( + AuthorizeHandler $authorizeHandler, + RegisterHandler $registerHandler, + TokenHandler $tokenHandler, + RevokeHandler $revokeHandler, + MetadataHandler $metadataHandler, + OAuthTokenVerifierInterface $tokenVerifier, + ) => new McpAuthRouter( + authorizeHandler: $authorizeHandler, + registerHandler: $registerHandler, + tokenHandler: $tokenHandler, + revokeHandler: $revokeHandler, + metadataHandler: $metadataHandler, + responseFactory: $responseFactory, + streamFactory: $streamFactory, + tokenVerifier: $tokenVerifier, ), ]; } @@ -115,7 +181,9 @@ public function boot( MiddlewareRegistryInterface $registry, EnvironmentInterface $env, McpAuthRouter $oauthRouter, - OAuthServerProviderInterface $oauthProvider, + ExceptionHandlerMiddleware $exceptionHandler, + LoggerMiddleware $logger, + AuthMiddleware $authMiddleware, ): void { $convertValues = static fn( string|null|bool $values, @@ -126,6 +194,9 @@ public function boot( $allowedMethods = $convertValues($env->get('MCP_CORS_ALLOWED_METHODS', 'GET,POST,PUT,DELETE,OPTIONS')); $allowedHeaders = $convertValues($env->get('MCP_CORS_ALLOWED_HEADERS', 'Content-Type,Authorization')); + $registry->register($logger); + $registry->register($exceptionHandler); + $registry->register( new CorsMiddleware( allowedOrigins: $allowedOrigins, @@ -144,18 +215,7 @@ public function boot( if ((bool) $env->get('OAUTH_ENABLED', false)) { $registry->register($oauthRouter); } - } - /** - * @return non-empty-string - */ - private function getPublicKey(): string - { - return <<register($authMiddleware); } }