resolve_context( $server_id, $context ); // SEC-013-005: cap check via context — never hardcoded manage_options. if ( ! current_user_can( $context['cap'] ) ) { return; } $rows = MCPServerQuery::instance()->query( array( 'id' => $server_id, 'number' => 1, ) ); if ( empty( $rows ) ) { $this->render_missing_server_notice(); return; } $this->render_body( $rows[0]->to_array(), $context ); } /** * Merges context defaults and applies the acrossai_mcp_client_block_context * filter. Casts the filter return to (array) per SEC-013-003. * * @since 0.0.6 * @experimental May change without notice before 1.0.0. * * @param int $server_id MCP server ID. * @param array $context Caller-provided context. * @return array */ protected function resolve_context( int $server_id, array $context ): array { $context_slug = isset( $context['context'] ) ? sanitize_key( (string) $context['context'] ) : 'admin'; $defaults = array( 'context' => $context_slug, 'cap' => 'manage_options', 'submit_target_url' => admin_url( 'admin.php?page=' . AdminPageSlugs::PARENT ), 'nonce_action' => 'acrossai_mcp_render_' . $this->slug() . '_' . $server_id . '_' . $context_slug, 'user_id' => get_current_user_id(), 'copy_button' => true, 'sub_client' => '', ); $context = wp_parse_args( $context, $defaults ); $filtered = apply_filters( 'acrossai_mcp_client_block_context', $context, $this->slug(), $server_id ); // SEC-013-003: a third-party callback returning a non-array MUST NOT // fatal, and the defaults MUST still apply. A bare `(array)` cast is // not enough for the second half — `(array) null` is `[]`, which drops // every default and leaves render() reading an undefined 'cap' key. // Re-merge so the contract in this method's docblock actually holds. return wp_parse_args( is_array( $filtered ) ? $filtered : array(), $defaults ); } /** * Renders the "Generate New Application Password" button. Disabled when * $context['user_id'] does not equal get_current_user_id() (defense * against admin-impersonation when embedded in a third-party context). * * @since 0.0.6 * @experimental May change without notice before 1.0.0. (SEC-013-002) * * @param array $server Server row data. * @param array $context Resolved context array. * @return void */ protected function passwords_generate_button( array $server, array $context ): void { $user_id_match = get_current_user_id() === (int) $context['user_id']; $attrs = $user_id_match ? '' : ' disabled aria-disabled="true"'; $endpoint = rest_url( 'acrossai-mcp-manager/v1/generate-app-password' ); $nonce = wp_create_nonce( 'wp_rest' ); // Sub-clients (e.g. MCP Clients Block's per-client tabs) split on // $context['sub_client']; fall back to the block's slug otherwise. $client_slug = '' !== (string) ( $context['sub_client'] ?? '' ) ? sanitize_key( (string) $context['sub_client'] ) : sanitize_key( $this->slug() ); printf( '' . '', esc_attr( (string) (int) $server['id'] ), esc_attr( $client_slug ), esc_attr( sanitize_key( (string) $context['context'] ) ), esc_url( $endpoint ), esc_attr( $nonce ), esc_attr( $attrs ), esc_html__( 'Generate New Application Password', 'acrossai-mcp-manager' ) ); if ( ! $user_id_match ) { printf( '
%s
', esc_html__( 'Application Passwords can only be generated for the current user.', 'acrossai-mcp-manager' ) ); } } /** * Emits a JSON configuration block for a specific MCP client.
*
* @since 0.0.6
* @experimental May change without notice before 1.0.0.
*
* @param array $server Server row data.
* @param string $client_slug Client slug.
* @param array $config Config array to JSON-encode.
* @return void
*/
protected function config_json_pre_block( array $server, string $client_slug, array $config ): void {
$json = wp_json_encode( $config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES );
printf(
'%3$s
',
esc_attr( sanitize_key( $client_slug ) ),
esc_attr( (string) (int) $server['id'] ),
esc_html( (string) $json )
);
}
/**
* Renders the "Copy Configuration" JS button for a config block.
*
* @since 0.0.6
* @experimental May change without notice before 1.0.0.
*
* @param string $target_selector CSS selector of the to copy.
* @return void
*/
protected function copy_config_button( string $target_selector ): void {
printf(
'',
esc_attr( $target_selector ),
esc_html__( 'Copy Configuration', 'acrossai-mcp-manager' )
);
}
/**
* Renders a "server not found" notice when the server ID is invalid.
*
* @since 0.0.6
* @return void
*/
protected function render_missing_server_notice(): void {
printf(
'%s
',
esc_html__( 'MCP server not found.', 'acrossai-mcp-manager' )
);
}
/**
* Renders the "feature currently disabled" notice with a link back to
* the MCP settings tab. Used by NpmClientBlock when its F012 toggle is
* off. (SEC-013-005)
*
* @since 0.0.6
* @experimental May change without notice before 1.0.0.
*
* @param string $feature_label Feature label (e.g. 'npm / npx CLI').
* @param string $enable_link_text Link text (e.g. 'enable CLI Connections').
* @param string $explanation Explanatory paragraph.
* @return void
*/
protected function render_feature_disabled_notice( string $feature_label, string $enable_link_text, string $explanation ): void {
$settings_url = admin_url( 'admin.php?page=acrossai-settings&tab=mcp' );
printf(
'',
esc_html__( 'This feature is currently disabled.', 'acrossai-mcp-manager' ),
esc_html( sprintf( /* translators: %s: feature name */ __( 'To use the %s feature, please', 'acrossai-mcp-manager' ), $feature_label ) ),
esc_url( $settings_url ),
esc_html( $enable_link_text ),
esc_html( $explanation )
);
}
/**
* Renders a section heading inside a block body.
*
* @since 0.0.6
* @param string $heading Heading text (already i18n'd).
* @return void
*/
protected function render_section_heading( string $heading ): void {
printf( '%s
', esc_html( $heading ) );
}
/**
* Shortcode entry point. Subclasses may override; default just renders
* with the shortcode's server attribute.
*
* @since 0.0.6
* @experimental May change without notice before 1.0.0.
*
* @param array|string $atts Shortcode attributes.
* @return string
*/
public static function shortcode( $atts ): string {
$atts = shortcode_atts(
array(
'server' => '0',
),
is_array( $atts ) ? $atts : array(),
'acrossai_mcp_block'
);
$server_id = absint( $atts['server'] );
if ( 0 === $server_id ) {
return '';
}
ob_start();
static::instance()->render( $server_id, array( 'context' => 'shortcode' ) );
return (string) ob_get_clean();
}
/**
* Singleton instance accessor. Subclasses each maintain their own $instance.
*
* @since 0.0.6
* @return static
*/
abstract public static function instance();
}