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(); +});