Skip to content

Commit 40cff59

Browse files
webmycclaude
andcommitted
feat(admin): add Settings > MCP Adapter page to opt registered abilities into the default server (fixes #183)
The default MCP server hides every registered ability by default, requiring site administrators to write the wp_register_ability_args filter documented in WordPress contributor guides. This adds a first-class settings page under Settings > MCP Adapter that exposes a per-ability checkbox UI and persists the selection in a single `mcp_adapter_public_abilities` option. The page is deliberately minimal: - Standard wp-admin h1 + introductory paragraph - One list table of registered abilities with checkboxes - One option, one filter - No React, no annotation-aware UI, no bulk actions, no source filter - PHP 7.4, namespaced, strict_types A more feature-complete reference implementation of the same pattern is shipped as a standalone plugin at https://github.com/respira-press/inhale-mcp-abilities for site owners who want the additional affordances while this PR is in review. The developer-facing path (setting meta.mcp.public = true at registration time) remains the canonical primitive; the settings page is positioned for site owners who don't write PHP. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d0ac080 commit 40cff59

5 files changed

Lines changed: 406 additions & 0 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,10 @@ The MCP Adapter automatically creates a default server that exposes registered W
309309
- Access via HTTP: `/wp-json/mcp/mcp-adapter-default-server`
310310
- Access via STDIO: `wp mcp-adapter serve --server=mcp-adapter-default-server`
311311

312+
### Settings page
313+
314+
Administrators can opt registered abilities into the default MCP server without writing PHP at **Settings → MCP Adapter**. The page lists every ability currently registered on the site and persists the selection in the `mcp_adapter_public_abilities` option. The same `wp_register_ability_args` filter is applied behind the scenes, so the developer flow above (setting `meta.mcp.public = true` directly at registration) remains the canonical path. The settings page is positioned for site owners who don't write PHP.
315+
312316
<details>
313317
<summary><strong>Create a new ability (click to expand)</strong></summary>
314318

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<?php
2+
/**
3+
* Reads the saved settings-page option and toggles `meta.mcp.public` on
4+
* abilities the administrator has opted into the default MCP server.
5+
*
6+
* @package WP\MCP\Admin
7+
*/
8+
9+
declare( strict_types=1 );
10+
11+
namespace WP\MCP\Admin;
12+
13+
/**
14+
* Class - AbilityExposureFilter
15+
*
16+
* Translates a stored array of opted-in ability names into the canonical
17+
* `meta.mcp.public` flag the adapter consumes when materializing the default
18+
* MCP server. Implements the `wp_register_ability_args` pattern documented in
19+
* WordPress contributor guides and used by site administrators today.
20+
*/
21+
final class AbilityExposureFilter {
22+
23+
/**
24+
* Option name where the opted-in ability list is stored.
25+
*
26+
* @var string
27+
*/
28+
public const OPTION = 'mcp_adapter_public_abilities';
29+
30+
/**
31+
* Namespace prefix that the adapter manages internally. Abilities under
32+
* this prefix are exempt from the toggle.
33+
*
34+
* @var string
35+
*/
36+
private const MANAGED_NAMESPACE = 'mcp-adapter/';
37+
38+
/**
39+
* Register the registration-time filter.
40+
*/
41+
public function register(): void {
42+
add_filter( 'wp_register_ability_args', array( $this, 'maybe_expose' ), 10, 2 );
43+
}
44+
45+
/**
46+
* Inject `meta.mcp.public = true` onto abilities the administrator has
47+
* opted into via the settings page.
48+
*
49+
* @param array<string, mixed> $args Ability registration args.
50+
* @param string $ability_name The ability name being registered.
51+
* @return array<string, mixed>
52+
*/
53+
public function maybe_expose( array $args, string $ability_name ): array {
54+
if ( 0 === strpos( $ability_name, self::MANAGED_NAMESPACE ) ) {
55+
return $args;
56+
}
57+
58+
$opted_in = get_option( self::OPTION, array() );
59+
if ( ! is_array( $opted_in ) || ! in_array( $ability_name, $opted_in, true ) ) {
60+
return $args;
61+
}
62+
63+
if ( ! isset( $args['meta'] ) || ! is_array( $args['meta'] ) ) {
64+
$args['meta'] = array();
65+
}
66+
if ( ! isset( $args['meta']['mcp'] ) || ! is_array( $args['meta']['mcp'] ) ) {
67+
$args['meta']['mcp'] = array();
68+
}
69+
$args['meta']['mcp']['public'] = true;
70+
71+
return $args;
72+
}
73+
}

includes/Admin/SettingsPage.php

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
<?php
2+
/**
3+
* Settings page that lets administrators select which registered WordPress
4+
* abilities are exposed to the default MCP server.
5+
*
6+
* @package WP\MCP\Admin
7+
*/
8+
9+
declare( strict_types=1 );
10+
11+
namespace WP\MCP\Admin;
12+
13+
/**
14+
* Class - SettingsPage
15+
*
16+
* Renders Settings > MCP Adapter. Scope is intentionally narrow:
17+
* - single column, native wp-admin chrome
18+
* - one option, `mcp_adapter_public_abilities`
19+
* - one filter, `wp_register_ability_args`
20+
* - no React, no annotation-aware UI, no bulk actions, no source filter
21+
*
22+
* A more feature-complete reference UI lives at
23+
* https://github.com/respira-press/inhale-mcp-abilities for site owners who
24+
* want the additional affordances while this PR goes through review.
25+
*/
26+
final class SettingsPage {
27+
28+
private const MENU_SLUG = 'mcp-adapter';
29+
private const OPTION_GROUP = 'mcp_adapter_settings';
30+
private const CAPABILITY = 'manage_options';
31+
32+
/**
33+
* Wire admin hooks.
34+
*/
35+
public function register(): void {
36+
add_action( 'admin_menu', array( $this, 'register_menu' ) );
37+
add_action( 'admin_init', array( $this, 'register_setting' ) );
38+
}
39+
40+
/**
41+
* Add the page under Settings.
42+
*/
43+
public function register_menu(): void {
44+
add_options_page(
45+
__( 'MCP Adapter', 'mcp-adapter' ),
46+
__( 'MCP Adapter', 'mcp-adapter' ),
47+
self::CAPABILITY,
48+
self::MENU_SLUG,
49+
array( $this, 'render_page' )
50+
);
51+
}
52+
53+
/**
54+
* Register the option backing the page.
55+
*/
56+
public function register_setting(): void {
57+
register_setting(
58+
self::OPTION_GROUP,
59+
AbilityExposureFilter::OPTION,
60+
array(
61+
'type' => 'array',
62+
'description' => __( 'Abilities exposed to the default MCP server.', 'mcp-adapter' ),
63+
'sanitize_callback' => array( $this, 'sanitize_option' ),
64+
'default' => array(),
65+
'show_in_rest' => false,
66+
)
67+
);
68+
}
69+
70+
/**
71+
* Whitelist-based sanitize callback. Only accepts entries that match an
72+
* ability currently registered on this site and is not in the adapter's
73+
* managed namespace.
74+
*
75+
* @param mixed $value Submitted value.
76+
* @return array<int, string>
77+
*/
78+
public function sanitize_option( $value ): array {
79+
if ( ! is_array( $value ) ) {
80+
return array();
81+
}
82+
83+
$known = array_map(
84+
static function ( $name ): string {
85+
return (string) $name;
86+
},
87+
array_keys( $this->discover_abilities() )
88+
);
89+
90+
$out = array();
91+
foreach ( $value as $entry ) {
92+
if ( ! is_string( $entry ) ) {
93+
continue;
94+
}
95+
$entry = sanitize_text_field( wp_unslash( $entry ) );
96+
if ( '' === $entry || 0 === strpos( $entry, 'mcp-adapter/' ) ) {
97+
continue;
98+
}
99+
if ( ! in_array( $entry, $known, true ) ) {
100+
continue;
101+
}
102+
if ( in_array( $entry, $out, true ) ) {
103+
continue;
104+
}
105+
$out[] = $entry;
106+
}
107+
return $out;
108+
}
109+
110+
/**
111+
* Discover registered abilities on this site, keyed by ability name.
112+
*
113+
* @return array<string, array{label: string, description: string, managed: bool}>
114+
*/
115+
public function discover_abilities(): array {
116+
$abilities = array();
117+
if ( ! function_exists( 'wp_get_abilities' ) ) {
118+
return $abilities;
119+
}
120+
121+
$registered = wp_get_abilities();
122+
if ( ! is_array( $registered ) ) {
123+
return $abilities;
124+
}
125+
126+
foreach ( $registered as $ability ) {
127+
$name = '';
128+
$label = '';
129+
$description = '';
130+
131+
if ( is_object( $ability ) ) {
132+
$name = method_exists( $ability, 'get_name' ) ? (string) $ability->get_name() : '';
133+
$label = method_exists( $ability, 'get_label' ) ? (string) $ability->get_label() : '';
134+
$description = method_exists( $ability, 'get_description' ) ? (string) $ability->get_description() : '';
135+
} elseif ( is_array( $ability ) ) {
136+
$name = isset( $ability['name'] ) ? (string) $ability['name'] : '';
137+
$label = isset( $ability['label'] ) ? (string) $ability['label'] : '';
138+
$description = isset( $ability['description'] ) ? (string) $ability['description'] : '';
139+
}
140+
141+
if ( '' === $name ) {
142+
continue;
143+
}
144+
145+
$abilities[ $name ] = array(
146+
'label' => $label,
147+
'description' => $description,
148+
'managed' => 0 === strpos( $name, 'mcp-adapter/' ),
149+
);
150+
}
151+
ksort( $abilities );
152+
return $abilities;
153+
}
154+
155+
/**
156+
* Render the settings page.
157+
*/
158+
public function render_page(): void {
159+
if ( ! current_user_can( self::CAPABILITY ) ) {
160+
wp_die( esc_html__( 'You do not have permission to access this page.', 'mcp-adapter' ) );
161+
}
162+
163+
$abilities = $this->discover_abilities();
164+
$exposed = get_option( AbilityExposureFilter::OPTION, array() );
165+
if ( ! is_array( $exposed ) ) {
166+
$exposed = array();
167+
}
168+
?>
169+
<div class="wrap">
170+
<h1><?php esc_html_e( 'MCP Adapter', 'mcp-adapter' ); ?></h1>
171+
<p>
172+
<?php
173+
esc_html_e(
174+
'Choose which registered WordPress abilities are exposed to the default MCP server. Abilities are hidden by default; checking one sets meta.mcp.public to true on it at registration time.',
175+
'mcp-adapter'
176+
);
177+
?>
178+
</p>
179+
<?php settings_errors(); ?>
180+
<form method="post" action="options.php">
181+
<?php settings_fields( self::OPTION_GROUP ); ?>
182+
<?php if ( empty( $abilities ) ) : ?>
183+
<p>
184+
<em>
185+
<?php
186+
esc_html_e(
187+
'No abilities are currently registered on this site. Activate the plugins or themes that register abilities to see them here.',
188+
'mcp-adapter'
189+
);
190+
?>
191+
</em>
192+
</p>
193+
<?php else : ?>
194+
<table class="wp-list-table widefat fixed striped">
195+
<thead>
196+
<tr>
197+
<th scope="col" style="width:40px;"><span class="screen-reader-text"><?php esc_html_e( 'Expose', 'mcp-adapter' ); ?></span></th>
198+
<th scope="col"><?php esc_html_e( 'Ability', 'mcp-adapter' ); ?></th>
199+
<th scope="col"><?php esc_html_e( 'Description', 'mcp-adapter' ); ?></th>
200+
</tr>
201+
</thead>
202+
<tbody>
203+
<?php foreach ( $abilities as $name => $info ) : ?>
204+
<tr<?php echo $info['managed'] ? ' class="disabled"' : ''; ?>>
205+
<td>
206+
<?php if ( $info['managed'] ) : ?>
207+
<input type="checkbox" disabled aria-label="<?php echo esc_attr( sprintf( /* translators: %s: ability name. */ __( 'Managed by adapter: %s', 'mcp-adapter' ), $name ) ); ?>" />
208+
<?php else : ?>
209+
<input
210+
type="checkbox"
211+
name="<?php echo esc_attr( AbilityExposureFilter::OPTION ); ?>[]"
212+
value="<?php echo esc_attr( $name ); ?>"
213+
<?php checked( in_array( $name, $exposed, true ) ); ?>
214+
aria-label="<?php echo esc_attr( sprintf( /* translators: %s: ability name. */ __( 'Expose %s to the default MCP server', 'mcp-adapter' ), $name ) ); ?>"
215+
/>
216+
<?php endif; ?>
217+
</td>
218+
<td>
219+
<code><?php echo esc_html( $name ); ?></code>
220+
</td>
221+
<td>
222+
<?php
223+
if ( $info['managed'] ) {
224+
echo '<em>' . esc_html__( '(managed by the adapter)', 'mcp-adapter' ) . '</em>';
225+
} else {
226+
echo esc_html( $info['description'] );
227+
}
228+
?>
229+
</td>
230+
</tr>
231+
<?php endforeach; ?>
232+
</tbody>
233+
</table>
234+
<?php endif; ?>
235+
<?php submit_button( __( 'Save changes', 'mcp-adapter' ) ); ?>
236+
</form>
237+
</div>
238+
<?php
239+
}
240+
}

includes/Plugin.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
namespace WP\MCP;
1313

14+
use WP\MCP\Admin\AbilityExposureFilter;
15+
use WP\MCP\Admin\SettingsPage;
1416
use WP\MCP\Core\McpAdapter;
1517

1618
/**
@@ -57,6 +59,15 @@ private function setup(): void {
5759
}
5860

5961
McpAdapter::instance();
62+
63+
// Always register the ability-exposure filter so that opted-in
64+
// abilities receive `meta.mcp.public = true` at registration time.
65+
( new AbilityExposureFilter() )->register();
66+
67+
// Settings page is admin-only.
68+
if ( is_admin() ) {
69+
( new SettingsPage() )->register();
70+
}
6071
}
6172

6273
/**

0 commit comments

Comments
 (0)