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
Binary file added brain-seed
Binary file not shown.
262 changes: 262 additions & 0 deletions php/src/Front/Mcp/McpContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
namespace Core\Front\Mcp;

use Closure;
use Illuminate\Http\Request;

/**
* Context object passed to MCP tool handlers.
Expand All @@ -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,
) {}

/**
Expand Down Expand Up @@ -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<int, string>
*/
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<int, array|object>
*/
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<int, string>
*/
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<int, string>
*/
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<int, mixed> $scopes
* @return array<int, string>
*/
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);
}
}
99 changes: 99 additions & 0 deletions php/tests/Unit/McpContextScopesTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

// SPDX-License-Identifier: EUPL-1.2

declare(strict_types=1);

namespace Core\Mcp\Tests\Unit;

use Core\Front\Mcp\McpContext;
use Illuminate\Http\Request;
use Tests\TestCase;

/**
* Scope resolution on McpContext.
*
* These came across with the methods from dappcore/agent, which declared this
* same FQCN in its own tree. Coverage follows ownership: agent testing a class
* it no longer owns proves nothing about the copy a consumer actually loads.
*/
class McpContextScopesTest extends TestCase
{
/**
* A session-like object exposing an API key with permissions, which is one
* of the shapes scopeCandidates() walks.
*/
private function scopeSession(array $scopes = []): object
{
return new class($scopes)
{
public function __construct(private readonly array $scopes) {}

public function getApiKey(): object
{
return (object) ['permissions' => $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);
}
}
}
}
Loading
Loading