Skip to content

Commit 9c9467d

Browse files
authored
fix(mcp): roll the dependency contracts, so the tool classes can be built (#21)
AgentTool does `use ValidatesDependencies` and `implements HasDependencies`, and neither symbol has ever existed in this repository — they live in dappcore/mcp, which this package deliberately does not depend on. Every class extending AgentTool was therefore fatal on load: php -r 'new \Core\Mod\Agentic\Mcp\Tools\Agent\Brain\BrainRemember();' FATAL: Trait "Core\Mcp\Tools\Concerns\ValidatesDependencies" not found All forty registered tool classes, unconstructible in every environment. Not "unregistered" — unbuildable. The dead $listens event registration that meant nothing ever tried to instantiate one was anaesthetic, not a second bug: make registration live without this and the suite goes from 156 failures to 1321. The design is a local copy of Core\Mcp, not a dependency on it, so the fix is to finish the copy. Three contracts roll in unchanged from dappcore/mcp, which now holds the canonical versions: DependencyType, ToolDependency and HasDependencies. Each gains the SPDX header this repo requires. Two could NOT be rolled verbatim, because upstream's and this repo's ToolDependencyService share a name and not an API. MissingDependencyException upstream takes (string $toolName, array $missingDependencies, array $suggestedOrder) and composes its own message. This service raises it as `new $exceptionClass($message)` — one argument. Copying the upstream signature would have replaced a class-not-found with an ArgumentCountError the first time a dependency went unmet. It is written message-first, with the detail as optional arguments. ValidatesDependencies upstream calls checkDependencies() and getMissingDependencies(), which do not exist here — the equivalents are canExecute() and missing() — and passes named arguments to a method declared `validateDependencies(mixed ...$arguments)`, where they bind to nothing. It is written against the API this repo actually has, positionally, and returns the service's own {tool, type, key, message} rows rather than pretending to hand back ToolDependency objects. Two supporting fixes fall out. AgentToolRegistry imported Core\Mcp\Services\ToolDependencyService, which resolves to this repo's php/Mcp/Services/ToolDependencyService.php — a file declaring Core\Mod\Agentic\Mcp\Services\ToolDependencyService — so autoloading it raised "Cannot redeclare". It now imports the class that is actually there. And normaliseDependency() met ToolDependency objects with get_object_vars(), which hands the array branch a DependencyType enum where it casts to string, and loses the text because the object calls it description where the service reads message; it now goes through toArray() and maps the field across. Sixteen tools declare real dependencies via ToolDependency::contextExists(), so this path is live, not hypothetical. Receipts: all 40 tool classes construct, verified by instantiating every one. Suite 131 failed / 1190 passed, from 156 / 1165 — exactly the 25-test ValidatesDependencies cluster fixed, zero new failures, confirmed by diffing failing test names either side.
1 parent a0debd8 commit 9c9467d

7 files changed

Lines changed: 457 additions & 2 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
// SPDX-License-Identifier: EUPL-1.2
4+
5+
declare(strict_types=1);
6+
7+
namespace Core\Mcp\Dependencies;
8+
9+
/**
10+
* Types of tool dependencies.
11+
*
12+
* Defines how a prerequisite must be satisfied before a tool can execute.
13+
*/
14+
enum DependencyType: string
15+
{
16+
/**
17+
* Another tool must have been called in the current session.
18+
* Example: task_update requires plan_create to have been called.
19+
*/
20+
case TOOL_CALLED = 'tool_called';
21+
22+
/**
23+
* A specific state key must exist in the session context.
24+
* Example: session_log requires session_id to be set.
25+
*/
26+
case SESSION_STATE = 'session_state';
27+
28+
/**
29+
* A specific context value must be present.
30+
* Example: workspace_id must exist for workspace-scoped tools.
31+
*/
32+
case CONTEXT_EXISTS = 'context_exists';
33+
34+
/**
35+
* A database entity must exist (checked by ID or slug).
36+
* Example: task_update requires the plan_slug to reference an existing plan.
37+
*/
38+
case ENTITY_EXISTS = 'entity_exists';
39+
40+
/**
41+
* A custom condition evaluated at runtime.
42+
* Example: Complex business rules that don't fit other types.
43+
*/
44+
case CUSTOM = 'custom';
45+
46+
/**
47+
* Get a human-readable label for this dependency type.
48+
*/
49+
public function label(): string
50+
{
51+
return match ($this) {
52+
self::TOOL_CALLED => 'Tool must be called first',
53+
self::SESSION_STATE => 'Session state required',
54+
self::CONTEXT_EXISTS => 'Context value required',
55+
self::ENTITY_EXISTS => 'Entity must exist',
56+
self::CUSTOM => 'Custom condition',
57+
};
58+
}
59+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
// SPDX-License-Identifier: EUPL-1.2
4+
5+
declare(strict_types=1);
6+
7+
namespace Core\Mcp\Dependencies;
8+
9+
/**
10+
* Interface for tools that declare dependencies.
11+
*
12+
* Tools implementing this interface can specify prerequisites
13+
* that must be satisfied before execution.
14+
*/
15+
interface HasDependencies
16+
{
17+
/**
18+
* Get the dependencies for this tool.
19+
*
20+
* @return array<ToolDependency>
21+
*/
22+
public function dependencies(): array;
23+
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
<?php
2+
3+
// SPDX-License-Identifier: EUPL-1.2
4+
5+
declare(strict_types=1);
6+
7+
namespace Core\Mcp\Dependencies;
8+
9+
/**
10+
* Represents a single tool dependency.
11+
*
12+
* Defines what must be satisfied before a tool can execute.
13+
*/
14+
class ToolDependency
15+
{
16+
/**
17+
* Create a new tool dependency.
18+
*
19+
* @param DependencyType $type The type of dependency
20+
* @param string $key The identifier (tool name, state key, context key, etc.)
21+
* @param string|null $description Human-readable description for error messages
22+
* @param bool $optional If true, this is a soft dependency (warning, not error)
23+
* @param array $metadata Additional metadata for custom validation
24+
*/
25+
public function __construct(
26+
public readonly DependencyType $type,
27+
public readonly string $key,
28+
public readonly ?string $description = null,
29+
public readonly bool $optional = false,
30+
public readonly array $metadata = [],
31+
) {}
32+
33+
/**
34+
* Create a tool_called dependency.
35+
*/
36+
public static function toolCalled(string $toolName, ?string $description = null): self
37+
{
38+
return new self(
39+
type: DependencyType::TOOL_CALLED,
40+
key: $toolName,
41+
description: $description ?? "Tool '{$toolName}' must be called first",
42+
);
43+
}
44+
45+
/**
46+
* Create a session_state dependency.
47+
*/
48+
public static function sessionState(string $stateKey, ?string $description = null): self
49+
{
50+
return new self(
51+
type: DependencyType::SESSION_STATE,
52+
key: $stateKey,
53+
description: $description ?? "Session state '{$stateKey}' is required",
54+
);
55+
}
56+
57+
/**
58+
* Create a context_exists dependency.
59+
*/
60+
public static function contextExists(string $contextKey, ?string $description = null): self
61+
{
62+
return new self(
63+
type: DependencyType::CONTEXT_EXISTS,
64+
key: $contextKey,
65+
description: $description ?? "Context '{$contextKey}' is required",
66+
);
67+
}
68+
69+
/**
70+
* Create an entity_exists dependency.
71+
*/
72+
public static function entityExists(string $entityType, ?string $description = null, array $metadata = []): self
73+
{
74+
return new self(
75+
type: DependencyType::ENTITY_EXISTS,
76+
key: $entityType,
77+
description: $description ?? "Entity '{$entityType}' must exist",
78+
metadata: $metadata,
79+
);
80+
}
81+
82+
/**
83+
* Create a custom dependency with callback metadata.
84+
*/
85+
public static function custom(string $name, ?string $description = null, array $metadata = []): self
86+
{
87+
return new self(
88+
type: DependencyType::CUSTOM,
89+
key: $name,
90+
description: $description,
91+
metadata: $metadata,
92+
);
93+
}
94+
95+
/**
96+
* Mark this dependency as optional (soft dependency).
97+
*/
98+
public function asOptional(): self
99+
{
100+
return new self(
101+
type: $this->type,
102+
key: $this->key,
103+
description: $this->description,
104+
optional: true,
105+
metadata: $this->metadata,
106+
);
107+
}
108+
109+
/**
110+
* Convert to array representation.
111+
*/
112+
public function toArray(): array
113+
{
114+
return [
115+
'type' => $this->type->value,
116+
'key' => $this->key,
117+
'description' => $this->description,
118+
'optional' => $this->optional,
119+
'metadata' => $this->metadata,
120+
];
121+
}
122+
123+
/**
124+
* Create from array representation.
125+
*/
126+
public static function fromArray(array $data): self
127+
{
128+
return new self(
129+
type: DependencyType::from($data['type']),
130+
key: $data['key'],
131+
description: $data['description'] ?? null,
132+
optional: $data['optional'] ?? false,
133+
metadata: $data['metadata'] ?? [],
134+
);
135+
}
136+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
3+
// SPDX-License-Identifier: EUPL-1.2
4+
5+
declare(strict_types=1);
6+
7+
namespace Core\Mcp\Exceptions;
8+
9+
use RuntimeException;
10+
11+
/**
12+
* Thrown when a tool's declared dependencies are not satisfied.
13+
*
14+
* Shaped for this repo's ToolDependencyService, not copied from dappcore/mcp's
15+
* version of the same class. That one takes
16+
* (string $toolName, array $missingDependencies, array $suggestedOrder) and
17+
* builds its own message, while this service raises it with a single,
18+
* already-composed message:
19+
*
20+
* $exceptionClass = 'Core\Mcp\Exceptions\MissingDependencyException';
21+
* if (class_exists($exceptionClass)) {
22+
* return new $exceptionClass($message);
23+
* }
24+
*
25+
* Rolling the upstream signature in verbatim would have turned a class-not-found
26+
* into an ArgumentCountError the first time a dependency went unmet — a
27+
* different fatal, not a fix. The extra detail stays available as optional
28+
* constructor arguments, so callers that have it can pass it.
29+
*/
30+
class MissingDependencyException extends RuntimeException
31+
{
32+
/**
33+
* @param array<int, array{tool: string, type: string, key: string, message: string}> $missingDependencies
34+
* The rows ToolDependencyService::missing() returns — arrays, not
35+
* ToolDependency objects, because that is what this service produces.
36+
* @param array<int, string> $suggestedOrder Tools worth calling first.
37+
*/
38+
public function __construct(
39+
string $message,
40+
public readonly array $missingDependencies = [],
41+
public readonly string $toolName = '',
42+
public readonly array $suggestedOrder = [],
43+
) {
44+
parent::__construct($message);
45+
}
46+
47+
/**
48+
* The dependency keys that were not satisfied.
49+
*
50+
* @return array<int, string>
51+
*
52+
* @example
53+
* $exception->missingKeys(); // ['workspace_id']
54+
*/
55+
public function missingKeys(): array
56+
{
57+
return array_values(array_filter(array_map(
58+
static fn (array $dependency): string => (string) ($dependency['key'] ?? ''),
59+
$this->missingDependencies,
60+
)));
61+
}
62+
}

‎php/Mcp/Services/ToolDependencyService.php‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
namespace Core\Mod\Agentic\Mcp\Services;
88

99
use Carbon\CarbonImmutable;
10+
use Core\Mcp\Dependencies\ToolDependency;
1011
use Illuminate\Container\Container;
1112
use InvalidArgumentException;
1213
use RuntimeException;
@@ -272,6 +273,21 @@ private function normaliseDependency(mixed $dependency): array
272273
];
273274
}
274275

276+
if ($dependency instanceof ToolDependency) {
277+
// toArray(), not get_object_vars(): the object holds $type as a
278+
// DependencyType enum and names its text $description. The array
279+
// branch below casts type to string — which fatals on an enum — and
280+
// looks for 'message', so a raw property dump both breaks and
281+
// silently loses the description.
282+
$fields = $dependency->toArray();
283+
$fields['message'] = $fields['description'] ?? null;
284+
285+
return $this->normaliseDependency(array_filter(
286+
$fields,
287+
static fn (mixed $value): bool => $value !== null,
288+
));
289+
}
290+
275291
if (is_object($dependency)) {
276292
$vars = get_object_vars($dependency);
277293

0 commit comments

Comments
 (0)