diff --git a/php/Mcp/Transport/Contracts/McpToolHandler.php b/php/Mcp/Transport/Contracts/McpToolHandler.php deleted file mode 100644 index 27d067ed..00000000 --- a/php/Mcp/Transport/Contracts/McpToolHandler.php +++ /dev/null @@ -1,16 +0,0 @@ -sessionId; - } - - public function setSessionId(?string $sessionId): void - { - $this->sessionId = $sessionId; - } - - public function getCurrentPlan(): ?object - { - return $this->currentPlan; - } - - public function setCurrentPlan(?object $plan): void - { - $this->currentPlan = $plan; - } - - public function sendNotification(string $method, array $params = []): void - { - if ($this->notificationCallback instanceof Closure) { - ($this->notificationCallback)($method, $params); - } - } - - public function logToSession(string $message, string $type = 'info', array $data = []): void - { - if ($this->logCallback instanceof Closure) { - ($this->logCallback)($message, $type, $data); - } - } - - public function setNotificationCallback(?Closure $callback): void - { - $this->notificationCallback = $callback; - } - - public function setLogCallback(?Closure $callback): void - { - $this->logCallback = $callback; - } - - /** - * @return array - */ - public function getScopes(): array - { - foreach ($this->scopeCandidates() as $candidate) { - $scopes = $this->resolveScopes($candidate); - - if ($scopes !== []) { - return $scopes; - } - } - - return []; - } - - public function hasScope(string $scope): bool - { - $wanted = $this->canonicalScope($scope); - if ($wanted === null) { - return false; - } - - foreach ($this->getScopes() as $grantedScope) { - if ($this->canonicalScope($grantedScope) === $wanted) { - return true; - } - } - - return false; - } - - public function hasSession(): bool - { - return $this->sessionId !== null; - } - - public function hasPlan(): bool - { - return $this->currentPlan !== null; - } - - /** - * @return array - */ - private function scopeCandidates(): array - { - $candidates = []; - - if (is_array($this->scopeSource) || is_object($this->scopeSource)) { - $candidates[] = $this->scopeSource; - } - - if ($this->currentPlan !== null) { - $candidates[] = $this->currentPlan; - } - - $request = $this->currentRequest(); - if (! $request instanceof Request) { - return $candidates; - } - - $requestContext = $request->attributes->get('mcp_workspace_context'); - if (is_array($requestContext) || is_object($requestContext)) { - $candidates[] = $requestContext; - } - - foreach (['agent_api_key', 'api_key'] as $attribute) { - $value = $request->attributes->get($attribute); - - if (is_array($value) || is_object($value)) { - $candidates[] = $value; - } - } - - return $candidates; - } - - /** - * @return array - */ - private function resolveScopes(mixed $source, int $depth = 0): array - { - if ($depth > 3 || $source === null || $source === $this) { - return []; - } - - if (is_string($source)) { - return $this->normaliseScopes([$source]); - } - - if (is_array($source)) { - if (array_is_list($source)) { - return $this->normaliseScopes($source); - } - - foreach (['scopes', 'permissions', 'authorised_scopes', 'authorized_scopes'] as $key) { - if (! array_key_exists($key, $source)) { - continue; - } - - $scopes = $this->resolveScopes($source[$key], $depth + 1); - if ($scopes !== []) { - return $scopes; - } - } - - foreach (['api_key', 'agent_api_key', 'apiKey', 'agentApiKey', 'session', 'auth', 'authorisation', 'authorization'] as $key) { - if (! array_key_exists($key, $source)) { - continue; - } - - $scopes = $this->resolveScopes($source[$key], $depth + 1); - if ($scopes !== []) { - return $scopes; - } - } - - return []; - } - - if (! is_object($source)) { - return []; - } - - foreach (['getScopes', 'getPermissions'] as $method) { - if (! method_exists($source, $method)) { - continue; - } - - $scopes = $this->resolveScopes($source->{$method}(), $depth + 1); - if ($scopes !== []) { - return $scopes; - } - } - - foreach (['scopes', 'permissions', 'authorised_scopes', 'authorized_scopes'] as $key) { - $scopes = $this->resolveScopes($this->extractObjectValue($source, $key), $depth + 1); - if ($scopes !== []) { - return $scopes; - } - } - - foreach (['apiKey', 'agentApiKey', 'api_key', 'agent_api_key', 'session', 'auth', 'authorisation', 'authorization'] as $key) { - $scopes = $this->resolveScopes($this->extractObjectValue($source, $key), $depth + 1); - if ($scopes !== []) { - return $scopes; - } - } - - return []; - } - - private function extractObjectValue(object $source, string $key): mixed - { - if (method_exists($source, 'getAttribute')) { - $value = $source->getAttribute($key); - - if ($value !== null) { - return $value; - } - } - - foreach ($this->getterNames($key) as $getter) { - if (! method_exists($source, $getter)) { - continue; - } - - $value = $source->{$getter}(); - if ($value !== null) { - return $value; - } - } - - if (property_exists($source, $key)) { - return $source->{$key}; - } - - $vars = get_object_vars($source); - if (array_key_exists($key, $vars)) { - return $vars[$key]; - } - - if (isset($source->{$key})) { - return $source->{$key}; - } - - return null; - } - - /** - * @return array - */ - private function getterNames(string $key): array - { - $segments = array_filter(explode('_', $key), fn (string $segment): bool => $segment !== ''); - $studly = implode('', array_map(static fn (string $segment): string => ucfirst($segment), $segments)); - - $names = ['get'.ucfirst($key)]; - if ($studly !== '') { - $names[] = 'get'.$studly; - } - - return array_values(array_unique($names)); - } - - private function currentRequest(): ?Request - { - if (! function_exists('app') || ! class_exists(Request::class) || ! app()->bound('request')) { - return null; - } - - $request = app('request'); - - return $request instanceof Request ? $request : null; - } - - /** - * @param array $scopes - * @return array - */ - private function normaliseScopes(array $scopes): array - { - $normalised = []; - $seen = []; - - foreach ($scopes as $scope) { - if (! is_string($scope)) { - continue; - } - - $cleanScope = trim($scope); - $canonicalScope = $this->canonicalScope($cleanScope); - - if ($canonicalScope === null || isset($seen[$canonicalScope])) { - continue; - } - - $seen[$canonicalScope] = true; - $normalised[] = $cleanScope; - } - - return $normalised; - } - - private function canonicalScope(string $scope): ?string - { - $cleanScope = trim($scope); - - if ($cleanScope === '') { - return null; - } - - return str_replace(':', '.', $cleanScope); - } -} diff --git a/php/tests/Feature/Mcp/Transport/McpContextTest.php b/php/tests/Feature/Mcp/Transport/McpContextTest.php deleted file mode 100644 index 650ef13b..00000000 --- a/php/tests/Feature/Mcp/Transport/McpContextTest.php +++ /dev/null @@ -1,132 +0,0 @@ - $this->scopes]; - } - }; -} - -test('McpContext_getters_Good_track_the_current_session_and_plan', function (): void { - $plan = (object) ['slug' => 'plan-1']; - $context = new McpContext('sess-1', $plan); - - expect($context->getSessionId())->toBe('sess-1') - ->and($context->getCurrentPlan())->toBe($plan) - ->and($context->hasSession())->toBeTrue() - ->and($context->hasPlan())->toBeTrue(); -}); - -test('McpContext_callbacks_Bad_are_optional_and_can_be_left_unset', function (): void { - $context = new McpContext; - - $context->sendNotification('mcp.progress', ['value' => 50]); - $context->logToSession('noop'); - - expect($context->hasSession())->toBeFalse() - ->and($context->hasPlan())->toBeFalse(); -}); - -test('McpContext_callbacks_Ugly_forward_notifications_and_session_logs_through_the_transport_hooks', function (): void { - $captured = []; - $context = new McpContext( - notificationCallback: function (string $method, array $params) use (&$captured): void { - $captured['notification'] = [$method, $params]; - }, - logCallback: function (string $message, string $type, array $data) use (&$captured): void { - $captured['log'] = [$message, $type, $data]; - }, - ); - - $context->sendNotification('mcp.progress', ['value' => 100]); - $context->logToSession('finished', 'info', ['ok' => true]); - - expect($captured['notification'])->toBe(['mcp.progress', ['value' => 100]]) - ->and($captured['log'])->toBe(['finished', 'info', ['ok' => true]]); -}); - -test('McpContext_getScopes_Good_returns_session_scopes', function (): void { - $context = new McpContext(scopeSource: mcpScopeSession([ - 'brain.remember', - 'brain.recall', - ])); - - expect($context->getScopes())->toBe([ - 'brain.remember', - 'brain.recall', - ]); -}); - -test('McpContext_getScopes_Good_reads_authenticated_request_scopes_from_mcp_workspace_context', function (): void { - $workspace = createWorkspace(); - $apiKey = createApiKey($workspace, 'Scoped MCP Key', [ - 'brain.remember', - 'brain.recall', - ]); - - $request = Request::create('/api/v1/mcp/tools/call', 'POST'); - $request->attributes->set('mcp_workspace_context', [ - 'workspace_id' => $workspace->id, - 'api_key' => $apiKey, - ]); - - $originalRequest = app()->bound('request') ? app('request') : null; - app()->instance('request', $request); - - try { - expect((new McpContext)->getScopes())->toBe([ - 'brain.remember', - 'brain.recall', - ]); - } finally { - if ($originalRequest instanceof Request) { - app()->instance('request', $originalRequest); - } else { - app()->forgetInstance('request'); - } - } -}); - -test('McpContext_hasScope_Good_returns_true_for_a_present_scope', function (): void { - $context = new McpContext(scopeSource: mcpScopeSession([ - 'brain.remember', - 'brain.recall', - ])); - - expect($context->hasScope('brain.remember'))->toBeTrue(); -}); - -test('McpContext_hasScope_Bad_returns_false_for_a_missing_scope', function (): void { - $context = new McpContext(scopeSource: mcpScopeSession([ - 'brain.remember', - 'brain.recall', - ])); - - expect($context->hasScope('ofm.fan.read'))->toBeFalse(); -}); - -test('McpContext_getScopes_Ugly_defaults_to_an_empty_array_for_an_empty_session', function (): void { - $context = new McpContext(scopeSource: mcpScopeSession([])); - - expect($context->getScopes())->toBe([]) - ->and($context->hasScope('brain.remember'))->toBeFalse(); -}); diff --git a/php/tests/Feature/Mcp/Transport/McpToolHandlerTest.php b/php/tests/Feature/Mcp/Transport/McpToolHandlerTest.php deleted file mode 100644 index 59bebe1b..00000000 --- a/php/tests/Feature/Mcp/Transport/McpToolHandlerTest.php +++ /dev/null @@ -1,65 +0,0 @@ - 'list_posts', - 'description' => 'List CMS posts', - 'inputSchema' => ['type' => 'object'], - ]; - } - - public function handle(array $args, McpContext $context): array - { - return ['ok' => true]; - } - }; - - expect($handler::schema())->toBe([ - 'name' => 'list_posts', - 'description' => 'List CMS posts', - 'inputSchema' => ['type' => 'object'], - ]); -}); - -test('McpToolHandler_handle_Bad_receives_the_transport_agnostic_context_object', function (): void { - $context = new McpContext('sess-1'); - $handler = new class implements McpToolHandler - { - public static function schema(): array - { - return ['name' => 'ping', 'description' => 'Ping', 'inputSchema' => ['type' => 'object']]; - } - - public function handle(array $args, McpContext $context): array - { - return ['session_id' => $context->getSessionId()]; - } - }; - - expect($handler->handle([], $context)['session_id'])->toBe('sess-1'); -}); - -test('McpToolHandler_interface_Ugly_exposes_exactly_the_two_contract_methods_required_by_the_rfc', function (): void { - $methods = array_map( - static fn (ReflectionMethod $method): string => $method->getName(), - (new ReflectionClass(McpToolHandler::class))->getMethods(), - ); - - expect($methods)->toBe(['schema', 'handle']); -});