Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build-linux-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,5 @@ node_modules
.templates
.researches
runtime
mcp-*.log
ctx-*.log
.env
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/Application/AppScope.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
19 changes: 14 additions & 5 deletions src/Application/Logger/LoggerFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
},
);
}
}
6 changes: 6 additions & 0 deletions src/Console/BaseCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(),
);
Expand Down
1 change: 1 addition & 0 deletions src/McpServer/ActionsBootloader.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ public function defineDependencies(): array
{
return [
McpServerBootloader::class,
HttpTransportBootloader::class,
];
}

Expand Down
23 changes: 4 additions & 19 deletions src/McpServer/Console/MCPServerCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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(
Expand All @@ -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));

Expand Down
221 changes: 221 additions & 0 deletions src/McpServer/HttpTransportBootloader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
<?php

declare(strict_types=1);

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;
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\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\Transports\Middleware\CorsMiddleware;
use Mcp\Server\Transports\Middleware\ProxyAwareMiddleware;
use Spiral\Boot\Bootloader\Bootloader;
use Spiral\Boot\EnvironmentInterface;
use Spiral\McpServer\MiddlewareRegistryInterface;

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 => 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
),
);

return $store;
},

// OAuth Server Provider - using proxy to external OAuth server
OAuthServerProviderInterface::class => static fn(
EnvironmentInterface $env,
OAuthTokenVerifierInterface $tokenVerifier,
OAuthRegisteredClientsStoreInterface $clientStore,
OAuthMetadata $authMetadata,
) => new ProxyProvider(
endpoints: new ProxyEndpoints(
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: $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(
AuthRouterOptions $options,
) => OAuthMetadata::forGithub(
issuer: $options->issuerUrl,
),

AuthorizeHandler::class => static fn(
OAuthServerProviderInterface $provider,
) => new AuthorizeHandler(
provider: $provider,
responseFactory: $responseFactory,
streamFactory: $streamFactory,
),
RegisterHandler::class => static fn(
OAuthRegisteredClientsStoreInterface $clientStore,
) => new RegisterHandler(
clientsStore: $clientStore,
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,
),
];
}

public function boot(
MiddlewareRegistryInterface $registry,
EnvironmentInterface $env,
McpAuthRouter $oauthRouter,
ExceptionHandlerMiddleware $exceptionHandler,
LoggerMiddleware $logger,
AuthMiddleware $authMiddleware,
): void {
$convertValues = static fn(
string|null|bool $values,
) => \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($logger);
$registry->register($exceptionHandler);

$registry->register(
new CorsMiddleware(
allowedOrigins: $allowedOrigins,
allowedMethods: $allowedMethods,
allowedHeaders: $allowedHeaders,
),
);

$registry->register(
new ProxyAwareMiddleware(
trustProxy: (bool) $env->get('MCP_TRUST_PROXY', true),
),
);

// Register OAuth router middleware if enabled
if ((bool) $env->get('OAUTH_ENABLED', false)) {
$registry->register($oauthRouter);
}

$registry->register($authMiddleware);
}
}
Loading
Loading