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
59 changes: 59 additions & 0 deletions php/Mcp/Dependencies/DependencyType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

// SPDX-License-Identifier: EUPL-1.2

declare(strict_types=1);

namespace Core\Mcp\Dependencies;

/**
* Types of tool dependencies.
*
* Defines how a prerequisite must be satisfied before a tool can execute.
*/
enum DependencyType: string
{
/**
* Another tool must have been called in the current session.
* Example: task_update requires plan_create to have been called.
*/
case TOOL_CALLED = 'tool_called';

/**
* A specific state key must exist in the session context.
* Example: session_log requires session_id to be set.
*/
case SESSION_STATE = 'session_state';

/**
* A specific context value must be present.
* Example: workspace_id must exist for workspace-scoped tools.
*/
case CONTEXT_EXISTS = 'context_exists';

/**
* A database entity must exist (checked by ID or slug).
* Example: task_update requires the plan_slug to reference an existing plan.
*/
case ENTITY_EXISTS = 'entity_exists';

/**
* A custom condition evaluated at runtime.
* Example: Complex business rules that don't fit other types.
*/
case CUSTOM = 'custom';

/**
* Get a human-readable label for this dependency type.
*/
public function label(): string
{
return match ($this) {
self::TOOL_CALLED => 'Tool must be called first',
self::SESSION_STATE => 'Session state required',
self::CONTEXT_EXISTS => 'Context value required',
self::ENTITY_EXISTS => 'Entity must exist',
self::CUSTOM => 'Custom condition',
};
}
}
23 changes: 23 additions & 0 deletions php/Mcp/Dependencies/HasDependencies.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

// SPDX-License-Identifier: EUPL-1.2

declare(strict_types=1);

namespace Core\Mcp\Dependencies;

/**
* Interface for tools that declare dependencies.
*
* Tools implementing this interface can specify prerequisites
* that must be satisfied before execution.
*/
interface HasDependencies
{
/**
* Get the dependencies for this tool.
*
* @return array<ToolDependency>
*/
public function dependencies(): array;
}
136 changes: 136 additions & 0 deletions php/Mcp/Dependencies/ToolDependency.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
<?php

// SPDX-License-Identifier: EUPL-1.2

declare(strict_types=1);

namespace Core\Mcp\Dependencies;

/**
* Represents a single tool dependency.
*
* Defines what must be satisfied before a tool can execute.
*/
class ToolDependency
{
/**
* Create a new tool dependency.
*
* @param DependencyType $type The type of dependency
* @param string $key The identifier (tool name, state key, context key, etc.)
* @param string|null $description Human-readable description for error messages
* @param bool $optional If true, this is a soft dependency (warning, not error)
* @param array $metadata Additional metadata for custom validation
*/
public function __construct(
public readonly DependencyType $type,
public readonly string $key,
public readonly ?string $description = null,
public readonly bool $optional = false,
public readonly array $metadata = [],
) {}

/**
* Create a tool_called dependency.
*/
public static function toolCalled(string $toolName, ?string $description = null): self
{
return new self(
type: DependencyType::TOOL_CALLED,
key: $toolName,
description: $description ?? "Tool '{$toolName}' must be called first",
);
}

/**
* Create a session_state dependency.
*/
public static function sessionState(string $stateKey, ?string $description = null): self
{
return new self(
type: DependencyType::SESSION_STATE,
key: $stateKey,
description: $description ?? "Session state '{$stateKey}' is required",
);
}

/**
* Create a context_exists dependency.
*/
public static function contextExists(string $contextKey, ?string $description = null): self
{
return new self(
type: DependencyType::CONTEXT_EXISTS,
key: $contextKey,
description: $description ?? "Context '{$contextKey}' is required",
);
}

/**
* Create an entity_exists dependency.
*/
public static function entityExists(string $entityType, ?string $description = null, array $metadata = []): self
{
return new self(
type: DependencyType::ENTITY_EXISTS,
key: $entityType,
description: $description ?? "Entity '{$entityType}' must exist",
metadata: $metadata,
);
}

/**
* Create a custom dependency with callback metadata.
*/
public static function custom(string $name, ?string $description = null, array $metadata = []): self
{
return new self(
type: DependencyType::CUSTOM,
key: $name,
description: $description,
metadata: $metadata,
);
}

/**
* Mark this dependency as optional (soft dependency).
*/
public function asOptional(): self
{
return new self(
type: $this->type,
key: $this->key,
description: $this->description,
optional: true,
metadata: $this->metadata,
);
}

/**
* Convert to array representation.
*/
public function toArray(): array
{
return [
'type' => $this->type->value,
'key' => $this->key,
'description' => $this->description,
'optional' => $this->optional,
'metadata' => $this->metadata,
];
}

/**
* Create from array representation.
*/
public static function fromArray(array $data): self
{
return new self(
type: DependencyType::from($data['type']),
key: $data['key'],
description: $data['description'] ?? null,
optional: $data['optional'] ?? false,
metadata: $data['metadata'] ?? [],
);
}
}
62 changes: 62 additions & 0 deletions php/Mcp/Exceptions/MissingDependencyException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

