From 12b924e2bba3a62840d6519415d3306b3456cc7e Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 8 Aug 2026 11:44:04 +0100 Subject: [PATCH] feat(mcp): serve the agent resources surface that only dappcore/mcp had MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent MCP server answered resources/list with a hardcoded ['resources' => []] and implemented no resources/read at all. The real surface — plans, phase checklists, plan state, session context — lived only in dappcore/mcp's McpAgentServerCommand, which is also where the dependency cycle came from: a shared package importing Core\Mod\Agentic\Models\*. This ports the capability across so mcp's copy can be deleted afterwards rather than before. Nothing is missing from both at once. Five URI shapes, one class each, matching how Mcp\Tools\Agent already splits tools out rather than carrying them as methods on a 2000-line command: plans://all AllPlansResource plans://{slug} PlanDocumentResource plans://{slug}/phases/{order} PhaseChecklistResource plans://{slug}/state/{key} StateValueResource sessions://{id}/context SessionContextResource AgentResourceRegistry lists and routes them, the counterpart to AgentToolRegistry. Resources contribute their own list entries, so the plan document resource enumerates every non-archived plan while the unbounded per-key ones advertise nothing and stay directly addressable. Two deliberate departures from the code being ported. A missing target now answers a JSON-RPC -32602 instead of returning the string "Plan not found: {slug}" as the resource body — a client could not distinguish that from a real document whose text happens to say so. And initialize advertises the resources capability. Implementing the surface without declaring it would leave it undiscoverable: a client that is not told the server has resources never issues resources/list, so it would have been shipped and never called, which is the failure this whole sweep exists to remove. Registered as a container singleton in register(), not hung off an event like onMcpTools. There is no McpResourcesRegistering to listen for, and $listens is populated by ModuleScanner from app/Core|Mod|Website only — dead once this package sits under vendor/ — so a binding is what actually resolves in a host application. Ten new tests: six over the registry (routing, declining near-miss shapes, reading, dynamic listing, and returning null rather than a document for a missing target) and four driving the real stdio command end to end for the initialize advertisement, resources/list, resources/read and its error path. Suite: 156 failed, 1165 passed, from 156 failed, 1155 passed — the same failures, plus exactly these ten. Co-Authored-By: Virgil --- php/Boot.php | 38 +++-- php/Mcp/Console/McpAgentServerCommand.php | 63 ++++++++- php/Mcp/Resources/Agent/AgentResource.php | 87 ++++++++++++ php/Mcp/Resources/Agent/AllPlansResource.php | 66 +++++++++ .../Agent/PhaseChecklistResource.php | 99 +++++++++++++ .../Resources/Agent/PlanDocumentResource.php | 77 +++++++++++ .../Agent/SessionContextResource.php | 95 +++++++++++++ .../Resources/Agent/StateValueResource.php | 73 ++++++++++ php/Services/AgentResourceRegistry.php | 130 ++++++++++++++++++ .../Mcp/Console/McpAgentServerCommandTest.php | 65 +++++++++ .../Mcp/Resources/AgentResourcesTest.php | 105 ++++++++++++++ 11 files changed, 885 insertions(+), 13 deletions(-) create mode 100644 php/Mcp/Resources/Agent/AgentResource.php create mode 100644 php/Mcp/Resources/Agent/AllPlansResource.php create mode 100644 php/Mcp/Resources/Agent/PhaseChecklistResource.php create mode 100644 php/Mcp/Resources/Agent/PlanDocumentResource.php create mode 100644 php/Mcp/Resources/Agent/SessionContextResource.php create mode 100644 php/Mcp/Resources/Agent/StateValueResource.php create mode 100644 php/Services/AgentResourceRegistry.php create mode 100644 php/tests/Feature/Mcp/Resources/AgentResourcesTest.php diff --git a/php/Boot.php b/php/Boot.php index a009b068..14565c4a 100644 --- a/php/Boot.php +++ b/php/Boot.php @@ -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; @@ -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'); @@ -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'), @@ -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, @@ -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, )); } diff --git a/php/Mcp/Console/McpAgentServerCommand.php b/php/Mcp/Console/McpAgentServerCommand.php index e0150a7a..1bc72a29 100644 --- a/php/Mcp/Console/McpAgentServerCommand.php +++ b/php/Mcp/Console/McpAgentServerCommand.php @@ -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; @@ -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'); @@ -64,6 +66,7 @@ public function handle( $toolRegistry, $quotaService, $queryAuditService, + $resourceRegistry, ); if ($response === null) { @@ -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) { @@ -142,6 +146,7 @@ private function processPayload( $toolRegistry, $quotaService, $queryAuditService, + $resourceRegistry, ); if ($response !== null) { @@ -157,6 +162,7 @@ private function processPayload( $toolRegistry, $quotaService, $queryAuditService, + $resourceRegistry, ); } @@ -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') { @@ -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), @@ -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), }; @@ -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; @@ -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; diff --git a/php/Mcp/Resources/Agent/AgentResource.php b/php/Mcp/Resources/Agent/AgentResource.php new file mode 100644 index 00000000..d024b195 --- /dev/null +++ b/php/Mcp/Resources/Agent/AgentResource.php @@ -0,0 +1,87 @@ +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 + * + * @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(), + ]; + } +} diff --git a/php/Mcp/Resources/Agent/AllPlansResource.php b/php/Mcp/Resources/Agent/AllPlansResource.php new file mode 100644 index 00000000..69d88961 --- /dev/null +++ b/php/Mcp/Resources/Agent/AllPlansResource.php @@ -0,0 +1,66 @@ +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'), + ]; + } +} diff --git a/php/Mcp/Resources/Agent/PhaseChecklistResource.php b/php/Mcp/Resources/Agent/PhaseChecklistResource.php new file mode 100644 index 00000000..3f3d21fc --- /dev/null +++ b/php/Mcp/Resources/Agent/PhaseChecklistResource.php @@ -0,0 +1,99 @@ +read('plans://ship-the-thing/phases/2'); + */ +final class PhaseChecklistResource extends AgentResource +{ + public function uriTemplate(): string + { + return 'plans://{slug}/phases/{order}'; + } + + public function matches(string $uri): bool + { + return $this->partsFor($uri) !== null; + } + + public function read(string $uri): ?string + { + $parts = $this->partsFor($uri); + if ($parts === null) { + return null; + } + + [$slug, $order] = $parts; + + $plan = AgentPlan::where('slug', $slug)->first(); + if (! $plan) { + return null; + } + + $phase = $plan->agentPhases()->where('order', $order)->first(); + if (! $phase) { + return null; + } + + $markdown = "# Phase {$phase->order}: {$phase->name}\n\n"; + $markdown .= "**Status:** {$phase->getStatusIcon()} {$phase->status}\n\n"; + + if ($phase->description) { + $markdown .= "{$phase->description}\n\n"; + } + + $markdown .= "## Tasks\n\n"; + + foreach ($phase->tasks ?? [] as $task) { + // Tasks are stored either as a bare name or as a {name, status} map. + $status = is_string($task) ? 'pending' : ($task['status'] ?? 'pending'); + $name = is_string($task) ? $task : ($task['name'] ?? 'Unknown'); + $icon = $status === 'completed' ? '✅' : '⬜'; + $markdown .= "- {$icon} {$name}\n"; + } + + return $markdown; + } + + public function entries(): array + { + return []; + } + + /** + * @return array{0: string, 1: int}|null + */ + private function partsFor(string $uri): ?array + { + if (! str_starts_with($uri, 'plans://')) { + return null; + } + + $parts = explode('/', substr($uri, strlen('plans://'))); + + if (count($parts) !== 3 || $parts[1] !== 'phases' || $parts[0] === '') { + return null; + } + + // Numeric check before the cast: "phases/latest" is not phase 0. + if (! is_numeric($parts[2])) { + return null; + } + + return [$parts[0], (int) $parts[2]]; + } +} diff --git a/php/Mcp/Resources/Agent/PlanDocumentResource.php b/php/Mcp/Resources/Agent/PlanDocumentResource.php new file mode 100644 index 00000000..e50558fa --- /dev/null +++ b/php/Mcp/Resources/Agent/PlanDocumentResource.php @@ -0,0 +1,77 @@ +read('plans://ship-the-thing'); + */ +final class PlanDocumentResource extends AgentResource +{ + public function uriTemplate(): string + { + return 'plans://{slug}'; + } + + public function matches(string $uri): bool + { + return $this->slugFor($uri) !== null; + } + + public function read(string $uri): ?string + { + $slug = $this->slugFor($uri); + if ($slug === null) { + return null; + } + + $plan = AgentPlan::with('agentPhases')->where('slug', $slug)->first(); + + return $plan?->toMarkdown(); + } + + public function entries(): array + { + return AgentPlan::notArchived() + ->get() + ->map(fn (AgentPlan $plan): array => $this->entry( + "plans://{$plan->slug}", + (string) $plan->title, + "Work plan: {$plan->title}", + )) + ->all(); + } + + /** + * Extract the slug from a bare plans://{slug} URI. + * + * Returns null for plans://all and for the deeper phases/state URIs, which + * belong to the other resources — the registry asks each resource in turn, + * so each has to decline anything that is not precisely its own shape. + */ + private function slugFor(string $uri): ?string + { + if (! str_starts_with($uri, 'plans://') || $uri === AllPlansResource::URI) { + return null; + } + + $path = substr($uri, strlen('plans://')); + if ($path === '' || str_contains($path, '/')) { + return null; + } + + return $path; + } +} diff --git a/php/Mcp/Resources/Agent/SessionContextResource.php b/php/Mcp/Resources/Agent/SessionContextResource.php new file mode 100644 index 00000000..7f4467ed --- /dev/null +++ b/php/Mcp/Resources/Agent/SessionContextResource.php @@ -0,0 +1,95 @@ +read('sessions://abc123/context'); + */ +final class SessionContextResource extends AgentResource +{ + public function uriTemplate(): string + { + return 'sessions://{sessionId}/context'; + } + + public function matches(string $uri): bool + { + return $this->sessionIdFor($uri) !== null; + } + + public function read(string $uri): ?string + { + $sessionId = $this->sessionIdFor($uri); + if ($sessionId === null) { + return null; + } + + $session = AgentSession::where('session_id', $sessionId)->first(); + if (! $session) { + return null; + } + + $context = $session->getHandoffContext(); + + $markdown = "# Session: {$session->session_id}\n\n"; + $markdown .= "**Agent:** {$session->agent_type}\n"; + $markdown .= "**Status:** {$session->status}\n"; + $markdown .= "**Duration:** {$session->getDurationFormatted()}\n\n"; + + if ($session->plan) { + $markdown .= "## Plan\n\n"; + $markdown .= "**{$session->plan->title}** ({$session->plan->slug})\n\n"; + } + + if (! empty($context['context_summary'])) { + $markdown .= "## Context Summary\n\n"; + $markdown .= json_encode($context['context_summary'], JSON_PRETTY_PRINT)."\n\n"; + } + + if (! empty($context['handoff_notes'])) { + $markdown .= "## Handoff Notes\n\n"; + $markdown .= json_encode($context['handoff_notes'], JSON_PRETTY_PRINT)."\n\n"; + } + + if (! empty($context['artifacts'])) { + $markdown .= "## Artifacts\n\n"; + foreach ($context['artifacts'] as $artifact) { + $markdown .= "- {$artifact['action']}: {$artifact['path']}\n"; + } + $markdown .= "\n"; + } + + return $markdown; + } + + public function entries(): array + { + return []; + } + + private function sessionIdFor(string $uri): ?string + { + if (! str_starts_with($uri, 'sessions://')) { + return null; + } + + $parts = explode('/', substr($uri, strlen('sessions://'))); + + if (count($parts) !== 2 || $parts[1] !== 'context' || $parts[0] === '') { + return null; + } + + return $parts[0]; + } +} diff --git a/php/Mcp/Resources/Agent/StateValueResource.php b/php/Mcp/Resources/Agent/StateValueResource.php new file mode 100644 index 00000000..306d0512 --- /dev/null +++ b/php/Mcp/Resources/Agent/StateValueResource.php @@ -0,0 +1,73 @@ +read('plans://ship-the-thing/state/build_id'); + */ +final class StateValueResource extends AgentResource +{ + public function uriTemplate(): string + { + return 'plans://{slug}/state/{key}'; + } + + public function matches(string $uri): bool + { + return $this->partsFor($uri) !== null; + } + + public function read(string $uri): ?string + { + $parts = $this->partsFor($uri); + if ($parts === null) { + return null; + } + + [$slug, $key] = $parts; + + $plan = AgentPlan::where('slug', $slug)->first(); + if (! $plan) { + return null; + } + + $state = $plan->states()->where('key', $key)->first(); + + return $state?->getFormattedValue(); + } + + public function entries(): array + { + return []; + } + + /** + * @return array{0: string, 1: string}|null + */ + private function partsFor(string $uri): ?array + { + if (! str_starts_with($uri, 'plans://')) { + return null; + } + + $parts = explode('/', substr($uri, strlen('plans://'))); + + if (count($parts) !== 3 || $parts[1] !== 'state' || $parts[0] === '' || $parts[2] === '') { + return null; + } + + return [$parts[0], $parts[2]]; + } +} diff --git a/php/Services/AgentResourceRegistry.php b/php/Services/AgentResourceRegistry.php new file mode 100644 index 00000000..8be5bfd6 --- /dev/null +++ b/php/Services/AgentResourceRegistry.php @@ -0,0 +1,130 @@ + + */ + private array $resources = []; + + /** + * Register one resource. + * + * @example + * $registry->register(new AllPlansResource()); + */ + public function register(AgentResource $resource): self + { + $this->resources[] = $resource; + + return $this; + } + + /** + * Register several resources at once. + * + * @param iterable $resources + * + * @example + * $registry->registerMany([new AllPlansResource(), new PlanDocumentResource()]); + */ + public function registerMany(iterable $resources): self + { + foreach ($resources as $resource) { + $this->register($resource); + } + + return $this; + } + + /** + * Every entry resources/list should advertise. + * + * Resources contribute zero or more entries, so a per-plan resource can + * enumerate what actually exists while the unbounded per-key ones (phase + * checklists, state values) advertise nothing and stay directly addressable. + * + * @return array + * + * @example + * $registry->entries(); + */ + public function entries(): array + { + $entries = []; + + foreach ($this->resources as $resource) { + foreach ($resource->entries() as $entry) { + $entries[] = $entry; + } + } + + return $entries; + } + + /** + * Resolve the resource that serves a URI. + * + * @example + * $registry->resolve('plans://all'); + */ + public function resolve(string $uri): ?AgentResource + { + foreach ($this->resources as $resource) { + if ($resource->matches($uri)) { + return $resource; + } + } + + return null; + } + + /** + * Read a URI, or null when nothing serves it. + * + * Null covers both "no resource matches this shape" and "the shape matched + * but names something that does not exist", so the command answers a proper + * JSON-RPC error either way rather than handing back a document that reads + * like content but says "Plan not found". + * + * @return array{uri: string, mimeType: string, text: string}|null + * + * @example + * $registry->read('plans://ship-the-thing'); + */ + public function read(string $uri): ?array + { + $resource = $this->resolve($uri); + if (! $resource) { + return null; + } + + $text = $resource->read($uri); + if ($text === null) { + return null; + } + + return [ + 'uri' => $uri, + 'mimeType' => $resource->mimeType(), + 'text' => $text, + ]; + } +} diff --git a/php/tests/Feature/Mcp/Console/McpAgentServerCommandTest.php b/php/tests/Feature/Mcp/Console/McpAgentServerCommandTest.php index 5270abe1..cf0a9684 100644 --- a/php/tests/Feature/Mcp/Console/McpAgentServerCommandTest.php +++ b/php/tests/Feature/Mcp/Console/McpAgentServerCommandTest.php @@ -7,6 +7,7 @@ use Core\Mod\Agentic\Mcp\Console\McpAgentServerCommand; use Core\Mod\Agentic\Mcp\Services\McpQuotaService; use Core\Mod\Agentic\Mcp\Services\ToolRegistry; +use Core\Mod\Agentic\Models\AgentPlan; use Illuminate\Contracts\Console\Kernel; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Artisan; @@ -150,3 +151,67 @@ function mcpAgentServerRun(string $request): array ->and($result['responses'][0]['error']['code'])->toBe(-32001) ->and($result['responses'][0]['error']['message'])->toBe('MCP quota exceeded.'); }); + +test('McpAgentServerCommand_initialize_Good_advertises_the_resources_capability', function (): void { + // A client that is not told the server has resources never calls + // resources/list, so an unadvertised surface is undiscoverable. + $result = mcpAgentServerRun(json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + ]).PHP_EOL); + + $capabilities = $result['responses'][0]['result']['capabilities']; + + expect($capabilities)->toHaveKey('resources'); + expect($capabilities['resources']['list'])->toBeTrue(); + expect($capabilities['resources']['read'])->toBeTrue(); + expect($capabilities['tools']['call'])->toBeTrue(); +}); + +test('McpAgentServerCommand_resourcesList_Good_returns_the_agent_resource_surface', function (): void { + AgentPlan::factory()->create(['slug' => 'listed-plan', 'title' => 'Listed Plan']); + + $result = mcpAgentServerRun(json_encode([ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'resources/list', + ]).PHP_EOL); + + $uris = array_column($result['responses'][0]['result']['resources'], 'uri'); + + // Previously this answered a hardcoded empty array. + expect($uris)->toContain('plans://all'); + expect($uris)->toContain('plans://listed-plan'); +}); + +test('McpAgentServerCommand_resourcesRead_Good_returns_plan_contents', function (): void { + AgentPlan::factory()->create(['slug' => 'readable-plan', 'title' => 'Readable Plan']); + + $result = mcpAgentServerRun(json_encode([ + 'jsonrpc' => '2.0', + 'id' => 3, + 'method' => 'resources/read', + 'params' => ['uri' => 'plans://all'], + ]).PHP_EOL); + + $contents = $result['responses'][0]['result']['contents'][0]; + + expect($contents['uri'])->toBe('plans://all'); + expect($contents['mimeType'])->toBe('text/markdown'); + expect($contents['text'])->toContain('readable-plan'); +}); + +test('McpAgentServerCommand_resourcesRead_Bad_errors_for_an_unknown_uri', function (): void { + $result = mcpAgentServerRun(json_encode([ + 'jsonrpc' => '2.0', + 'id' => 4, + 'method' => 'resources/read', + 'params' => ['uri' => 'plans://does-not-exist'], + ]).PHP_EOL); + + // An error, not a body reading "Plan not found" — a client can tell the + // difference between a missing plan and a plan whose text says that. + expect($result['responses'][0])->toHaveKey('error'); + expect($result['responses'][0]['error']['code'])->toBe(-32602); +}); diff --git a/php/tests/Feature/Mcp/Resources/AgentResourcesTest.php b/php/tests/Feature/Mcp/Resources/AgentResourcesTest.php new file mode 100644 index 00000000..7a2c3c3e --- /dev/null +++ b/php/tests/Feature/Mcp/Resources/AgentResourcesTest.php @@ -0,0 +1,105 @@ +registerMany([ + new AllPlansResource, + new PlanDocumentResource, + new PhaseChecklistResource, + new StateValueResource, + new SessionContextResource, + ]); +} + +it('routes each URI shape to exactly one resource', function (): void { + $registry = agentResourceRegistry(); + + expect($registry->resolve('plans://all'))->toBeInstanceOf(AllPlansResource::class) + ->and($registry->resolve('plans://my-plan'))->toBeInstanceOf(PlanDocumentResource::class) + ->and($registry->resolve('plans://my-plan/phases/2'))->toBeInstanceOf(PhaseChecklistResource::class) + ->and($registry->resolve('plans://my-plan/state/build_id'))->toBeInstanceOf(StateValueResource::class) + ->and($registry->resolve('sessions://abc123/context'))->toBeInstanceOf(SessionContextResource::class); +}); + +it('claims no URI outside the shapes it serves', function (): void { + $registry = agentResourceRegistry(); + + // plans://all must not be swallowed by the {slug} resource, and a shape + // that is nearly right must not be served with the wrong reader. + expect($registry->resolve('plans://'))->toBeNull() + ->and($registry->resolve('plans://my-plan/phases/latest'))->toBeNull() + ->and($registry->resolve('plans://my-plan/state/'))->toBeNull() + ->and($registry->resolve('sessions://abc123'))->toBeNull() + ->and($registry->resolve('nonsense://thing'))->toBeNull(); +}); + +it('reads the all-plans overview', function (): void { + AgentPlan::factory()->create(['slug' => 'first-plan', 'title' => 'First Plan']); + + $contents = agentResourceRegistry()->read('plans://all'); + + expect($contents)->not->toBeNull() + ->and($contents['uri'])->toBe('plans://all') + ->and($contents['mimeType'])->toBe('text/markdown') + ->and($contents['text'])->toContain('# Work Plans') + ->and($contents['text'])->toContain('first-plan'); +}); + +it('reads one plan document', function (): void { + AgentPlan::factory()->create(['slug' => 'ship-it', 'title' => 'Ship It']); + + $contents = agentResourceRegistry()->read('plans://ship-it'); + + expect($contents)->not->toBeNull() + ->and($contents['uri'])->toBe('plans://ship-it') + ->and($contents['text'])->toBeString(); +}); + +it('advertises every non-archived plan in the listing', function (): void { + AgentPlan::factory()->create(['slug' => 'listed-plan', 'title' => 'Listed Plan']); + + $uris = array_column(agentResourceRegistry()->entries(), 'uri'); + + // The static overview plus a dynamic entry per plan — a client should see + // the plans that exist, not a template it has to guess slugs for. + expect($uris)->toContain('plans://all') + ->and($uris)->toContain('plans://listed-plan'); +}); + +it('returns null rather than a document when the target does not exist', function (): void { + $registry = agentResourceRegistry(); + + // The shape matches, so a resource claims it; the plan does not exist, so + // the read reports nothing. Previously this returned the string "Plan not + // found: ..." AS the resource body, which a client cannot tell apart from + // a real document that happens to say so. + expect($registry->resolve('plans://ghost'))->not->toBeNull() + ->and($registry->read('plans://ghost'))->toBeNull() + ->and($registry->read('plans://ghost/phases/1'))->toBeNull() + ->and($registry->read('plans://ghost/state/key'))->toBeNull() + ->and($registry->read('sessions://ghost/context'))->toBeNull(); +});