Skip to content

Commit a0debd8

Browse files
authored
feat(mcp): serve the agent resources surface that only dappcore/mcp had (#20)
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.
1 parent 1fd5971 commit a0debd8

11 files changed

Lines changed: 885 additions & 13 deletions

‎php/Boot.php‎

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@
1010
use Core\Events\ApiRoutesRegistering;
1111
use Core\Events\ConsoleBooting;
1212
use Core\Events\McpToolsRegistering;
13+
use Core\Mod\Agentic\Services\AgenticManager;
14+
use Core\Mod\Agentic\Services\AgentResourceRegistry;
15+
use Core\Mod\Agentic\Services\AgentToolRegistry;
16+
use Core\Mod\Agentic\Services\BrainService;
17+
use Core\Mod\Agentic\Services\ForgejoService;
1318
use Illuminate\Cache\RateLimiting\Limit;
1419
use Illuminate\Console\Scheduling\Schedule;
1520
use Illuminate\Http\Request;
@@ -90,17 +95,34 @@ public function register(): void
9095
config(['database.connections.brain' => $brainDb]);
9196
}
9297

93-
$this->app->singleton(\Core\Mod\Agentic\Services\AgenticManager::class);
94-
$this->app->singleton(\Core\Mod\Agentic\Services\AgentToolRegistry::class);
98+
$this->app->singleton(AgenticManager::class);
99+
$this->app->singleton(AgentToolRegistry::class);
95100

