diff --git a/brain-seed b/brain-seed new file mode 100755 index 0000000..f062385 Binary files /dev/null and b/brain-seed differ diff --git a/php/src/Front/Mcp/McpContext.php b/php/src/Front/Mcp/McpContext.php index 4b1eea8..94784d4 100644 --- a/php/src/Front/Mcp/McpContext.php +++ b/php/src/Front/Mcp/McpContext.php @@ -12,6 +12,7 @@ namespace Core\Front\Mcp; use Closure; +use Illuminate\Http\Request; /** * Context object passed to MCP tool handlers. @@ -35,6 +36,7 @@ public function __construct( private ?object $currentPlan = null, private ?Closure $notificationCallback = null, private ?Closure $logCallback = null, + private array|object|null $scopeSource = null, ) {} /** @@ -130,4 +132,264 @@ public function hasPlan(): bool { return $this->currentPlan !== null; } + + // --------------------------------------------------------------------- + // Scope resolution + // + // Adopted from dappcore/agent, which declared this same FQCN in its own + // tree and carried these two methods plus their helpers. Two packages + // owning one class name is decided by autoload order, and this copy wins in + // a consumer — so agent's scope support was unreachable wherever it + // mattered, and a caller reaching hasScope() on the loaded class would have + // hit an undefined method. The methods come here, agent's file goes, and + // the FQCN has one owner. + // --------------------------------------------------------------------- + + /** + * @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; + } + + /** + * @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; + } + } + + // get_object_vars() from out here returns only what is actually + // reachable, so it is asked first. property_exists() answers true for + // private and protected properties too, and reading one of those from + // outside the class raises "Cannot access private property" — which is + // what this did to any scope source holding its data privately, the + // commonest shape there is. + $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/Unit/McpContextScopesTest.php b/php/tests/Unit/McpContextScopesTest.php new file mode 100644 index 0000000..85407cd --- /dev/null +++ b/php/tests/Unit/McpContextScopesTest.php @@ -0,0 +1,99 @@ + $this->scopes]; + } + }; + } + + public function test_get_scopes_good_returns_session_scopes(): void + { + $context = new McpContext(scopeSource: $this->scopeSession([ + 'brain.remember', + 'brain.recall', + ])); + + $this->assertSame(['brain.remember', 'brain.recall'], $context->getScopes()); + } + + public function test_get_scopes_ugly_defaults_to_an_empty_array_for_an_empty_session(): void + { + $this->assertSame([], (new McpContext(scopeSource: $this->scopeSession([])))->getScopes()); + } + + public function test_get_scopes_ugly_is_empty_when_no_source_is_given_at_all(): void + { + // No scope source, no plan, no request attribute — the resolver must + // report nothing rather than reaching for something that is not there. + $this->assertSame([], (new McpContext)->getScopes()); + } + + public function test_has_scope_good_returns_true_for_a_present_scope(): void + { + $context = new McpContext(scopeSource: $this->scopeSession([ + 'brain.remember', + 'brain.recall', + ])); + + $this->assertTrue($context->hasScope('brain.recall')); + } + + public function test_has_scope_bad_returns_false_for_a_missing_scope(): void + { + $context = new McpContext(scopeSource: $this->scopeSession(['brain.remember'])); + + $this->assertFalse($context->hasScope('brain.forget')); + } + + public function test_get_scopes_good_reads_scopes_off_the_authenticated_request(): void + { + // The HTTP transport puts the resolved workspace context on the request + // rather than passing a scope source, so the resolver checks there too. + $request = Request::create('/api/v1/mcp/tools/call', 'POST'); + $request->attributes->set('mcp_workspace_context', [ + 'workspace_id' => 1, + 'api_key' => (object) ['permissions' => ['brain.remember', 'brain.recall']], + ]); + + $original = app()->bound('request') ? app('request') : null; + app()->instance('request', $request); + + try { + $this->assertSame(['brain.remember', 'brain.recall'], (new McpContext)->getScopes()); + } finally { + if ($original !== null) { + app()->instance('request', $original); + } + } + } +} diff --git a/php/tests/Unit/McpContextTest.php b/php/tests/Unit/McpContextTest.php new file mode 100644 index 0000000..151f034 --- /dev/null +++ b/php/tests/Unit/McpContextTest.php @@ -0,0 +1,123 @@ + 'plan-1']; + $context = new McpContext('sess-1', $plan); + + $this->assertSame('sess-1', $context->getSessionId()); + $this->assertSame($plan, $context->getCurrentPlan()); + $this->assertTrue($context->hasSession()); + $this->assertTrue($context->hasPlan()); + } + + public function test_callbacks_bad_are_optional_and_can_be_left_unset(): void + { + $context = new McpContext; + + // Must not throw with no transport hooks attached. + $context->sendNotification('mcp.progress', ['value' => 50]); + $context->logToSession('noop'); + + $this->assertFalse($context->hasSession()); + $this->assertFalse($context->hasPlan()); + } + + public function test_callbacks_ugly_forward_notifications_and_session_logs(): 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]); + + $this->assertSame(['mcp.progress', ['value' => 100]], $captured['notification']); + $this->assertSame(['finished', 'info', ['ok' => true]], $captured['log']); + } + + public function test_tool_handler_good_can_be_implemented_with_the_expected_shape(): void + { + $handler = new class implements McpToolHandler + { + public static function schema(): array + { + return [ + 'name' => 'list_posts', + 'description' => 'List CMS posts', + 'inputSchema' => ['type' => 'object'], + ]; + } + + public function handle(array $args, McpContext $context): array + { + return ['ok' => true]; + } + }; + + $this->assertSame([ + 'name' => 'list_posts', + 'description' => 'List CMS posts', + 'inputSchema' => ['type' => 'object'], + ], $handler::schema()); + } + + public function test_tool_handler_bad_receives_the_transport_agnostic_context(): void + { + $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()]; + } + }; + + $this->assertSame('sess-1', $handler->handle([], new McpContext('sess-1'))['session_id']); + } + + public function test_tool_handler_ugly_exposes_exactly_the_two_contract_methods(): void + { + $methods = array_map( + static fn (ReflectionMethod $method): string => $method->getName(), + (new ReflectionClass(McpToolHandler::class))->getMethods(), + ); + + $this->assertSame(['schema', 'handle'], $methods); + } +}