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
29 changes: 25 additions & 4 deletions php/src/Mcp/Controllers/McpApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -379,24 +379,45 @@ protected function resourceServerContent(string $scheme, string $uri): ?array
}

/**
* Resolve the bound agent resource provider, if the agent module supplies one.
* Resolve the bound agent resource provider, if a module supplies one.
*
* Absent when this package is installed without dappcore/agent, which is a
* Absent when this package is installed without an agent module, which is a
* legitimate deployment — the protocol surface stands on its own and the
* agent resources are an optional extension to it.
*
* Structural, not nominal. AgentResourceProvider states the contract and a
* provider that can name the interface should implement it, but
* dappcore/agent maintains its own copy of Core\Mcp rather than depending
* on this package, so it cannot reference the interface to implement it.
* Requiring `instanceof` would therefore have left these endpoints dead
* permanently rather than until the binding landed — an interim that never
* ends is a decision, so it is made here: bind under the interface name and
* satisfy its two methods, and the endpoints work either way.
*
* @return object|null A read() provider, or null when unbound
*
* @example
* $provider = $this->agentResourceProvider();
*/
protected function agentResourceProvider(): ?AgentResourceProvider
protected function agentResourceProvider(): ?object
{
if (! app()->bound(AgentResourceProvider::class)) {
return null;
}

$provider = app(AgentResourceProvider::class);

return $provider instanceof AgentResourceProvider ? $provider : null;
if (! is_object($provider)) {
return null;
}

if ($provider instanceof AgentResourceProvider) {
return $provider;
}

// Duck-typed fallback: read() is the whole contract this package
// consumes, so it is the whole check.
return method_exists($provider, 'read') ? $provider : null;
}

/**
Expand Down
23 changes: 13 additions & 10 deletions php/src/Mcp/Resources/Contracts/AgentResourceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,22 @@
* Nothing is bound when the agent module is absent, and the controller answers
* "unavailable" rather than pretending — the resources are optional to this
* package, not to the protocol.
*
* read() only, deliberately. An earlier draft also required entries() for
* listing, which nothing here ever called: this package's
* GET servers/{id}/resources lists a server's own configured resources, a
* different concept. A contract method with no consumer is over-specification
* that an implementer has to satisfy for nothing, so it is not asked for.
* Providers are free to offer listing for their own transports —
* dappcore/agent's registry does, for its stdio server.
*
* Satisfied structurally as well as nominally: a provider that cannot name this
* interface (because it maintains its own copy of Core\Mcp rather than
* depending on this package) binds under the interface name and implements
* read(), and McpApiController accepts it.
*/
interface AgentResourceProvider
{
/**
* Entries to advertise when listing resources.
*
* @return array<int, array{uri: string, name: string, description: string, mimeType: string}>
*
* @example
* $provider->entries(); // [['uri' => 'plans://all', ...]]
*/
public function entries(): array;

/**
* Read one resource, or null when nothing serves that URI.
*
Expand Down
84 changes: 84 additions & 0 deletions php/tests/Feature/AgentResourceProviderBindingTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

// SPDX-License-Identifier: EUPL-1.2

declare(strict_types=1);

namespace Core\Mcp\Tests\Feature;

use Core\Mcp\Controllers\McpApiController;
use Core\Mcp\Resources\Contracts\AgentResourceProvider;
use Tests\TestCase;

/**
* The provider is resolved structurally, not nominally.
*
* dappcore/agent maintains its own copy of Core\Mcp rather than depending on
* this package, so it cannot name AgentResourceProvider in an implements
* clause. Requiring instanceof would leave the plans:// and sessions://
* endpoints dead permanently rather than until a binding arrived.
*/
class AgentResourceProviderBindingTest extends TestCase
{
private function resolve(): ?object
{
$controller = new class extends McpApiController
{
public function provider(): ?object
{
return $this->agentResourceProvider();
}
};

return $controller->provider();
}

public function test_provider_good_accepts_a_duck_typed_binding(): void
{
// No implements clause — exactly what a package that cannot name the
// interface is able to bind.
$this->app->bind(AgentResourceProvider::class, fn (): object => new class
{
public function read(string $uri): ?array
{
return ['uri' => $uri, 'mimeType' => 'text/markdown', 'text' => '# Plan'];
}
});

$provider = $this->resolve();

$this->assertNotNull($provider);
$this->assertSame('# Plan', $provider->read('plans://all')['text']);
}

public function test_provider_good_accepts_a_nominal_implementation(): void
{
$this->app->bind(AgentResourceProvider::class, fn (): AgentResourceProvider => new class implements AgentResourceProvider
{
public function read(string $uri): ?array
{
return null;
}
});

$this->assertInstanceOf(AgentResourceProvider::class, $this->resolve());
}

public function test_provider_bad_rejects_an_object_without_read(): void
{
// Half a contract would fatal mid-request; the controller must see
// nothing rather than something unusable.
$this->app->bind(AgentResourceProvider::class, fn (): object => new class
{
public function somethingElse(): void {}
});

$this->assertNull($this->resolve());
}

public function test_provider_ugly_is_null_when_nothing_is_bound(): void
{
// The package installed without any agent module — a legitimate deploy.
$this->assertNull($this->resolve());
}
}
Loading