diff --git a/config/mcp.php b/config/mcp.php index 5822ddf..8a482a8 100644 --- a/config/mcp.php +++ b/config/mcp.php @@ -98,7 +98,7 @@ 'legacy' => (bool) env('MCP_HTTP_INTEGRATED_LEGACY', false), 'route_prefix' => env('MCP_HTTP_INTEGRATED_ROUTE_PREFIX', 'mcp'), 'stateless' => (bool) env('MCP_HTTP_INTEGRATED_STATELESS', false), - 'middleware' => ['api'], + 'middleware' => ['api', 'auth:sanctum'], 'domain' => env('MCP_HTTP_INTEGRATED_DOMAIN'), 'sse_poll_interval' => (int) env('MCP_HTTP_INTEGRATED_SSE_POLL_SECONDS', 1), 'cors_origin' => env('MCP_HTTP_INTEGRATED_CORS_ORIGIN', '*'), diff --git a/examples/AuthenticatedTools.php b/examples/AuthenticatedTools.php new file mode 100644 index 0000000..55ab014 --- /dev/null +++ b/examples/AuthenticatedTools.php @@ -0,0 +1,309 @@ + 'Authentication required', + 'message' => 'Please include a valid Authorization header with Bearer token', + ]; + } + + return [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'created_at' => $user->created_at, + ]; +}); + +/** + * Authentication debugging tool + * Useful for troubleshooting authentication issues + */ +Mcp::tool('debug_auth_context', function () { + return [ + 'mcp_auth' => [ + 'authenticated' => McpAuth::check(), + 'user_id' => McpAuth::user()?->id, + 'user_name' => McpAuth::user()?->name, + 'guard' => McpAuth::guard(), + 'has_token' => McpAuth::token() !== null, + 'token_type' => McpAuth::token() ? get_class(McpAuth::token()) : null, + ], + 'laravel_auth' => [ + 'authenticated' => Auth::check(), + 'user_id' => Auth::user()?->id, + 'user_name' => Auth::user()?->name, + 'default_guard' => Auth::getDefaultDriver(), + ], + 'request_headers' => [ + 'authorization' => McpAuth::header('Authorization') ? 'Present' : 'Missing', + 'session_id' => McpAuth::header('Mcp-Session-Id'), + 'all_headers' => array_keys(McpAuth::headers()), + ], + ]; +}); + +/** + * Protected action requiring authentication + * Demonstrates error handling for unauthenticated requests + */ +Mcp::tool('protected_action', function () { + $user = McpAuth::user() ?? Auth::user(); + + if (!$user) { + throw new \Exception('Authentication required to perform this action'); + } + + // Perform some protected operation + return [ + 'success' => true, + 'message' => "Protected action performed successfully by {$user->name}", + 'timestamp' => now()->toISOString(), + 'user_id' => $user->id, + ]; +}); + +/** + * Sanctum token-specific operations + * Shows how to work with Sanctum tokens and abilities + */ +Mcp::tool('check_token_abilities', function () { + $user = McpAuth::user() ?? Auth::user(); + + if (!$user) { + return ['error' => 'Authentication required']; + } + + $token = McpAuth::token(); + + if (!$token) { + return [ + 'user' => $user->name, + 'auth_method' => 'session_based', + 'message' => 'No token found, likely using session-based authentication', + ]; + } + + $abilities = []; + if (method_exists($token, 'abilities')) { + $abilities = $token->abilities; + } + + return [ + 'user' => $user->name, + 'token_name' => $token->name ?? 'Unknown', + 'token_abilities' => $abilities, + 'can_admin' => method_exists($token, 'can') ? $token->can('admin') : false, + 'can_read' => method_exists($token, 'can') ? $token->can('read') : false, + 'can_write' => method_exists($token, 'can') ? $token->can('write') : false, + 'token_created' => $token->created_at ?? null, + 'token_last_used' => $token->last_used_at ?? null, + ]; +}); + +/** + * User-specific resource access + * Shows how to filter data based on authenticated user + */ +Mcp::tool('get_user_posts', function (int $limit = 10) { + $user = McpAuth::user() ?? Auth::user(); + + if (!$user) { + return ['error' => 'Authentication required']; + } + + // Assuming you have a Post model with user relationship + // This is just a demonstration - adjust based on your models + try { + $posts = $user->posts() + ->latest() + ->limit($limit) + ->get() + ->map(function ($post) { + return [ + 'id' => $post->id, + 'title' => $post->title, + 'excerpt' => substr($post->content, 0, 100) . '...', + 'created_at' => $post->created_at, + 'updated_at' => $post->updated_at, + ]; + }); + + return [ + 'user' => $user->name, + 'posts_count' => $posts->count(), + 'posts' => $posts, + ]; + } catch (\Exception $e) { + return [ + 'user' => $user->name, + 'message' => 'Posts feature not available in this demo', + 'error' => $e->getMessage(), + ]; + } +}); + +/** + * Multi-guard authentication example + * Shows how to handle different authentication guards + */ +Mcp::tool('check_multi_guard_auth', function () { + $results = []; + + // Check MCP context first + if (McpAuth::check()) { + $results['mcp'] = [ + 'authenticated' => true, + 'user' => McpAuth::user()->name, + 'guard' => McpAuth::guard(), + ]; + } + + // Check different Laravel guards + $guards = ['web', 'api', 'sanctum']; + + foreach ($guards as $guard) { + try { + if (Auth::guard($guard)->check()) { + $results[$guard] = [ + 'authenticated' => true, + 'user' => Auth::guard($guard)->user()->name, + ]; + } else { + $results[$guard] = ['authenticated' => false]; + } + } catch (\Exception $e) { + $results[$guard] = [ + 'authenticated' => false, + 'error' => $e->getMessage(), + ]; + } + } + + return $results; +}); + +/** + * Permission-based access control + * Demonstrates how to implement permission checks + */ +Mcp::tool('admin_only_action', function () { + $user = McpAuth::user() ?? Auth::user(); + + if (!$user) { + return ['error' => 'Authentication required']; + } + + // Check if user has admin role (adjust based on your role system) + $isAdmin = false; + + // Example permission checks - adjust based on your implementation + if (method_exists($user, 'hasRole')) { + $isAdmin = $user->hasRole('admin'); + } elseif (method_exists($user, 'can')) { + $isAdmin = $user->can('admin-access'); + } elseif (isset($user->role)) { + $isAdmin = $user->role === 'admin'; + } + + // Also check token abilities for Sanctum + $token = McpAuth::token(); + if ($token && method_exists($token, 'can')) { + $isAdmin = $isAdmin && $token->can('admin'); + } + + if (!$isAdmin) { + return [ + 'error' => 'Insufficient permissions', + 'message' => 'This action requires administrator privileges', + 'user' => $user->name, + ]; + } + + return [ + 'success' => true, + 'message' => 'Admin action performed successfully', + 'user' => $user->name, + 'timestamp' => now()->toISOString(), + ]; +}); + +/** + * Resource with authentication context + * Shows how to use authentication in resources + */ +Mcp::resource('user://profile', function () { + $user = McpAuth::user() ?? Auth::user(); + + if (!$user) { + return json_encode(['error' => 'Authentication required']); + } + + return json_encode([ + 'profile' => [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'created_at' => $user->created_at, + 'updated_at' => $user->updated_at, + ], + 'auth_context' => [ + 'guard' => McpAuth::guard() ?? Auth::getDefaultDriver(), + 'authenticated_via' => McpAuth::check() ? 'mcp_context' : 'laravel_auth', + ], + ]); +})->name('user_profile')->mimeType('application/json'); + +/** + * Dynamic resource template with user context + * Shows parameterized resources with authentication + */ +Mcp::resourceTemplate('user://data/{dataType}', function (string $dataType) { + $user = McpAuth::user() ?? Auth::user(); + + if (!$user) { + return json_encode(['error' => 'Authentication required']); + } + + $data = match ($dataType) { + 'basic' => [ + 'id' => $user->id, + 'name' => $user->name, + ], + 'contact' => [ + 'email' => $user->email, + 'email_verified' => $user->email_verified_at !== null, + ], + 'timestamps' => [ + 'created_at' => $user->created_at, + 'updated_at' => $user->updated_at, + ], + default => ['error' => "Unknown data type: {$dataType}"], + }; + + return json_encode([ + 'user_id' => $user->id, + 'data_type' => $dataType, + 'data' => $data, + 'retrieved_at' => now()->toISOString(), + ]); +})->name('user_data')->mimeType('application/json'); diff --git a/samples/basic/routes/mcp.php b/samples/basic/routes/mcp.php index fc88880..832f3bb 100644 --- a/samples/basic/routes/mcp.php +++ b/samples/basic/routes/mcp.php @@ -5,6 +5,7 @@ use App\Mcp\GetArticleContent; use App\Mcp\GetAppVersion; use PhpMcp\Laravel\Facades\Mcp; +use PhpMcp\Laravel\Facades\McpAuth; use Illuminate\Support\Facades\Auth; Mcp::tool('welcome_message', GenerateWelcomeMessage::class); @@ -14,7 +15,23 @@ ->mimeType('text/plain'); Mcp::tool('get_me', function () { - return Auth::user(); + // Try MCP context first (for dedicated HTTP), fallback to Laravel Auth + $user = McpAuth::user() ?? Auth::user(); + + if (!$user) { + return [ + 'error' => 'No authenticated user found', + 'context' => 'Make sure to include Authorization header with Bearer token', + 'mcp_context' => McpAuth::check() ? 'MCP context available' : 'No MCP context', + 'auth_context' => Auth::check() ? 'Laravel auth available' : 'No Laravel auth', + ]; + } + + return [ + 'user' => $user, + 'guard' => McpAuth::guard() ?? Auth::getDefaultDriver(), + 'auth_method' => McpAuth::check() ? 'mcp_context' : 'laravel_auth', + ]; }); Mcp::resourceTemplate('content://articles/{articleId}', GetArticleContent::class) diff --git a/src/Facades/McpAuth.php b/src/Facades/McpAuth.php new file mode 100644 index 0000000..25985e0 --- /dev/null +++ b/src/Facades/McpAuth.php @@ -0,0 +1,102 @@ +transport = new HttpServerTransport($server->getSessionManager()); $server->listen($this->transport, false); @@ -31,7 +32,9 @@ public function __construct(Server $server) */ public function handleMessage(Request $request): Response { - return $this->transport->handleMessageRequest($request); + return $this->authMiddleware->handle($request, function ($request) { + return $this->transport->handleMessageRequest($request); + }); } /** @@ -40,6 +43,8 @@ public function handleMessage(Request $request): Response */ public function handleSse(Request $request): StreamedResponse { - return $this->transport->handleSseRequest($request); + return $this->authMiddleware->handle($request, function ($request) { + return $this->transport->handleSseRequest($request); + }); } } diff --git a/src/Http/Controllers/StreamableTransportController.php b/src/Http/Controllers/StreamableTransportController.php index becc601..13d8d1a 100644 --- a/src/Http/Controllers/StreamableTransportController.php +++ b/src/Http/Controllers/StreamableTransportController.php @@ -5,6 +5,7 @@ namespace PhpMcp\Laravel\Http\Controllers; use Illuminate\Http\Request; +use PhpMcp\Laravel\Http\Middleware\McpAuthenticationMiddleware; use PhpMcp\Laravel\Transports\StreamableHttpServerTransport; use PhpMcp\Server\Contracts\EventStoreInterface; use PhpMcp\Server\Server; @@ -15,7 +16,7 @@ class StreamableTransportController { private StreamableHttpServerTransport $transport; - public function __construct(Server $server) + public function __construct(Server $server, protected McpAuthenticationMiddleware $authMiddleware) { $eventStore = $this->createEventStore(); $sessionManager = $server->getSessionManager(); @@ -27,17 +28,23 @@ public function __construct(Server $server) public function handleGet(Request $request): Response|StreamedResponse { - return $this->transport->handleGetRequest($request); + return $this->authMiddleware->handle($request, function ($request) { + return $this->transport->handleGetRequest($request); + }); } public function handlePost(Request $request): Response|StreamedResponse { - return $this->transport->handlePostRequest($request); + return $this->authMiddleware->handle($request, function ($request) { + return $this->transport->handlePostRequest($request); + }); } public function handleDelete(Request $request): Response { - return $this->transport->handleDeleteRequest($request); + return $this->authMiddleware->handle($request, function ($request) { + return $this->transport->handleDeleteRequest($request); + }); } /** diff --git a/src/Http/Middleware/McpAuthenticationMiddleware.php b/src/Http/Middleware/McpAuthenticationMiddleware.php new file mode 100644 index 0000000..48ad5fe --- /dev/null +++ b/src/Http/Middleware/McpAuthenticationMiddleware.php @@ -0,0 +1,174 @@ +extractAuthContext($request); + + // Store in MCP context for later access by tools + McpContext::setAuthContext($authContext); + + // Log for debugging purposes + Log::debug('MCP Authentication context set', [ + 'has_user' => !empty($authContext['user']), + 'auth_guard' => $authContext['guard'] ?? null, + 'session_id' => $request->header('Mcp-Session-Id'), + ]); + + $response = $next($request); + + // Clear the context after request processing + McpContext::clearAuthContext(); + + return $response; + } + + /** + * Extract authentication context from the request. + * + * @param \Illuminate\Http\Request $request + * @return array + */ + protected function extractAuthContext(Request $request): array + { + $context = [ + 'request_headers' => $this->getRelevantHeaders($request), + 'user' => null, + 'guard' => null, + 'token' => null, + ]; + + // Try different authentication guards + foreach ($this->getAuthGuards() as $guard) { + if (Auth::guard($guard)->check()) { + $user = Auth::guard($guard)->user(); + $context['user'] = $user; + $context['guard'] = $guard; + + // For Sanctum, also store the token + if ($guard === 'sanctum' && method_exists($user, 'currentAccessToken')) { + $context['token'] = $user->currentAccessToken(); + } + + break; + } + } + + // If no authenticated user found, try to authenticate using Bearer token + if (!$context['user'] && $request->bearerToken()) { + $context = $this->attemptTokenAuthentication($request, $context); + } + + return $context; + } + + /** + * Get relevant headers for authentication context. + * + * @param \Illuminate\Http\Request $request + * @return array + */ + protected function getRelevantHeaders(Request $request): array + { + $headers = []; + + $relevantHeaders = [ + 'Authorization', + 'X-API-KEY', + 'X-Auth-Token', + 'Cookie', + 'Mcp-Session-Id', + ]; + + foreach ($relevantHeaders as $header) { + if ($request->hasHeader($header)) { + $headers[$header] = $request->header($header); + } + } + + return $headers; + } + + /** + * Get the authentication guards to try. + * + * @return array + */ + protected function getAuthGuards(): array + { + return [ + 'sanctum', + 'api', + 'web', + config('auth.defaults.guard'), + ]; + } + + /** + * Attempt to authenticate using a bearer token. + * + * @param \Illuminate\Http\Request $request + * @param array $context + * @return array + */ + protected function attemptTokenAuthentication(Request $request, array $context): array + { + $token = $request->bearerToken(); + + if (!$token) { + return $context; + } + + // Try Sanctum token authentication + if (class_exists(\Laravel\Sanctum\PersonalAccessToken::class)) { + try { + $accessToken = \Laravel\Sanctum\PersonalAccessToken::findToken($token); + + if ($accessToken && $accessToken->can('*')) { + $user = $accessToken->tokenable; + + if ($user) { + $context['user'] = $user; + $context['guard'] = 'sanctum'; + $context['token'] = $accessToken; + + // Set the authenticated user for this request + Auth::guard('sanctum')->setUser($user); + } + } + } catch (\Exception $e) { + Log::warning('Failed to authenticate with Sanctum token', [ + 'error' => $e->getMessage(), + 'token_prefix' => substr($token, 0, 10) . '...' + ]); + } + } + + return $context; + } +} diff --git a/src/McpServiceProvider.php b/src/McpServiceProvider.php index eb8118e..841de77 100644 --- a/src/McpServiceProvider.php +++ b/src/McpServiceProvider.php @@ -14,7 +14,9 @@ use PhpMcp\Laravel\Events\PromptsListChanged; use PhpMcp\Laravel\Events\ResourcesListChanged; use PhpMcp\Laravel\Events\ToolsListChanged; +use PhpMcp\Laravel\Http\Middleware\McpAuthenticationMiddleware; use PhpMcp\Laravel\Listeners\McpNotificationListener; +use PhpMcp\Laravel\Support\McpContext; use PhpMcp\Schema\ServerCapabilities; use PhpMcp\Server\Registry; use PhpMcp\Server\Server; @@ -32,8 +34,11 @@ public function register(): void $this->mergeConfigFrom(__DIR__ . '/../config/mcp.php', 'mcp'); $this->app->singleton(McpRegistrar::class, fn() => new McpRegistrar()); + $this->app->singleton(McpContext::class); + $this->app->singleton(McpAuthenticationMiddleware::class); $this->app->alias(McpRegistrar::class, 'mcp.registrar'); + $this->app->alias(McpContext::class, 'mcp.context'); } public function boot(): void diff --git a/src/Support/McpContext.php b/src/Support/McpContext.php new file mode 100644 index 0000000..4861513 --- /dev/null +++ b/src/Support/McpContext.php @@ -0,0 +1,176 @@ + + */ + public static function all(): array + { + return [ + 'auth' => static::$authContext, + 'request' => static::$requestContext, + ]; + } +} diff --git a/tests/Feature/AuthenticationTest.php b/tests/Feature/AuthenticationTest.php new file mode 100644 index 0000000..a6d7e29 --- /dev/null +++ b/tests/Feature/AuthenticationTest.php @@ -0,0 +1,201 @@ + 1, + 'name' => 'Test User', + 'email' => 'test@example.com', + ]; + + $authContext = [ + 'user' => $user, + 'guard' => 'sanctum', + 'token' => 'test-token', + 'request_headers' => [ + 'Authorization' => 'Bearer test-token', + ], + ]; + + McpContext::setAuthContext($authContext); + + $this->assertEquals($user, McpContext::user()); + $this->assertTrue(McpContext::check()); + $this->assertEquals('sanctum', McpContext::guard()); + $this->assertEquals('test-token', McpContext::token()); + $this->assertEquals('Bearer test-token', McpContext::header('Authorization')); + } + + /** @test */ + public function it_returns_null_when_no_auth_context(): void + { + $this->assertNull(McpContext::user()); + $this->assertFalse(McpContext::check()); + $this->assertNull(McpContext::guard()); + $this->assertNull(McpContext::token()); + $this->assertEmpty(McpContext::headers()); + } + + /** @test */ + public function it_can_clear_auth_context(): void + { + $user = (object) ['id' => 1, 'name' => 'Test User']; + + McpContext::setAuthContext([ + 'user' => $user, + 'guard' => 'web', + 'request_headers' => [], + ]); + + $this->assertTrue(McpContext::check()); + + McpContext::clearAuthContext(); + + $this->assertFalse(McpContext::check()); + $this->assertNull(McpContext::user()); + } + + /** @test */ + public function mcp_auth_facade_works(): void + { + $user = (object) [ + 'id' => 1, + 'name' => 'Facade User', + ]; + + McpContext::setAuthContext([ + 'user' => $user, + 'guard' => 'api', + 'token' => 'facade-token', + 'request_headers' => [ + 'Authorization' => 'Bearer facade-token', + 'X-Custom-Header' => 'custom-value', + ], + ]); + + $this->assertEquals($user, McpAuth::user()); + $this->assertTrue(McpAuth::check()); + $this->assertEquals('api', McpAuth::guard()); + $this->assertEquals('facade-token', McpAuth::token()); + $this->assertEquals('Bearer facade-token', McpAuth::header('Authorization')); + $this->assertEquals('custom-value', McpAuth::header('X-Custom-Header')); + $this->assertNull(McpAuth::header('Non-Existent-Header')); + $this->assertEquals('default', McpAuth::header('Non-Existent-Header', 'default')); + } + + /** @test */ + public function it_handles_mixed_authentication_scenarios(): void + { + // Simulate Laravel's Auth system having a user + $laravelUser = (object) ['id' => 1, 'name' => 'Laravel User']; + Auth::shouldReceive('user')->andReturn($laravelUser); + Auth::shouldReceive('check')->andReturn(true); + Auth::shouldReceive('getDefaultDriver')->andReturn('web'); + + // But MCP context is empty + $this->assertNull(McpAuth::user()); + $this->assertFalse(McpAuth::check()); + + // Tool should fallback to Laravel auth + $result = $this->simulateGetMeTool(); + + $this->assertEquals($laravelUser, $result['user']); + $this->assertEquals('laravel_auth', $result['auth_method']); + $this->assertEquals('web', $result['guard']); + } + + /** @test */ + public function it_prefers_mcp_context_over_laravel_auth(): void + { + // Set up both authentication contexts + $laravelUser = (object) ['id' => 1, 'name' => 'Laravel User']; + $mcpUser = (object) ['id' => 2, 'name' => 'MCP User']; + + Auth::shouldReceive('user')->andReturn($laravelUser); + Auth::shouldReceive('check')->andReturn(true); + Auth::shouldReceive('getDefaultDriver')->andReturn('web'); + + McpContext::setAuthContext([ + 'user' => $mcpUser, + 'guard' => 'sanctum', + 'token' => 'mcp-token', + 'request_headers' => [], + ]); + + // Tool should prefer MCP context + $result = $this->simulateGetMeTool(); + + $this->assertEquals($mcpUser, $result['user']); + $this->assertEquals('mcp_context', $result['auth_method']); + $this->assertEquals('sanctum', $result['guard']); + } + + /** @test */ + public function it_handles_unauthenticated_scenarios(): void + { + Auth::shouldReceive('user')->andReturn(null); + Auth::shouldReceive('check')->andReturn(false); + + $result = $this->simulateGetMeTool(); + + $this->assertArrayHasKey('error', $result); + $this->assertEquals('No authenticated user found', $result['error']); + $this->assertArrayHasKey('context', $result); + $this->assertArrayHasKey('mcp_context', $result); + $this->assertArrayHasKey('auth_context', $result); + } + + /** + * Simulate the get_me tool from the sample routes/mcp.php + */ + protected function simulateGetMeTool(): array + { + // Try MCP context first (for dedicated HTTP), fallback to Laravel Auth + $user = McpAuth::user() ?? Auth::user(); + + if (!$user) { + return [ + 'error' => 'No authenticated user found', + 'context' => 'Make sure to include Authorization header with Bearer token', + 'mcp_context' => McpAuth::check() ? 'MCP context available' : 'No MCP context', + 'auth_context' => Auth::check() ? 'Laravel auth available' : 'No Laravel auth', + ]; + } + + return [ + 'user' => $user, + 'guard' => McpAuth::guard() ?? Auth::getDefaultDriver(), + 'auth_method' => McpAuth::check() ? 'mcp_context' : 'laravel_auth', + ]; + } +}