96-
$this->app->singleton(\Core\Mod\Agentic\Services\ForgejoService::class, function ($app) {
97-
return new \Core\Mod\Agentic\Services\ForgejoService(
101+
// Resources are bound here rather than hung off an event the way tools
102+
// are. There is no McpResourcesRegistering to listen for, and $listens
103+
// is populated by ModuleScanner from app/Core|Mod|Website only — dead
104+
// once this package is installed under vendor/ — so a container binding
105+
// is what actually resolves in a host application.
106+
$this->app->singleton(
107+
AgentResourceRegistry::class,
108+
static fn (): AgentResourceRegistry => (new AgentResourceRegistry)
109+
->registerMany([
110+
new Mcp\Resources\Agent\AllPlansResource,
111+
new Mcp\Resources\Agent\PlanDocumentResource,
112+
new Mcp\Resources\Agent\PhaseChecklistResource,
113+
new Mcp\Resources\Agent\StateValueResource,
114+
new Mcp\Resources\Agent\SessionContextResource,
115+
]),
116+
);
117+
118+
$this->app->singleton(ForgejoService::class, function ($app) {
119+
return new ForgejoService(
98120
baseUrl: (string) config('agentic.forge_url', 'https://forge.lthn.ai'),
99121
token: (string) config('agentic.forge_token', ''),
100122
);
101123
});
102124

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

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

114-
return new \Core\Mod\Agentic\Services\BrainService(
136+
return new BrainService(
115137
ollamaUrl: $ollamaUrl,
116138
qdrantUrl: $qdrantUrl,
117139
collection: config('mcp.brain.collection', 'openbrain'),
@@ -201,7 +223,7 @@ public function onConsole(ConsoleBooting $event): void
201223
*/
202224
public function onMcpTools(McpToolsRegistering $event): void
203225
{
204-
$registry = $this->app->make(Services\AgentToolRegistry::class);
226+
$registry = $this->app->make(AgentToolRegistry::class);
205227

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

249271
$registry->registerMany(array_map(
250-
static fn (string $toolClass) => new $toolClass(),
272+
static fn (string $toolClass) => new $toolClass,
251273
$toolClasses,
252274
));
253275
}

‎php/Mcp/Console/McpAgentServerCommand.php‎

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use Core\Mod\Agentic\Mcp\Services\McpQuotaService;
1010
use Core\Mod\Agentic\Mcp\Services\QueryAuditService;
1111
use Core\Mod\Agentic\Mcp\Services\ToolRegistry;
12+
use Core\Mod\Agentic\Services\AgentResourceRegistry;
1213
use Illuminate\Console\Command;
1314
use InvalidArgumentException;
1415
use JsonException;
@@ -37,6 +38,7 @@ public function handle(
3738
ToolRegistry $toolRegistry,
3839
McpQuotaService $quotaService,
3940
QueryAuditService $queryAuditService,
41+
AgentResourceRegistry $resourceRegistry,
4042
): int {
4143
$inputPath = $this->streamPath('MCP_AGENT_SERVER_INPUT', 'php://stdin');
4244
$outputPath = $this->streamPath('MCP_AGENT_SERVER_OUTPUT', 'php://stdout');
@@ -64,6 +66,7 @@ public function handle(
6466
$toolRegistry,
6567
$quotaService,
6668
$queryAuditService,
69+
$resourceRegistry,
6770
);
6871

6972
if ($response === null) {
@@ -118,7 +121,8 @@ private function processPayload(
118121
ToolRegistry $toolRegistry,
119122
McpQuotaService $quotaService,
120123
QueryAuditService $queryAuditService,
121-
): array|null {
124+
AgentResourceRegistry $resourceRegistry,
125+
): ?array {
122126
try {
123127
$request = json_decode($payload, true, 512, JSON_THROW_ON_ERROR);
124128
} catch (JsonException) {
@@ -142,6 +146,7 @@ private function processPayload(
142146
$toolRegistry,
143147
$quotaService,
144148
$queryAuditService,
149+
$resourceRegistry,
145150
);
146151

147152
if ($response !== null) {
@@ -157,6 +162,7 @@ private function processPayload(
157162
$toolRegistry,
158163
$quotaService,
159164
$queryAuditService,
165+
$resourceRegistry,
160166
);
161167
}
162168

@@ -171,7 +177,8 @@ private function processRequest(
171177
ToolRegistry $toolRegistry,
172178
McpQuotaService $quotaService,
173179
QueryAuditService $queryAuditService,
174-
): array|null {
180+
AgentResourceRegistry $resourceRegistry,
181+
): ?array {
175182
$id = $request['id'] ?? null;
176183

177184
if (($request['jsonrpc'] ?? null) !== '2.0') {
@@ -199,6 +206,16 @@ private function processRequest(
199206
'list' => true,
200207
'call' => true,
201208
],
209+
// Advertised, not just implemented: a client that is not
210+
// told the server has resources never issues resources/list,
211+
// so an unadvertised surface is undiscoverable and might as
212+
// well not exist.
213+
'resources' => [
214+
'list' => true,
215+
'read' => true,
216+
'subscribe' => false,
217+
'listChanged' => true,
218+
],
202219
],
203220
], $id),
204221
'ping' => $this->successResponse(['ok' => true], $id),
@@ -221,7 +238,11 @@ private function processRequest(
221238
$quotaService,
222239
$queryAuditService,
223240
),
224-
'resources/list' => $this->successResponse(['resources' => []], $id),
241+
'resources/list' => $this->successResponse(
242+
['resources' => $resourceRegistry->entries()],
243+
$id,
244+
),
245+
'resources/read' => $this->handleResourceRead($params, $id, $resourceRegistry),
225246
default => $this->errorResponse(-32601, 'Method not found', $id),
226247
};
227248

@@ -340,13 +361,45 @@ private function handleToolCall(
340361
}
341362
}
342363

364+
/**
365+
* Serve a resources/read request.
366+
*
367+
* A URI that no resource claims, and one whose shape matches but whose
368+
* target does not exist, both answer -32602 rather than a body reading
369+
* "Plan not found" — that string was previously returned as the resource
370+
* content, so a client could not tell a missing plan from a plan whose
371+
* document happens to say that.
372+
*
373+
* @example
374+
* $response = $this->handleResourceRead(['uri' => 'plans://all'], 1, $resourceRegistry);
375+
*/
376+
private function handleResourceRead(
377+
array $params,
378+
mixed $id,
379+
AgentResourceRegistry $resourceRegistry,
380+
): array {
381+
$uri = $params['uri'] ?? null;
382+
383+
if (! is_string($uri) || $uri === '') {
384+
return $this->errorResponse(-32602, 'Invalid params', $id);
385+
}
386+
387+
$contents = $resourceRegistry->read($uri);
388+
389+
if ($contents === null) {
390+
return $this->errorResponse(-32602, "Resource not found: {$uri}", $id);
391+
}
392+
393+
return $this->successResponse(['contents' => [$contents]], $id);
394+
}
395+
343396
/**
344397
* Resolve the requested tool name from supported JSON-RPC parameter keys.
345398
*
346399
* @example
347400
* $toolName = $this->resolveToolName(['tool' => 'brain_list']);
348401
*/
349-
private function resolveToolName(array $params): string|null
402+
private function resolveToolName(array $params): ?string
350403
{
351404
$value = $params['name'] ?? $params['tool'] ?? null;
352405

@@ -376,7 +429,7 @@ private function workspaceId(array $params, array $arguments): string
376429
* @example
377430
* $query = $this->toolQuery(['query' => 'select * from memories']);
378431
*/
379-
private function toolQuery(array $arguments): string|null
432+
private function toolQuery(array $arguments): ?string
380433
{
381434
$query = $arguments['query'] ?? null;
382435

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<?php
2+
3+
// SPDX-License-Identifier: EUPL-1.2
4+
5+
declare(strict_types=1);
6+
7+
namespace Core\Mod\Agentic\Mcp\Resources\Agent;
8+
9+
/**
10+
* Base class for MCP Agent Server resources.
11+
*
12+
* Mirrors the shape of Mcp\Tools\Agent\AgentTool: one class per resource,
13+
* registered into a registry, rather than a handler method on a monolithic
14+
* command. These carry the plans/phases/sessions/state surface ported out of
15+
* dappcore/mcp, which owned it only because that command was filed in the
16+
* shared package — the data is entirely agent-domain.
17+
*
18+
* A resource answers two questions: what should `resources/list` advertise
19+
* (entries(), which may be dynamic), and what does `resources/read` return for
20+
* a given URI (matches() then read()).
21+
*/
22+
abstract class AgentResource
23+
{
24+
/**
25+
* The URI template this resource answers, for documentation and errors.
26+
*
27+
* @example
28+
* $resource->uriTemplate(); // 'plans://{slug}/phases/{order}'
29+
*/
30+
abstract public function uriTemplate(): string;
31+
32+
/**
33+
* Whether this resource can serve the given URI.
34+
*
35+
* @example
36+
* $resource->matches('plans://all'); // true
37+
*/
38+
abstract public function matches(string $uri): bool;
39+
40+
/**
41+
* Render the resource body for a URI this resource matches.
42+
*
43+
* Returns null when the URI matches the shape but names something that does
44+
* not exist, so the caller can answer "not found" rather than an empty
45+
* document that reads like real content.
46+
*
47+
* @example
48+
* $resource->read('plans://all');
49+
*/
50+
abstract public function read(string $uri): ?string;
51+
52+
/**
53+
* Entries this resource contributes to resources/list.
54+
*
55+
* Returns a list so one resource can advertise many URIs — the plan
56+
* document resource enumerates every non-archived plan.
57+
*
58+
* @return array<int, array{uri: string, name: string, description: string, mimeType: string}>
59+
*
60+
* @example
61+
* $resource->entries(); // [['uri' => 'plans://all', ...]]
62+
*/
63+
abstract public function entries(): array;
64+
65+
/**
66+
* The MIME type this resource renders. Markdown throughout.
67+
*/
68+
public function mimeType(): string
69+
{
70+
return 'text/markdown';
71+
}
72+
73+
/**
74+
* Build a single list entry, so subclasses do not repeat the shape.
75+
*
76+
* @return array{uri: string, name: string, description: string, mimeType: string}
77+
*/
78+
protected function entry(string $uri, string $name, string $description): array
79+
{
80+
return [
81+
'uri' => $uri,
82+
'name' => $name,
83+
'description' => $description,
84+
'mimeType' => $this->mimeType(),
85+
];
86+
}
87+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<?php
2+
3+
// SPDX-License-Identifier: EUPL-1.2
4+
5+
declare(strict_types=1);
6+
7+
namespace Core\Mod\Agentic\Mcp\Resources\Agent;
8+
9+
use Core\Mod\Agentic\Models\AgentPlan;
10+
11+
/**
12+
* plans://all — a markdown overview of every non-archived plan, grouped by
13+
* status with each plan's completion percentage.
14+
*
15+
* @example
16+
* (new AllPlansResource())->read('plans://all');
17+
*/
18+
final class AllPlansResource extends AgentResource
19+
{
20+
public const URI = 'plans://all';
21+
22+
public function uriTemplate(): string
23+
{
24+
return self::URI;
25+
}
26+
27+
public function matches(string $uri): bool
28+
{
29+
return $uri === self::URI;
30+
}
31+
32+
public function read(string $uri): ?string
33+
{
34+
if (! $this->matches($uri)) {
35+
return null;
36+
}
37+
38+
$plans = AgentPlan::with('agentPhases')
39+
->notArchived()
40+
->orderBy('updated_at', 'desc')
41+
->get();
42+
43+
$markdown = "# Work Plans\n\n";
44+
$markdown .= '**Total:** '.$plans->count()." plan(s)\n\n";
45+
46+
foreach ($plans->groupBy('status') as $status => $group) {
47+
$markdown .= '## '.ucfirst((string) $status).' ('.$group->count().")\n\n";
48+
49+
foreach ($group as $plan) {
50+
$progress = $plan->getProgress();
51+
$markdown .= "- **[{$plan->slug}]** {$plan->title} - {$progress['percentage']}%\n";
52+
}
53+
54+
$markdown .= "\n";
55+
}
56+
57+
return $markdown;
58+
}
59+
60+
public function entries(): array
61+
{
62+
return [
63+
$this->entry(self::URI, 'All Plans Overview', 'Overview of all work plans and their status'),
64+
];
65+
}
66+
}

0 commit comments

Comments
 (0)