// SPDX-License-Identifier: EUPL-1.2

declare(strict_types=1);

namespace Core\Mcp\Exceptions;

use RuntimeException;

/**
* Thrown when a tool's declared dependencies are not satisfied.
*
* Shaped for this repo's ToolDependencyService, not copied from dappcore/mcp's
* version of the same class. That one takes
* (string $toolName, array $missingDependencies, array $suggestedOrder) and
* builds its own message, while this service raises it with a single,
* already-composed message:
*
* $exceptionClass = 'Core\Mcp\Exceptions\MissingDependencyException';
* if (class_exists($exceptionClass)) {
* return new $exceptionClass($message);
* }
*
* Rolling the upstream signature in verbatim would have turned a class-not-found
* into an ArgumentCountError the first time a dependency went unmet — a
* different fatal, not a fix. The extra detail stays available as optional
* constructor arguments, so callers that have it can pass it.
*/
class MissingDependencyException extends RuntimeException
{
/**
* @param array<int, array{tool: string, type: string, key: string, message: string}> $missingDependencies
* The rows ToolDependencyService::missing() returns — arrays, not
* ToolDependency objects, because that is what this service produces.
* @param array<int, string> $suggestedOrder Tools worth calling first.
*/
public function __construct(
string $message,
public readonly array $missingDependencies = [],
public readonly string $toolName = '',
public readonly array $suggestedOrder = [],
) {
parent::__construct($message);
}

/**
* The dependency keys that were not satisfied.
*
* @return array<int, string>
*
* @example
* $exception->missingKeys(); // ['workspace_id']
*/
public function missingKeys(): array
{
return array_values(array_filter(array_map(
static fn (array $dependency): string => (string) ($dependency['key'] ?? ''),
$this->missingDependencies,
)));
}
}
16 changes: 16 additions & 0 deletions php/Mcp/Services/ToolDependencyService.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
namespace Core\Mod\Agentic\Mcp\Services;

use Carbon\CarbonImmutable;
use Core\Mcp\Dependencies\ToolDependency;
use Illuminate\Container\Container;
use InvalidArgumentException;
use RuntimeException;
Expand Down Expand Up @@ -272,6 +273,21 @@ private function normaliseDependency(mixed $dependency): array
];
}

if ($dependency instanceof ToolDependency) {
// toArray(), not get_object_vars(): the object holds $type as a
// DependencyType enum and names its text $description. The array
// branch below casts type to string — which fatals on an enum — and
// looks for 'message', so a raw property dump both breaks and
// silently loses the description.
$fields = $dependency->toArray();
$fields['message'] = $fields['description'] ?? null;

return $this->normaliseDependency(array_filter(
$fields,
static fn (mixed $value): bool => $value !== null,
));
}

if (is_object($dependency)) {
$vars = get_object_vars($dependency);

Expand Down
Loading
Loading