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
38 changes: 30 additions & 8 deletions php/Boot.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@
use Core\Events\ApiRoutesRegistering;
use Core\Events\ConsoleBooting;
use Core\Events\McpToolsRegistering;
use Core\Mod\Agentic\Services\AgenticManager;
use Core\Mod\Agentic\Services\AgentResourceRegistry;
use Core\Mod\Agentic\Services\AgentToolRegistry;
use Core\Mod\Agentic\Services\BrainService;
use Core\Mod\Agentic\Services\ForgejoService;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Http\Request;
Expand Down Expand Up @@ -90,17 +95,34 @@ public function register(): void
config(['database.connections.brain' => $brainDb]);
}

$this->app->singleton(\Core\Mod\Agentic\Services\AgenticManager::class);
$this->app->singleton(\Core\Mod\Agentic\Services\AgentToolRegistry::class);
$this->app->singleton(AgenticManager::class);
$this->app->singleton(AgentToolRegistry::class);

$this->app->singleton(\Core\Mod\Agentic\Services\ForgejoService::class, function ($app) {
return new \Core\Mod\Agentic\Services\ForgejoService(
// Resources are bound here rather than hung off an event the way tools
// are. There is no McpResourcesRegistering to listen for, and $listens
// is populated by ModuleScanner from app/Core|Mod|Website only — dead
// once this package is installed under vendor/ — so a container binding
// is what actually resolves in a host application.
$this->app->singleton(
AgentResourceRegistry::class,
static fn (): AgentResourceRegistry => (new AgentResourceRegistry)
->registerMany([
new Mcp\Resources\Agent\AllPlansResource,
new Mcp\Resources\Agent\PlanDocumentResource,
new Mcp\Resources\Agent\PhaseChecklistResource,
new Mcp\Resources\Agent\StateValueResource,
new Mcp\Resources\Agent\SessionContextResource,
]),
);

$this->app->singleton(ForgejoService::class, function ($app) {
return new ForgejoService(
baseUrl: (string) config('agentic.forge_url', 'https://forge.lthn.ai'),
token: (string) config('agentic.forge_token', ''),
);
});

$this->app->singleton(\Core\Mod\Agentic\Services\BrainService::class, function ($app) {
$this->app->singleton(BrainService::class, function ($app) {
$ollamaUrl = config('mcp.brain.ollama_url', 'http://localhost:11434');
$qdrantUrl = config('mcp.brain.qdrant_url', 'http://localhost:6334');

Expand All @@ -111,7 +133,7 @@ public function register(): void
);
$verifySsl = ! ($hasLocalTld($ollamaUrl) || $hasLocalTld($qdrantUrl));

return new \Core\Mod\Agentic\Services\BrainService(
return new BrainService(
ollamaUrl: $ollamaUrl,
qdrantUrl: $qdrantUrl,
collection: config('mcp.brain.collection', 'openbrain'),
Expand Down Expand Up @@ -201,7 +223,7 @@ public function onConsole(ConsoleBooting $event): void
*/
public function onMcpTools(McpToolsRegistering $event): void
{
$registry = $this->app->make(Services\AgentToolRegistry::class);
$registry = $this->app->make(AgentToolRegistry::class);

$toolClasses = [
Mcp\Tools\Agent\Brain\BrainRemember::class,
Expand Down Expand Up @@ -247,7 +269,7 @@ public function onMcpTools(McpToolsRegistering $event): void
];

$registry->registerMany(array_map(
static fn (string $toolClass) => new $toolClass(),
static fn (string $toolClass) => new $toolClass,
$toolClasses,
));
}
Expand Down
63 changes: 58 additions & 5 deletions php/Mcp/Console/McpAgentServerCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Core\Mod\Agentic\Mcp\Services\McpQuotaService;
use Core\Mod\Agentic\Mcp\Services\QueryAuditService;
use Core\Mod\Agentic\Mcp\Services\ToolRegistry;
use Core\Mod\Agentic\Services\AgentResourceRegistry;
use Illuminate\Console\Command;
use InvalidArgumentException;
use JsonException;
Expand Down Expand Up @@ -37,6 +38,7 @@ public function handle(
ToolRegistry $toolRegistry,
McpQuotaService $quotaService,
QueryAuditService $queryAuditService,
AgentResourceRegistry $resourceRegistry,
): int {
$inputPath = $this->streamPath('MCP_AGENT_SERVER_INPUT', 'php://stdin');
$outputPath = $this->streamPath('MCP_AGENT_SERVER_OUTPUT', 'php://stdout');
Expand Down Expand Up @@ -64,6 +66,7 @@ public function handle(
$toolRegistry,
$quotaService,
$queryAuditService,
$resourceRegistry,
);

if ($response === null) {
Expand Down Expand Up @@ -118,7 +121,8 @@ private function processPayload(
ToolRegistry $toolRegistry,
McpQuotaService $quotaService,
QueryAuditService $queryAuditService,
): array|null {
AgentResourceRegistry $resourceRegistry,
): ?array {
try {
$request = json_decode($payload, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
Expand All @@ -142,6 +146,7 @@ private function processPayload(
$toolRegistry,
$quotaService,
$queryAuditService,
$resourceRegistry,
);

if ($response !== null) {
Expand All @@ -157,6 +162,7 @@ private function processPayload(
$toolRegistry,
$quotaService,
$queryAuditService,
$resourceRegistry,
);
}

Expand All @@ -171,7 +177,8 @@ private function processRequest(
ToolRegistry $toolRegistry,
McpQuotaService $quotaService,
QueryAuditService $queryAuditService,
): array|null {
AgentResourceRegistry $resourceRegistry,
): ?array {
$id = $request['id'] ?? null;

if (($request['jsonrpc'] ?? null) !== '2.0') {
Expand Down Expand Up @@ -199,6 +206,16 @@ private function processRequest(
'list' => true,
'call' => true,
],
// Advertised, not just implemented: a client that is not
// told the server has resources never issues resources/list,
// so an unadvertised surface is undiscoverable and might as
// well not exist.
'resources' => [
'list' => true,
'read' => true,
'subscribe' => false,
'listChanged' => true,
],
],
], $id),
'ping' => $this->successResponse(['ok' => true], $id),
Expand All @@ -221,7 +238,11 @@ private function processRequest(
$quotaService,
$queryAuditService,
),
'resources/list' => $this->successResponse(['resources' => []], $id),
'resources/list' => $this->successResponse(
['resources' => $resourceRegistry->entries()],
$id,
),
'resources/read' => $this->handleResourceRead($params, $id, $resourceRegistry),
default => $this->errorResponse(-32601, 'Method not found', $id),
};

Expand Down Expand Up @@ -340,13 +361,45 @@ private function handleToolCall(
}
}

/**
* Serve a resources/read request.
*
* A URI that no resource claims, and one whose shape matches but whose
* target does not exist, both answer -32602 rather than a body reading
* "Plan not found" — that string was previously returned as the resource
* content, so a client could not tell a missing plan from a plan whose
* document happens to say that.
*
* @example
* $response = $this->handleResourceRead(['uri' => 'plans://all'], 1, $resourceRegistry);
*/
private function handleResourceRead(
array $params,
mixed $id,
AgentResourceRegistry $resourceRegistry,
): array {
$uri = $params['uri'] ?? null;

if (! is_string($uri) || $uri === '') {
return $this->errorResponse(-32602, 'Invalid params', $id);
}

$contents = $resourceRegistry->read($uri);

if ($contents === null) {
return $this->errorResponse(-32602, "Resource not found: {$uri}", $id);
}

return $this->successResponse(['contents' => [$contents]], $id);
}

/**
* Resolve the requested tool name from supported JSON-RPC parameter keys.
*
* @example
* $toolName = $this->resolveToolName(['tool' => 'brain_list']);
*/
private function resolveToolName(array $params): string|null
private function resolveToolName(array $params): ?string
{
$value = $params['name'] ?? $params['tool'] ?? null;

Expand Down Expand Up @@ -376,7 +429,7 @@ private function workspaceId(array $params, array $arguments): string
* @example
* $query = $this->toolQuery(['query' => 'select * from memories']);
*/
private function toolQuery(array $arguments): string|null
private function toolQuery(array $arguments): ?string
{
$query = $arguments['query'] ?? null;

Expand Down
87 changes: 87 additions & 0 deletions php/Mcp/Resources/Agent/AgentResource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php

// SPDX-License-Identifier: EUPL-1.2

declare(strict_types=1);

namespace Core\Mod\Agentic\Mcp\Resources\Agent;

/**
* Base class for MCP Agent Server resources.
*
* Mirrors the shape of Mcp\Tools\Agent\AgentTool: one class per resource,
* registered into a registry, rather than a handler method on a monolithic
* command. These carry the plans/phases/sessions/state surface ported out of
* dappcore/mcp, which owned it only because that command was filed in the
* shared package — the data is entirely agent-domain.
*
* A resource answers two questions: what should `resources/list` advertise
* (entries(), which may be dynamic), and what does `resources/read` return for
* a given URI (matches() then read()).
*/
abstract class AgentResource
{
/**
* The URI template this resource answers, for documentation and errors.
*
* @example
* $resource->uriTemplate(); // 'plans://{slug}/phases/{order}'
*/
abstract public function uriTemplate(): string;

/**
* Whether this resource can serve the given URI.
*
* @example
* $resource->matches('plans://all'); // true
*/
abstract public function matches(string $uri): bool;

/**
* Render the resource body for a URI this resource matches.
*
* Returns null when the URI matches the shape but names something that does
* not exist, so the caller can answer "not found" rather than an empty
* document that reads like real content.
*
* @example
* $resource->read('plans://all');
*/
abstract public function read(string $uri): ?string;

/**
* Entries this resource contributes to resources/list.
*
* Returns a list so one resource can advertise many URIs — the plan
* document resource enumerates every non-archived plan.
*
* @return array<int, array{uri: string, name: string, description: string, mimeType: string}>
*
* @example
* $resource->entries(); // [['uri' => 'plans://all', ...]]
*/
abstract public function entries(): array;

/**
* The MIME type this resource renders. Markdown throughout.
*/
public function mimeType(): string
{
return 'text/markdown';
}

/**
* Build a single list entry, so subclasses do not repeat the shape.
*
* @return array{uri: string, name: string, description: string, mimeType: string}
*/
protected function entry(string $uri, string $name, string $description): array
{
return [
'uri' => $uri,
'name' => $name,
'description' => $description,
'mimeType' => $this->mimeType(),
];
}
}
66 changes: 66 additions & 0 deletions php/Mcp/Resources/Agent/AllPlansResource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

// SPDX-License-Identifier: EUPL-1.2

declare(strict_types=1);

namespace Core\Mod\Agentic\Mcp\Resources\Agent;

use Core\Mod\Agentic\Models\AgentPlan;

/**
* plans://all — a markdown overview of every non-archived plan, grouped by
* status with each plan's completion percentage.
*
* @example
* (new AllPlansResource())->read('plans://all');
*/
final class AllPlansResource extends AgentResource
{
public const URI = 'plans://all';

public function uriTemplate(): string
{
return self::URI;
}

public function matches(string $uri): bool
{
return $uri === self::URI;
}

public function read(string $uri): ?string
{
if (! $this->matches($uri)) {
return null;
}

$plans = AgentPlan::with('agentPhases')
->notArchived()
->orderBy('updated_at', 'desc')
->get();

$markdown = "# Work Plans\n\n";
$markdown .= '**Total:** '.$plans->count()." plan(s)\n\n";

foreach ($plans->groupBy('status') as $status => $group) {
$markdown .= '## '.ucfirst((string) $status).' ('.$group->count().")\n\n";

foreach ($group as $plan) {
$progress = $plan->getProgress();
$markdown .= "- **[{$plan->slug}]** {$plan->title} - {$progress['percentage']}%\n";
}

$markdown .= "\n";
}

return $markdown;
}

public function entries(): array
{
return [
$this->entry(self::URI, 'All Plans Overview', 'Overview of all work plans and their status'),
];
}
}
Loading
Loading