feat: group tools by integration with inherited group mode and "nur lesen" switch
Tools tab shows one block per integration with a group mode (inherited by its tools) and a read-only switch that disables all non-readonly abilities of the group. ExposurePolicy resolves tool override > group override > tool default; lit/guide keeps its default, direct-only tools fall back to direct under a group "indirekt". Group entries live in lit_mcp_exposure under "@group:<id>" and "@readonly:<id>"; the old flat format stays valid. The ability->group map comes lazily from IntegrationRegistry (Divi is not loaded at plugins_loaded). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+176
-41
@@ -4,18 +4,24 @@ declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Admin;
|
||||
|
||||
use LIT\MCP\Abilities\Ability;
|
||||
use LIT\MCP\Abilities\ExposurePolicy;
|
||||
use LIT\MCP\Integrations\IntegrationRegistry;
|
||||
|
||||
/**
|
||||
* Tab "Tools": direct/indirect/disabled exposure per ability (spec §8.4). Only the modes
|
||||
* ExposurePolicy::isAllowed() allows for a given ability are offered; "Standard" saves nothing,
|
||||
* so a later version's changed default takes effect automatically.
|
||||
* Tab "Tools": direct/indirect/disabled exposure per ability (spec §8.4), grouped by the
|
||||
* integration the ability comes from. Each group has its own mode (inherited by its tools)
|
||||
* and a "nur lesen" switch. Only the modes ExposurePolicy::isAllowed() allows for a given
|
||||
* ability are offered; "Standard"/"wie Gruppe" saves nothing, so a later version's changed
|
||||
* default takes effect automatically.
|
||||
*/
|
||||
final class ToolsTab implements AdminTab
|
||||
{
|
||||
public const GROUP = 'lit_mcp_tools';
|
||||
|
||||
/** More direct tools than this cost noticeable context in every claude.ai conversation. */
|
||||
public const DIRECT_WARNING_THRESHOLD = 25;
|
||||
|
||||
public function __construct(private IntegrationRegistry $registry, private ExposurePolicy $policy)
|
||||
{
|
||||
}
|
||||
@@ -34,11 +40,21 @@ final class ToolsTab implements AdminTab
|
||||
{
|
||||
register_setting(self::GROUP, ExposurePolicy::OPTION, [
|
||||
'type' => 'array',
|
||||
'sanitize_callback' => [ExposurePolicy::class, 'sanitize'],
|
||||
'sanitize_callback' => [$this, 'sanitize'],
|
||||
'default' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize callback: ExposurePolicy::sanitize() restricted to the known group ids.
|
||||
*
|
||||
* @return array<string, string|true>
|
||||
*/
|
||||
public function sanitize(mixed $input): array
|
||||
{
|
||||
return ExposurePolicy::sanitize($input, $this->registry->ids());
|
||||
}
|
||||
|
||||
public function registerActions(): void
|
||||
{
|
||||
}
|
||||
@@ -64,50 +80,169 @@ final class ToolsTab implements AdminTab
|
||||
'Das Divi-Code-Modul ist immer erlaubt (nur nachrangig) und hat keine eigene Einstellung.',
|
||||
'wp-lit-mcp'
|
||||
) . '</p>';
|
||||
$policy = $this->policy->withGroups($this->registry->groupMap());
|
||||
$this->renderCounter(count($policy->directNames($this->registry->abilities())));
|
||||
|
||||
echo '<form method="post" action="options.php">';
|
||||
settings_fields(self::GROUP);
|
||||
echo '<table class="widefat striped"><thead><tr><th>' . esc_html__('Tool', 'wp-lit-mcp') . '</th><th>'
|
||||
. esc_html__('Einstellung', 'wp-lit-mcp') . '</th></tr></thead><tbody>';
|
||||
|
||||
foreach ($this->registry->abilities() as $ability) {
|
||||
$name = $ability->name();
|
||||
$current = $this->policy->mode($ability);
|
||||
$isDefault = $current === $this->policy->defaultMode($ability);
|
||||
printf(
|
||||
'<tr><td><strong>%s</strong><br><code>%s</code></td><td><select name="%s[%s]">',
|
||||
esc_html($ability->label()),
|
||||
esc_html($name),
|
||||
esc_attr(ExposurePolicy::OPTION),
|
||||
esc_attr($name)
|
||||
);
|
||||
printf(
|
||||
'<option value=""%s>%s</option>',
|
||||
selected($isDefault, true, false),
|
||||
esc_html(sprintf(
|
||||
/* translators: %s: default mode */
|
||||
__('Standard (%s)', 'wp-lit-mcp'),
|
||||
$this->modeLabel($this->policy->defaultMode($ability))
|
||||
))
|
||||
);
|
||||
foreach (ExposurePolicy::MODES as $mode) {
|
||||
if (! ExposurePolicy::isAllowed($name, $mode)) {
|
||||
continue;
|
||||
}
|
||||
printf(
|
||||
'<option value="%s"%s>%s</option>',
|
||||
esc_attr($mode),
|
||||
selected(! $isDefault && $current === $mode, true, false),
|
||||
esc_html($this->modeLabel($mode))
|
||||
);
|
||||
}
|
||||
echo '</select></td></tr>';
|
||||
foreach ($this->registry->grouped() as $group => $abilities) {
|
||||
$this->renderGroup($policy, $group, $abilities);
|
||||
}
|
||||
|
||||
echo '</tbody></table>';
|
||||
submit_button();
|
||||
echo '</form>';
|
||||
}
|
||||
|
||||
private function renderCounter(int $direct): void
|
||||
{
|
||||
echo '<p><strong>' . esc_html(sprintf(
|
||||
/* translators: %d: number of direct tools */
|
||||
_n('Direkt sichtbar: %d Tool', 'Direkt sichtbar: %d Tools', $direct, 'wp-lit-mcp'),
|
||||
$direct
|
||||
)) . '</strong></p>';
|
||||
|
||||
if ($direct > self::DIRECT_WARNING_THRESHOLD) {
|
||||
echo '<p class="description"><strong>' . esc_html__('Achtung:', 'wp-lit-mcp') . '</strong> '
|
||||
. esc_html__(
|
||||
'Viele direkte Tools kosten in claude.ai in jeder Unterhaltung Kontext.',
|
||||
'wp-lit-mcp'
|
||||
) . ' '
|
||||
. esc_html__('Selten gebrauchte Tools besser auf „indirekt“ stellen.', 'wp-lit-mcp')
|
||||
. '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
/** @param list<Ability> $abilities */
|
||||
private function renderGroup(ExposurePolicy $policy, string $group, array $abilities): void
|
||||
{
|
||||
$count = count($abilities);
|
||||
$mixed = false;
|
||||
foreach ($abilities as $ability) {
|
||||
$mixed = $mixed || $policy->hasToolOverride($ability->name());
|
||||
}
|
||||
|
||||
printf(
|
||||
'<details open class="lit-mcp-tool-group"><summary>%s</summary>',
|
||||
esc_html(sprintf(
|
||||
/* translators: 1: group label, 2: number of tools */
|
||||
_n('%1$s (%2$d Tool)', '%1$s (%2$d Tools)', $count, 'wp-lit-mcp'),
|
||||
$this->groupLabel($group),
|
||||
$count
|
||||
))
|
||||
);
|
||||
|
||||
$groupMode = $policy->groupMode($group);
|
||||
printf(
|
||||
'<p><label>%s <select name="%s[%s]">',
|
||||
esc_html__('Ganze Gruppe:', 'wp-lit-mcp'),
|
||||
esc_attr(ExposurePolicy::OPTION),
|
||||
esc_attr(ExposurePolicy::GROUP_PREFIX . $group)
|
||||
);
|
||||
printf(
|
||||
'<option value=""%s>%s</option>',
|
||||
selected($groupMode === null, true, false),
|
||||
esc_html__('Standard der Tools', 'wp-lit-mcp')
|
||||
);
|
||||
foreach (ExposurePolicy::MODES as $mode) {
|
||||
printf(
|
||||
'<option value="%s"%s>%s</option>',
|
||||
esc_attr($mode),
|
||||
selected($groupMode === $mode, true, false),
|
||||
esc_html($this->modeLabel($mode))
|
||||
);
|
||||
}
|
||||
echo '</select></label> ';
|
||||
printf(
|
||||
'<label><input type="checkbox" name="%s[%s]" value="1"%s> %s</label>',
|
||||
esc_attr(ExposurePolicy::OPTION),
|
||||
esc_attr(ExposurePolicy::READONLY_PREFIX . $group),
|
||||
checked($policy->isReadonly($group), true, false),
|
||||
esc_html__('nur lesen (schreibende Tools dieser Gruppe aus)', 'wp-lit-mcp')
|
||||
);
|
||||
if ($mixed) {
|
||||
echo ' <em>' . esc_html__('gemischt – einzelne Tools haben eigene Einstellungen', 'wp-lit-mcp') . '</em>';
|
||||
}
|
||||
echo '</p>';
|
||||
|
||||
echo '<table class="widefat striped"><thead><tr><th>' . esc_html__('Tool', 'wp-lit-mcp') . '</th><th>'
|
||||
. esc_html__('Einstellung', 'wp-lit-mcp') . '</th></tr></thead><tbody>';
|
||||
foreach ($abilities as $ability) {
|
||||
$this->renderTool($policy, $ability, $groupMode !== null);
|
||||
}
|
||||
echo '</tbody></table></details>';
|
||||
}
|
||||
|
||||
private function renderTool(ExposurePolicy $policy, Ability $ability, bool $groupOverride): void
|
||||
{
|
||||
$name = $ability->name();
|
||||
$own = $policy->toolOverride($name);
|
||||
$locked = $policy->disabledByReadonly($ability);
|
||||
$required = in_array($name, ExposurePolicy::REQUIRED, true);
|
||||
|
||||
printf(
|
||||
'<tr><td><strong>%s</strong><br><code>%s</code></td><td><select name="%s[%s]"%s>',
|
||||
esc_html($ability->label()),
|
||||
esc_html($name),
|
||||
esc_attr(ExposurePolicy::OPTION),
|
||||
esc_attr($name),
|
||||
$locked ? ' disabled' : ''
|
||||
);
|
||||
$inherited = $this->modeLabel($policy->inheritedMode($ability));
|
||||
printf(
|
||||
'<option value=""%s>%s</option>',
|
||||
selected($own === null, true, false),
|
||||
esc_html($groupOverride && ! $required
|
||||
/* translators: %s: mode inherited from the group */
|
||||
? sprintf(__('wie Gruppe (%s)', 'wp-lit-mcp'), $inherited)
|
||||
/* translators: %s: default mode */
|
||||
: sprintf(__('Standard (%s)', 'wp-lit-mcp'), $this->modeLabel($policy->defaultMode($ability))))
|
||||
);
|
||||
foreach (ExposurePolicy::MODES as $mode) {
|
||||
if (! ExposurePolicy::isAllowed($name, $mode)) {
|
||||
continue;
|
||||
}
|
||||
printf(
|
||||
'<option value="%s"%s>%s</option>',
|
||||
esc_attr($mode),
|
||||
selected($own === $mode, true, false),
|
||||
esc_html($this->modeLabel($mode))
|
||||
);
|
||||
}
|
||||
echo '</select>';
|
||||
|
||||
if ($locked && $own !== null) {
|
||||
// A disabled select is not submitted – keep the tool's own setting for later.
|
||||
printf(
|
||||
'<input type="hidden" name="%s[%s]" value="%s">',
|
||||
esc_attr(ExposurePolicy::OPTION),
|
||||
esc_attr($name),
|
||||
esc_attr($own)
|
||||
);
|
||||
}
|
||||
|
||||
$note = match (true) {
|
||||
$locked => __('durch ‚nur lesen‘ aus', 'wp-lit-mcp'),
|
||||
$own === null && $policy->groupModeAdjusted($ability)
|
||||
=> __('„indirekt“ geht hier nicht – bleibt direkt', 'wp-lit-mcp'),
|
||||
default => '',
|
||||
};
|
||||
if ($note !== '') {
|
||||
echo ' <em>' . esc_html($note) . '</em>';
|
||||
}
|
||||
echo '</td></tr>';
|
||||
}
|
||||
|
||||
private function groupLabel(string $group): string
|
||||
{
|
||||
return match ($group) {
|
||||
'core' => __('Grundlagen', 'wp-lit-mcp'),
|
||||
'wordpress' => __('WordPress', 'wp-lit-mcp'),
|
||||
'media' => __('Medien', 'wp-lit-mcp'),
|
||||
'pods' => __('Pods', 'wp-lit-mcp'),
|
||||
'divi' => __('Divi', 'wp-lit-mcp'),
|
||||
default => $group,
|
||||
};
|
||||
}
|
||||
|
||||
private function modeLabel(string $mode): string
|
||||
{
|
||||
return match ($mode) {
|
||||
|
||||
Reference in New Issue
Block a user